@hank-warren/pi-plan-mode 0.1.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/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +313 -0
- package/index.ts +1 -0
- package/package.json +47 -0
- package/src/active-implementation-menu.ts +68 -0
- package/src/auto-permissions-delegation.ts +122 -0
- package/src/command.ts +30 -0
- package/src/completion-tool.ts +91 -0
- package/src/extension-runtime.ts +24 -0
- package/src/fresh-implementation.ts +213 -0
- package/src/implementation-retention.ts +122 -0
- package/src/index.ts +1 -0
- package/src/interactive-ui.ts +5 -0
- package/src/message-transform.ts +232 -0
- package/src/plan-action-controller.ts +103 -0
- package/src/plan-action-menus.ts +197 -0
- package/src/plan-export-controller.ts +38 -0
- package/src/plan-export-screen.ts +19 -0
- package/src/plan-export.ts +145 -0
- package/src/plan-launch-menu.ts +122 -0
- package/src/plan-mode.ts +1037 -0
- package/src/presentation.ts +108 -0
- package/src/prompt.ts +67 -0
- package/src/question-tool.ts +273 -0
- package/src/required-tools.ts +22 -0
- package/src/saved-plan-menu.ts +93 -0
- package/src/saved-plan-preflight.ts +39 -0
- package/src/settings-menu.ts +384 -0
- package/src/settings.ts +420 -0
- package/src/state.ts +167 -0
- package/src/tool-policy.ts +563 -0
- package/src/tool-selection.ts +98 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Markdown } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export const PLAN_MODE_COMPLETE_TOOL_NAME = "plan_mode_complete";
|
|
5
|
+
export const PLAN_MODE_COMPLETE_VERSION = 1;
|
|
6
|
+
export const PLAN_MODE_MAX_CHARS = 50_000;
|
|
7
|
+
|
|
8
|
+
export type PlanModeCompletionDetails = {
|
|
9
|
+
version: typeof PLAN_MODE_COMPLETE_VERSION;
|
|
10
|
+
source: typeof PLAN_MODE_COMPLETE_TOOL_NAME;
|
|
11
|
+
plan: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const PLAN_MODE_COMPLETE_PARAMS = {
|
|
15
|
+
type: "object",
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
required: ["plan"],
|
|
18
|
+
properties: {
|
|
19
|
+
plan: {
|
|
20
|
+
type: "string",
|
|
21
|
+
minLength: 1,
|
|
22
|
+
maxLength: PLAN_MODE_MAX_CHARS,
|
|
23
|
+
description: "The complete decision-ready implementation plan in Markdown.",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
type NormalizePlanModeCompletionResult = { ok: true; plan: string } | { ok: false; error: string };
|
|
29
|
+
|
|
30
|
+
export function normalizePlanModeCompletion(input: unknown): NormalizePlanModeCompletionResult {
|
|
31
|
+
if (!isRecord(input) || typeof input.plan !== "string") {
|
|
32
|
+
return { ok: false, error: "plan must be a string" };
|
|
33
|
+
}
|
|
34
|
+
const plan = input.plan.trim();
|
|
35
|
+
if (!plan) return { ok: false, error: "plan must not be empty" };
|
|
36
|
+
if (plan.length > PLAN_MODE_MAX_CHARS) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
error: `plan must not exceed ${PLAN_MODE_MAX_CHARS} characters`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, plan };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function planFromCompletionDetails(value: unknown) {
|
|
46
|
+
if (!isRecord(value)) return undefined;
|
|
47
|
+
if (
|
|
48
|
+
value.version !== PLAN_MODE_COMPLETE_VERSION ||
|
|
49
|
+
value.source !== PLAN_MODE_COMPLETE_TOOL_NAME
|
|
50
|
+
) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const normalized = normalizePlanModeCompletion({ plan: value.plan });
|
|
54
|
+
return normalized.ok ? normalized.plan : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function planModeCompleted(plan: string) {
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text" as const, text: `**Proposed Plan**\n\n${plan}` }],
|
|
60
|
+
details: {
|
|
61
|
+
version: PLAN_MODE_COMPLETE_VERSION,
|
|
62
|
+
source: PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
63
|
+
plan,
|
|
64
|
+
} satisfies PlanModeCompletionDetails,
|
|
65
|
+
terminate: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type PlanModeCompletionRenderResult = {
|
|
70
|
+
content: Array<{ type: string; text?: string }>;
|
|
71
|
+
details?: unknown;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function planModeCompletionMarkdown(result: PlanModeCompletionRenderResult) {
|
|
75
|
+
const content = result.content
|
|
76
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
77
|
+
.map((block) => block.text)
|
|
78
|
+
.join("\n")
|
|
79
|
+
.trim();
|
|
80
|
+
if (content) return content;
|
|
81
|
+
const plan = planFromCompletionDetails(result.details);
|
|
82
|
+
return plan ? `**Proposed Plan**\n\n${plan}` : "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function renderPlanModeCompletion(result: PlanModeCompletionRenderResult) {
|
|
86
|
+
return new Markdown(planModeCompletionMarkdown(result), 0, 0, getMarkdownTheme());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
90
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
91
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { PlanModeFixedThinkingLevel } from "./settings.js";
|
|
3
|
+
|
|
4
|
+
type AgentSettledHandler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
5
|
+
|
|
6
|
+
export function onAgentSettled(pi: ExtensionAPI, handler: AgentSettledHandler) {
|
|
7
|
+
(
|
|
8
|
+
pi as unknown as {
|
|
9
|
+
on(event: "agent_settled", callback: AgentSettledHandler): void;
|
|
10
|
+
}
|
|
11
|
+
).on("agent_settled", handler);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setPlanThinkingLevel(pi: ExtensionAPI, level: PlanModeFixedThinkingLevel) {
|
|
15
|
+
(pi.setThinkingLevel as unknown as (level: PlanModeFixedThinkingLevel) => void)(level);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isStaleExtensionContextError(error: unknown) {
|
|
19
|
+
return (
|
|
20
|
+
error instanceof Error &&
|
|
21
|
+
(error.message.includes("This extension ctx is stale after session replacement or reload") ||
|
|
22
|
+
error.message.includes("Extension context is no longer active"))
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ImplementationPlanRetention } from "./settings.js";
|
|
4
|
+
import type { PlanCompletionSource, PlanModeState } from "./state.js";
|
|
5
|
+
|
|
6
|
+
type NewSessionOptions = Exclude<Parameters<ExtensionCommandContext["newSession"]>[0], undefined>;
|
|
7
|
+
type ReplacementContext = Parameters<NonNullable<NewSessionOptions["withSession"]>>[0];
|
|
8
|
+
|
|
9
|
+
export interface FreshImplementationRequest {
|
|
10
|
+
plan: string;
|
|
11
|
+
source: PlanCompletionSource;
|
|
12
|
+
retention: ImplementationPlanRetention;
|
|
13
|
+
stateEntryType: string;
|
|
14
|
+
isCurrent(): boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FreshImplementationFromStateOptions {
|
|
18
|
+
getState(): PlanModeState;
|
|
19
|
+
menuIsCurrent(): boolean;
|
|
20
|
+
retention: ImplementationPlanRetention;
|
|
21
|
+
stateEntryType: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FreshImplementationResult =
|
|
25
|
+
| { kind: "started" }
|
|
26
|
+
| { kind: "cancelled" }
|
|
27
|
+
| { kind: "partial" }
|
|
28
|
+
| { kind: "rejected" }
|
|
29
|
+
| { kind: "stale" };
|
|
30
|
+
|
|
31
|
+
export function formatImplementationHandoff(plan: string) {
|
|
32
|
+
return `Plan mode is now disabled. Full tool access is restored. Implement this proposed plan now:\n\n${plan}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function startFreshImplementationFromState(
|
|
36
|
+
ctx: ExtensionContext,
|
|
37
|
+
options: FreshImplementationFromStateOptions,
|
|
38
|
+
) {
|
|
39
|
+
if (!isCommandContext(ctx)) {
|
|
40
|
+
ctx.ui.notify(
|
|
41
|
+
"Fresh implementation requires the interactive /plan command. Reopen /plan and try again.",
|
|
42
|
+
"warning",
|
|
43
|
+
);
|
|
44
|
+
return { kind: "rejected" } as const;
|
|
45
|
+
}
|
|
46
|
+
const initialState = options.getState();
|
|
47
|
+
const savedPlan = initialState.enabled ? undefined : initialState.savedPlan;
|
|
48
|
+
const plan = (initialState.enabled ? initialState.latestPlan : savedPlan?.plan)?.trim();
|
|
49
|
+
const source = initialState.enabled ? initialState.latestPlanSource : savedPlan?.source;
|
|
50
|
+
if (!plan || !source) {
|
|
51
|
+
ctx.ui.notify("No completed plan is available to implement.", "warning");
|
|
52
|
+
return { kind: "rejected" } as const;
|
|
53
|
+
}
|
|
54
|
+
const wasEnabled = initialState.enabled;
|
|
55
|
+
const isCurrent = () => {
|
|
56
|
+
const current = options.getState();
|
|
57
|
+
return (
|
|
58
|
+
options.menuIsCurrent() &&
|
|
59
|
+
current.enabled === wasEnabled &&
|
|
60
|
+
(wasEnabled
|
|
61
|
+
? current.latestPlan === plan && current.latestPlanSource === source
|
|
62
|
+
: current.savedPlan === savedPlan)
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
return startFreshImplementationSession(ctx, {
|
|
66
|
+
plan,
|
|
67
|
+
source,
|
|
68
|
+
retention: options.retention,
|
|
69
|
+
stateEntryType: options.stateEntryType,
|
|
70
|
+
isCurrent,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function startFreshImplementationSession(
|
|
75
|
+
ctx: ExtensionCommandContext,
|
|
76
|
+
request: FreshImplementationRequest,
|
|
77
|
+
): Promise<FreshImplementationResult> {
|
|
78
|
+
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
79
|
+
throw new Error("Fresh plan implementation is unavailable in print/JSON mode. Use TUI or RPC.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await ctx.waitForIdle();
|
|
83
|
+
if (!request.isCurrent()) return { kind: "stale" };
|
|
84
|
+
if (!(await preflightModel(ctx, request.isCurrent))) return { kind: "rejected" };
|
|
85
|
+
if (!request.isCurrent()) return { kind: "stale" };
|
|
86
|
+
|
|
87
|
+
const activeImplementation = {
|
|
88
|
+
id: randomUUID(),
|
|
89
|
+
plan: request.plan,
|
|
90
|
+
source: request.source,
|
|
91
|
+
startedAt: Date.now(),
|
|
92
|
+
retention: request.retention,
|
|
93
|
+
};
|
|
94
|
+
const destinationState: PlanModeState = {
|
|
95
|
+
enabled: false,
|
|
96
|
+
awaitingAction: false,
|
|
97
|
+
activeImplementation,
|
|
98
|
+
};
|
|
99
|
+
const handoff = formatImplementationHandoff(request.plan);
|
|
100
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
101
|
+
let setupError: string | undefined;
|
|
102
|
+
let kickoffError: string | undefined;
|
|
103
|
+
|
|
104
|
+
if (ctx.mode === "rpc") ctx.ui.notify("Starting fresh implementation session…", "info");
|
|
105
|
+
|
|
106
|
+
let result: Awaited<ReturnType<ExtensionCommandContext["newSession"]>>;
|
|
107
|
+
try {
|
|
108
|
+
result = await ctx.newSession({
|
|
109
|
+
...(parentSession ? { parentSession } : {}),
|
|
110
|
+
setup: async (sessionManager) => {
|
|
111
|
+
try {
|
|
112
|
+
sessionManager.appendCustomEntry(request.stateEntryType, destinationState);
|
|
113
|
+
} catch (error: unknown) {
|
|
114
|
+
setupError = safeErrorDetail(error);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
withSession: async (replacementCtx) => {
|
|
118
|
+
if (setupError) {
|
|
119
|
+
recoverSetupFailure(replacementCtx, handoff, setupError);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await replacementCtx.sendUserMessage(handoff);
|
|
124
|
+
replacementCtx.ui.notify(
|
|
125
|
+
"Fresh implementation session started. Only the approved plan was transferred.",
|
|
126
|
+
"info",
|
|
127
|
+
);
|
|
128
|
+
} catch (error: unknown) {
|
|
129
|
+
kickoffError = safeErrorDetail(error);
|
|
130
|
+
replacementCtx.ui.notify(
|
|
131
|
+
`Fresh session created, but implementation did not start: ${kickoffError}. Send a message to continue, use /plan exit to clear the active plan, or resume the parent planning session.`,
|
|
132
|
+
"error",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
} catch (error: unknown) {
|
|
138
|
+
safeNotify(
|
|
139
|
+
ctx,
|
|
140
|
+
`Unable to start a fresh implementation session: ${safeErrorDetail(error)}. The source plan remains available; retry or resume the planning session.`,
|
|
141
|
+
"error",
|
|
142
|
+
);
|
|
143
|
+
return { kind: "rejected" };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (result.cancelled) {
|
|
147
|
+
ctx.ui.notify("Fresh implementation cancelled. The plan remains available.", "info");
|
|
148
|
+
return { kind: "cancelled" };
|
|
149
|
+
}
|
|
150
|
+
return setupError || kickoffError ? { kind: "partial" } : { kind: "started" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function preflightModel(ctx: ExtensionCommandContext, isCurrent: () => boolean) {
|
|
154
|
+
const model = ctx.model;
|
|
155
|
+
if (!model) {
|
|
156
|
+
ctx.ui.notify("Unable to implement the plan: no model is selected.", "warning");
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
let auth: Awaited<ReturnType<ExtensionCommandContext["modelRegistry"]["getApiKeyAndHeaders"]>>;
|
|
160
|
+
try {
|
|
161
|
+
auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
162
|
+
} catch (error: unknown) {
|
|
163
|
+
if (isCurrent()) {
|
|
164
|
+
ctx.ui.notify(`Unable to implement the plan: ${safeErrorDetail(error)}`, "error");
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (!isCurrent()) return false;
|
|
169
|
+
if (!auth.ok) {
|
|
170
|
+
ctx.ui.notify(`Unable to implement the plan: ${safeErrorDetail(auth.error)}`, "warning");
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function recoverSetupFailure(ctx: ReplacementContext, handoff: string, setupError: string) {
|
|
177
|
+
ctx.ui.setEditorText(handoff);
|
|
178
|
+
ctx.ui.notify(
|
|
179
|
+
`Fresh session created, but the active plan could not be saved: ${setupError}. The implementation request is in the editor; submit it to continue or resume the parent planning session.`,
|
|
180
|
+
"error",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function safeNotify(
|
|
185
|
+
ctx: ExtensionCommandContext,
|
|
186
|
+
message: string,
|
|
187
|
+
level: "info" | "warning" | "error",
|
|
188
|
+
) {
|
|
189
|
+
try {
|
|
190
|
+
ctx.ui.notify(message, level);
|
|
191
|
+
} catch {
|
|
192
|
+
// The source context can become stale if Pi fails after replacement teardown.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isCommandContext(ctx: ExtensionContext): ctx is ExtensionCommandContext {
|
|
197
|
+
return typeof (ctx as Partial<ExtensionCommandContext>).newSession === "function";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function safeErrorDetail(error: unknown) {
|
|
201
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
202
|
+
const normalized =
|
|
203
|
+
[...detail]
|
|
204
|
+
.map((character) => {
|
|
205
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
206
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) ? " " : character;
|
|
207
|
+
})
|
|
208
|
+
.join("")
|
|
209
|
+
.replace(/\s+/gu, " ")
|
|
210
|
+
.trim() || "unknown error";
|
|
211
|
+
const characters = [...normalized];
|
|
212
|
+
return characters.length > 500 ? `${characters.slice(0, 499).join("")}…` : normalized;
|
|
213
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
injectActiveImplementationContext,
|
|
3
|
+
isEmptyAssistantMessage,
|
|
4
|
+
messageContainsExactPlanModeImplementationHandoff,
|
|
5
|
+
messageContainsInactivePlanModeArtifact,
|
|
6
|
+
messageContainsLegacyPlanModeContextArtifact,
|
|
7
|
+
messageContainsPlanModeImplementationContextArtifact,
|
|
8
|
+
messageContainsPlanModeImplementationHandoff,
|
|
9
|
+
stripPlanModeCompletionCallsFromMessage,
|
|
10
|
+
stripProposedPlanBlocksFromMessage,
|
|
11
|
+
} from "./message-transform.js";
|
|
12
|
+
import type { ImplementationPlanRetention } from "./settings.js";
|
|
13
|
+
import type { ActiveImplementationPlan, PlanModeState } from "./state.js";
|
|
14
|
+
|
|
15
|
+
export function retentionLabel(retention: ImplementationPlanRetention) {
|
|
16
|
+
return {
|
|
17
|
+
keep: "Keep plan active",
|
|
18
|
+
"clear-on-start": "Use plan for handoff only",
|
|
19
|
+
"clear-after-first-run": "Clear after first implementation run",
|
|
20
|
+
}[retention];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function implementationRetentionPreview(retention: ImplementationPlanRetention) {
|
|
24
|
+
return {
|
|
25
|
+
keep: "After Implement: Keep plan active until /plan exit.",
|
|
26
|
+
"clear-on-start":
|
|
27
|
+
"After Implement: Use the plan for the implementation handoff only, then clear it.",
|
|
28
|
+
"clear-after-first-run": "After Implement: Clear after the first implementation run settles.",
|
|
29
|
+
}[retention];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ImplementationContextResult {
|
|
33
|
+
messages: unknown[];
|
|
34
|
+
clearActiveImplementationId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ImplementationRetentionCoordinator {
|
|
38
|
+
restore(activeImplementation: ActiveImplementationPlan | undefined): void;
|
|
39
|
+
transformContext(messages: unknown[], state: PlanModeState): ImplementationContextResult;
|
|
40
|
+
implementationSettled(
|
|
41
|
+
activeImplementation: ActiveImplementationPlan | undefined,
|
|
42
|
+
): string | undefined;
|
|
43
|
+
reset(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createImplementationRetentionCoordinator(): ImplementationRetentionCoordinator {
|
|
47
|
+
let implementationWithDeliveredContext: string | undefined;
|
|
48
|
+
let restoredImplementationAwaitingContext: string | undefined;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
restore(activeImplementation) {
|
|
52
|
+
restoredImplementationAwaitingContext =
|
|
53
|
+
activeImplementation && activeImplementation.retention !== "keep"
|
|
54
|
+
? activeImplementation.id
|
|
55
|
+
: undefined;
|
|
56
|
+
},
|
|
57
|
+
transformContext(messages, state) {
|
|
58
|
+
const messagesWithoutPlanContext = messages.filter(
|
|
59
|
+
(message) =>
|
|
60
|
+
!messageContainsLegacyPlanModeContextArtifact(message) &&
|
|
61
|
+
!messageContainsPlanModeImplementationContextArtifact(message),
|
|
62
|
+
);
|
|
63
|
+
if (state.enabled) {
|
|
64
|
+
return {
|
|
65
|
+
messages: messagesWithoutPlanContext.filter(
|
|
66
|
+
(message) => !messageContainsPlanModeImplementationHandoff(message),
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const activeImplementation = state.activeImplementation;
|
|
72
|
+
const inactiveMessages = activeImplementation
|
|
73
|
+
? messagesWithoutPlanContext
|
|
74
|
+
: messagesWithoutPlanContext.filter(
|
|
75
|
+
(message) => !messageContainsPlanModeImplementationHandoff(message),
|
|
76
|
+
);
|
|
77
|
+
const filteredMessages = inactiveMessages
|
|
78
|
+
.filter((message) => !messageContainsInactivePlanModeArtifact(message))
|
|
79
|
+
.map(stripProposedPlanBlocksFromMessage)
|
|
80
|
+
.map(stripPlanModeCompletionCallsFromMessage)
|
|
81
|
+
.filter((message) => !isEmptyAssistantMessage(message));
|
|
82
|
+
if (!activeImplementation) return { messages: filteredMessages };
|
|
83
|
+
|
|
84
|
+
const contextualMessages = injectActiveImplementationContext(
|
|
85
|
+
filteredMessages,
|
|
86
|
+
activeImplementation,
|
|
87
|
+
);
|
|
88
|
+
// A busy /plan implement queues its handoff behind an older run. Do not arm cleanup
|
|
89
|
+
// until that exact handoff reaches context; a restored session has no older run to drain.
|
|
90
|
+
const deliveredCurrentHandoff =
|
|
91
|
+
restoredImplementationAwaitingContext === activeImplementation.id ||
|
|
92
|
+
filteredMessages.some((message) =>
|
|
93
|
+
messageContainsExactPlanModeImplementationHandoff(message, activeImplementation.plan),
|
|
94
|
+
);
|
|
95
|
+
if (!deliveredCurrentHandoff) return { messages: contextualMessages };
|
|
96
|
+
restoredImplementationAwaitingContext = undefined;
|
|
97
|
+
|
|
98
|
+
if (activeImplementation.retention === "clear-after-first-run") {
|
|
99
|
+
implementationWithDeliveredContext = activeImplementation.id;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
messages: contextualMessages,
|
|
103
|
+
clearActiveImplementationId:
|
|
104
|
+
activeImplementation.retention === "clear-on-start" ? activeImplementation.id : undefined,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
implementationSettled(activeImplementation) {
|
|
108
|
+
if (
|
|
109
|
+
activeImplementation?.retention !== "clear-after-first-run" ||
|
|
110
|
+
implementationWithDeliveredContext !== activeImplementation.id
|
|
111
|
+
) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
implementationWithDeliveredContext = undefined;
|
|
115
|
+
return activeImplementation.id;
|
|
116
|
+
},
|
|
117
|
+
reset() {
|
|
118
|
+
implementationWithDeliveredContext = undefined;
|
|
119
|
+
restoredImplementationAwaitingContext = undefined;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./plan-mode.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { showActiveImplementationMenu } from "./active-implementation-menu.js";
|
|
2
|
+
export { showPlanModeMenu, showReadyPlanMenu } from "./plan-action-menus.js";
|
|
3
|
+
export { showPlanLaunchMenu } from "./plan-launch-menu.js";
|
|
4
|
+
export { showSavedPlanMenu } from "./saved-plan-menu.js";
|
|
5
|
+
export { showPlanModeSettings } from "./settings-menu.js";
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { PLAN_MODE_COMPLETE_TOOL_NAME } from "./completion-tool.js";
|
|
2
|
+
import type { ActiveImplementationPlan } from "./state.js";
|
|
3
|
+
|
|
4
|
+
const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
|
|
5
|
+
export const PLAN_IMPLEMENTATION_CONTEXT_MESSAGE_TYPE = "plan-mode-implementation-context";
|
|
6
|
+
const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
|
|
7
|
+
const PLAN_IMPLEMENTATION_HANDOFF_PREFIX =
|
|
8
|
+
"Plan mode is now disabled. Full tool access is restored. Implement this proposed plan now:";
|
|
9
|
+
const PROPOSED_PLAN_PATTERN =
|
|
10
|
+
/^<proposed_plan>[\t ]*\r?\n([\s\S]*?)\r?\n<\/proposed_plan>[\t ]*$/gm;
|
|
11
|
+
const PROPOSED_PLAN_BLOCK_PATTERN =
|
|
12
|
+
/^<proposed_plan>[\t ]*\r?\n[\s\S]*?\r?\n<\/proposed_plan>[\t ]*$/gm;
|
|
13
|
+
|
|
14
|
+
export type ProposedPlanParseResult =
|
|
15
|
+
| { kind: "absent" }
|
|
16
|
+
| { kind: "valid"; plan: string }
|
|
17
|
+
| { kind: "empty" }
|
|
18
|
+
| { kind: "multiple" }
|
|
19
|
+
| { kind: "malformed" }
|
|
20
|
+
| { kind: "unclosed" };
|
|
21
|
+
|
|
22
|
+
type SessionMessage = {
|
|
23
|
+
role?: string;
|
|
24
|
+
content?: unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type TextBlock = {
|
|
28
|
+
type?: string;
|
|
29
|
+
text?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function parseProposedPlan(text: string): ProposedPlanParseResult {
|
|
33
|
+
const openingCount = text.match(/<proposed_plan>/gi)?.length ?? 0;
|
|
34
|
+
const closingCount = text.match(/<\/proposed_plan>/gi)?.length ?? 0;
|
|
35
|
+
if (openingCount === 0 && closingCount === 0) return { kind: "absent" };
|
|
36
|
+
if (openingCount > 1 || closingCount > 1) return { kind: "multiple" };
|
|
37
|
+
if (openingCount === 1 && closingCount === 0) return { kind: "unclosed" };
|
|
38
|
+
if (openingCount !== 1 || closingCount !== 1) return { kind: "malformed" };
|
|
39
|
+
|
|
40
|
+
const matches = Array.from(text.matchAll(PROPOSED_PLAN_PATTERN));
|
|
41
|
+
if (matches.length !== 1) return { kind: "malformed" };
|
|
42
|
+
const plan = matches[0]?.[1]?.trim() ?? "";
|
|
43
|
+
return plan ? { kind: "valid", plan } : { kind: "empty" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function extractProposedPlan(text: string) {
|
|
47
|
+
const result = parseProposedPlan(text);
|
|
48
|
+
return result.kind === "valid" ? result.plan : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function invalidPlanMessage(kind: "empty" | "multiple" | "malformed" | "unclosed") {
|
|
52
|
+
const detail = {
|
|
53
|
+
empty: "the block is empty",
|
|
54
|
+
multiple: "more than one plan block was produced",
|
|
55
|
+
malformed: "the tags must be on their own lines",
|
|
56
|
+
unclosed: "the closing tag is missing",
|
|
57
|
+
}[kind];
|
|
58
|
+
return `Proposed plan is not ready: ${detail}. Continue Plan mode and produce one complete non-empty <proposed_plan> block.`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function latestAssistantText(messages: unknown) {
|
|
62
|
+
if (!Array.isArray(messages)) return "";
|
|
63
|
+
for (const entry of [...messages].reverse()) {
|
|
64
|
+
const message = (entry as { message?: SessionMessage })?.message ?? (entry as SessionMessage);
|
|
65
|
+
if (message?.role !== "assistant") continue;
|
|
66
|
+
const text = messageText(message);
|
|
67
|
+
if (text) return text;
|
|
68
|
+
}
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
|
|
73
|
+
return unwrapSessionMessage(message).customType === PLAN_CONTEXT_MESSAGE_TYPE;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function messageContainsPlanModeImplementationContextArtifact(message: unknown) {
|
|
77
|
+
return unwrapSessionMessage(message).customType === PLAN_IMPLEMENTATION_CONTEXT_MESSAGE_TYPE;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function injectActiveImplementationContext(
|
|
81
|
+
messages: unknown[],
|
|
82
|
+
activeImplementation: ActiveImplementationPlan,
|
|
83
|
+
) {
|
|
84
|
+
let foundCurrentHandoff = false;
|
|
85
|
+
const messagesWithoutStaleContext = messages.filter((message) => {
|
|
86
|
+
if (messageContainsPlanModeImplementationContextArtifact(message)) return false;
|
|
87
|
+
if (!messageContainsPlanModeImplementationHandoff(message)) return true;
|
|
88
|
+
if (
|
|
89
|
+
!foundCurrentHandoff &&
|
|
90
|
+
messageContainsExactPlanModeImplementationHandoff(message, activeImplementation.plan)
|
|
91
|
+
) {
|
|
92
|
+
foundCurrentHandoff = true;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
});
|
|
97
|
+
if (foundCurrentHandoff) return messagesWithoutStaleContext;
|
|
98
|
+
|
|
99
|
+
let insertionIndex = 0;
|
|
100
|
+
while (isSummaryMessage(messagesWithoutStaleContext[insertionIndex])) insertionIndex += 1;
|
|
101
|
+
const contextMessage = {
|
|
102
|
+
role: "custom" as const,
|
|
103
|
+
customType: PLAN_IMPLEMENTATION_CONTEXT_MESSAGE_TYPE,
|
|
104
|
+
content: `[ACTIVE IMPLEMENTATION PLAN]\n\nThe user approved the exact implementation plan below. Continue following it until the user explicitly clears or supersedes it. The exact plan is the remainder of this message:\n\n${activeImplementation.plan}`,
|
|
105
|
+
display: false,
|
|
106
|
+
timestamp: activeImplementation.startedAt,
|
|
107
|
+
};
|
|
108
|
+
return [
|
|
109
|
+
...messagesWithoutStaleContext.slice(0, insertionIndex),
|
|
110
|
+
contextMessage,
|
|
111
|
+
...messagesWithoutStaleContext.slice(insertionIndex),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function messageContainsInactivePlanModeArtifact(message: unknown) {
|
|
116
|
+
const candidate = unwrapSessionMessage(message);
|
|
117
|
+
return (
|
|
118
|
+
candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE ||
|
|
119
|
+
(candidate.role === "toolResult" && candidate.toolName === PLAN_MODE_COMPLETE_TOOL_NAME)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function messageContainsPlanModeImplementationHandoff(message: unknown) {
|
|
124
|
+
const candidate = unwrapSessionMessage(message);
|
|
125
|
+
return (
|
|
126
|
+
candidate.role === "user" &&
|
|
127
|
+
contentText(candidate.content).trimStart().startsWith(PLAN_IMPLEMENTATION_HANDOFF_PREFIX)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function messageContainsExactPlanModeImplementationHandoff(message: unknown, plan: string) {
|
|
132
|
+
const candidate = unwrapSessionMessage(message);
|
|
133
|
+
if (candidate.role !== "user") return false;
|
|
134
|
+
return (
|
|
135
|
+
contentText(candidate.content).trim() ===
|
|
136
|
+
`${PLAN_IMPLEMENTATION_HANDOFF_PREFIX}\n\n${plan}`.trim()
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isSummaryMessage(message: unknown) {
|
|
141
|
+
const role = unwrapSessionMessage(message)?.role;
|
|
142
|
+
return role === "compactionSummary" || role === "branchSummary";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
|
|
146
|
+
return replaceAssistantContent(message, stripProposedPlanBlocksFromContent);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function stripPlanModeCompletionCallsFromMessage<T>(message: T): T {
|
|
150
|
+
return replaceAssistantContent(message, (content) => {
|
|
151
|
+
if (!Array.isArray(content)) return content;
|
|
152
|
+
const nextContent = content.filter((block) => {
|
|
153
|
+
const candidate = block as { type?: string; name?: string };
|
|
154
|
+
return !(candidate.type === "toolCall" && candidate.name === PLAN_MODE_COMPLETE_TOOL_NAME);
|
|
155
|
+
});
|
|
156
|
+
return nextContent.length === content.length ? content : nextContent;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isEmptyAssistantMessage(message: unknown) {
|
|
161
|
+
const candidate = unwrapSessionMessage(message);
|
|
162
|
+
return (
|
|
163
|
+
candidate.role === "assistant" &&
|
|
164
|
+
Array.isArray(candidate.content) &&
|
|
165
|
+
candidate.content.length === 0
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function replaceAssistantContent<T>(message: T, transform: (content: unknown) => unknown): T {
|
|
170
|
+
const candidate = unwrapSessionMessage(message);
|
|
171
|
+
if (candidate.role !== "assistant") return message;
|
|
172
|
+
|
|
173
|
+
const content = transform(candidate.content);
|
|
174
|
+
if (content === candidate.content) return message;
|
|
175
|
+
|
|
176
|
+
if (isSessionMessageEntry(message)) {
|
|
177
|
+
return { ...message, message: { ...candidate, content } };
|
|
178
|
+
}
|
|
179
|
+
return { ...candidate, content } as T;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function unwrapSessionMessage(message: unknown) {
|
|
183
|
+
const entry = message as { message?: unknown } | null | undefined;
|
|
184
|
+
return (entry?.message ?? message ?? {}) as {
|
|
185
|
+
role?: string;
|
|
186
|
+
customType?: string;
|
|
187
|
+
toolName?: string;
|
|
188
|
+
content?: unknown;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
|
|
193
|
+
return typeof message === "object" && message !== null && "message" in message;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function stripProposedPlanBlocksFromContent(content: unknown) {
|
|
197
|
+
if (typeof content === "string") return stripProposedPlanBlocks(content);
|
|
198
|
+
if (!Array.isArray(content)) return content;
|
|
199
|
+
|
|
200
|
+
let changed = false;
|
|
201
|
+
const nextContent = content.map((block) => {
|
|
202
|
+
const textBlock = block as TextBlock;
|
|
203
|
+
if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
|
|
204
|
+
|
|
205
|
+
const text = stripProposedPlanBlocks(textBlock.text);
|
|
206
|
+
if (text === textBlock.text) return block;
|
|
207
|
+
|
|
208
|
+
changed = true;
|
|
209
|
+
return { ...textBlock, text };
|
|
210
|
+
});
|
|
211
|
+
return changed ? nextContent : content;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function stripProposedPlanBlocks(text: string) {
|
|
215
|
+
return text.replace(PROPOSED_PLAN_BLOCK_PATTERN, "");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function messageText(message: SessionMessage) {
|
|
219
|
+
return contentText(message.content);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function contentText(content: unknown): string {
|
|
223
|
+
if (typeof content === "string") return content;
|
|
224
|
+
if (!Array.isArray(content)) return "";
|
|
225
|
+
return content
|
|
226
|
+
.map((block) => {
|
|
227
|
+
const textBlock = block as TextBlock;
|
|
228
|
+
return textBlock.type === "text" && typeof textBlock.text === "string" ? textBlock.text : "";
|
|
229
|
+
})
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.join("\n");
|
|
232
|
+
}
|