Skip to content

Commit f01d66d

Browse files
cmp0xffcodex
andcommitted
BUG: Preserve authority in clean plan handoffs
Clean-context execution previously trusted caller-supplied plan text and injected it as a system message, while copied skill state could be stale. That let the handoff gain unintended authority and made skill rediscovery unreliable. Validate a completed Plan Mode source against its latest complete assistant plan, record that message as structured provenance, rebuild only the current trusted prefix and re-resolved compact skill catalog, and return the exact plan in a user-role handoff for awaited normal submission. A generic implementation prompt plus a hidden plan system message was rejected because it bypasses normal skill matching and changes the plan authority. Verified with the focused core session suite, repository typechecks, lint, formatting checks, and the full npm test suite. Co-authored-by: Codex <noreply@openai.com>
1 parent 6f9f13c commit f01d66d

5 files changed

Lines changed: 323 additions & 54 deletions

File tree

‎packages/cli/src/ui/views/App.tsx‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -578,10 +578,16 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
578578
setErrorLine("No active session to derive from.");
579579
return;
580580
}
581+
let derivedSessionId: string | null = null;
582+
let submissionStarted = false;
581583
try {
582-
const sessionId = sessionManager.startPlanImplementationSession(sourceSessionId, proposedPlan);
584+
const { sessionId, implementationPrompt } = await sessionManager.startPlanImplementationSession(
585+
sourceSessionId,
586+
proposedPlan
587+
);
588+
derivedSessionId = sessionId;
583589
sessionManager.setActiveSessionId(sessionId);
584-
setPendingPlanImplementation(null);
590+
processStdoutRef.current.clear();
585591
await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true });
586592
const session = sessionManager.getSession(sessionId);
587593
setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : "");
@@ -591,20 +597,35 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
591597
setPlanMode(false);
592598
setPendingPermissionReply(null);
593599
setErrorLine(null);
594-
const source = sessionManager.getSession(sourceSessionId);
595-
if (source?.summary && !source.summary.includes(" (planned)")) {
596-
sessionManager.renameSession(sourceSessionId, `${source.summary} (planned)`);
597-
}
598600
refreshSessionsList();
599601
await refreshSkills(sessionId);
600-
handleSubmit({
601-
text: getImplementationPrompt(proposedPlan),
602+
setPendingPlanImplementation(null);
603+
submissionStarted = true;
604+
await handlePrompt({
605+
text: implementationPrompt,
602606
imageUrls: [],
603607
planMode: false,
604608
});
605609
} catch (error) {
610+
if (submissionStarted) {
611+
setErrorLine(error instanceof Error ? error.message : String(error));
612+
return;
613+
}
614+
if (derivedSessionId) {
615+
sessionManager.deleteSession(derivedSessionId);
616+
}
617+
sessionManager.setActiveSessionId(sourceSessionId);
618+
processStdoutRef.current.clear();
619+
await resetStaticView(loadVisibleMessages(sessionManager, sourceSessionId), { clearScreen: true });
620+
const source = sessionManager.getSession(sourceSessionId);
621+
setStatusLine(source ? buildStatusLine(source, resolveCurrentSettings(projectRoot)) : "");
622+
setRunningProcesses(source?.processes ?? null);
623+
setActiveStatus(source?.status ?? null);
624+
setActiveAskPermissions(source?.askPermissions);
625+
setPlanMode(true);
606626
setErrorLine(error instanceof Error ? error.message : String(error));
607627
setPendingPlanImplementation(proposedPlan);
628+
refreshSessionsList();
608629
}
609630
return;
610631
}
@@ -620,6 +641,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
620641
},
621642
[
622643
handleSubmit,
644+
handlePrompt,
623645
pendingPlanImplementation,
624646
sessionManager,
625647
resetStaticView,

‎packages/cli/src/ui/views/PlanImplementationPrompt.tsx‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useEffect, useState } from "react";
22
import { Box, Text } from "ink";
3+
import { extractProposedPlan } from "@vegamo/deepcode-core";
34
import { useTerminalInput } from "../hooks";
45
import type { InputKey } from "../hooks";
56

@@ -16,14 +17,7 @@ const CHOICES: Array<{ value: PlanImplementationChoice; label: string }> = [
1617
{ value: "clearContext", label: "clear context and implement this plan" },
1718
];
1819

19-
/** Return only a complete proposed plan, so historical or partial tags cannot trigger the chooser. */
20-
export function extractProposedPlan(reply: string | null): string | null {
21-
if (!reply) {
22-
return null;
23-
}
24-
const match = reply.match(/<proposed_plan>\s*([\s\S]*?\S[\s\S]*?)\s*<\/proposed_plan>/);
25-
return match?.[1] ?? null;
26-
}
20+
export { extractProposedPlan };
2721

2822
export function getImplementationPrompt(plan: string): string {
2923
const fullWidthPunctuationCount = (plan.match(/[,、;。]/g) ?? []).length;

‎packages/core/src/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ export type {
4141
} from "./settings";
4242

4343
// Session
44-
export { SessionManager, getProjectCode, getCompactPromptTokenThreshold } from "./session";
44+
export {
45+
SessionManager,
46+
buildPlanImplementationHandoff,
47+
extractProposedPlan,
48+
getProjectCode,
49+
getCompactPromptTokenThreshold,
50+
} from "./session";
4551
export type {
4652
SessionMessage,
4753
SessionEntry,
@@ -58,6 +64,7 @@ export type {
5864
LlmStreamProgress,
5965
LlmRetryEvent,
6066
SessionManagerOptions,
67+
PlanImplementationSessionResult,
6168
} from "./session";
6269

6370
// Prompt utilities

‎packages/core/src/session.ts‎

Lines changed: 106 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,19 @@ const PLAN_MODE_FORCE_ASK_SCOPES = [
108108
"mutate-git-log",
109109
] as const satisfies readonly PermissionScope[];
110110

111-
function buildPlanImplementationMessage(planText: string): string {
111+
export function extractProposedPlan(content: string | null): string | null {
112+
if (!content) {
113+
return null;
114+
}
115+
116+
let latestPlan: string | null = null;
117+
for (const match of content.matchAll(/<proposed_plan>\s*([\s\S]*?\S[\s\S]*?)\s*<\/proposed_plan>/g)) {
118+
latestPlan = match[1] ?? null;
119+
}
120+
return latestPlan;
121+
}
122+
123+
export function buildPlanImplementationHandoff(planText: string): string {
112124
const fullWidthPunctuationCount = (planText.match(/[,、;。]/g) ?? []).length;
113125
const directive =
114126
fullWidthPunctuationCount > 5
@@ -300,6 +312,16 @@ export type SessionEntry = {
300312
sessionId: string;
301313
messageId: string;
302314
};
315+
derivedFrom?: {
316+
kind: "plan-implementation";
317+
sessionId: string;
318+
messageId: string;
319+
};
320+
};
321+
322+
export type PlanImplementationSessionResult = {
323+
sessionId: string;
324+
implementationPrompt: string;
303325
};
304326

305327
export type SessionsIndex = {
@@ -317,7 +339,6 @@ export type MessageMeta = {
317339
asThinking?: boolean;
318340
isAnswers?: boolean;
319341
isSummary?: boolean;
320-
isPlan?: boolean;
321342
isModelChange?: boolean;
322343
skill?: SkillInfo;
323344
skillCatalog?: Array<{ name: string; description: string }>;
@@ -395,7 +416,7 @@ export type SessionManagerOptions = {
395416
onLlmStreamProgress?: (progress: LlmStreamProgress) => void;
396417
onLlmRetry?: (event: LlmRetryEvent) => void;
397418
onMcpStatusChanged?: () => void;
398-
onProcessStdout?: (pid: number, chunk: string) => void;
419+
onProcessStdout?: (pid: number, chunk: string, sessionId: string) => void;
399420
loadSharp?: SharpLoader;
400421
nonInteractive?: boolean;
401422
};
@@ -442,7 +463,7 @@ export class SessionManager {
442463
private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void;
443464
private readonly onLlmRetry?: (event: LlmRetryEvent) => void;
444465
private readonly onMcpStatusChanged?: () => void;
445-
private readonly onProcessStdout?: (pid: number, chunk: string) => void;
466+
private readonly onProcessStdout?: (pid: number, chunk: string, sessionId: string) => void;
446467
private readonly nonInteractive: boolean;
447468
private activeSessionId: string | null = null;
448469
private activePromptController: AbortController | null = null;
@@ -2336,30 +2357,67 @@ ${agentInstructions}
23362357
/**
23372358
* Derive a clean implementation session from a completed Plan Mode session.
23382359
* Unlike forkSession (which copies the full conversation history), this builds a
2339-
* fresh message list carrying only the system prompt, runtime context, AGENTS.md
2340-
* instructions, and the approved plan — so implementation starts from a clean
2341-
* context while file history stays traceable to the source session's checkpoint.
2360+
* fresh trusted prefix carrying the current system prompt, runtime context,
2361+
* AGENTS.md instructions, and a compact current skill catalog. The caller submits
2362+
* the returned user-role handoff so implementation starts from a clean context
2363+
* while file history stays traceable to the source session's checkpoint.
23422364
*/
2343-
startPlanImplementationSession(sourceSessionId: string, planText: string): string {
2365+
async startPlanImplementationSession(
2366+
sourceSessionId: string,
2367+
expectedPlan: string
2368+
): Promise<PlanImplementationSessionResult> {
23442369
const source = this.getSession(sourceSessionId);
23452370
if (!source) {
23462371
throw new Error(`No saved session found with ID "${sourceSessionId}".`);
23472372
}
23482373
if (source.planMode !== true) {
23492374
throw new Error(`Session "${sourceSessionId}" is not in Plan Mode.`);
23502375
}
2351-
2352-
const trimmedPlanText = planText.trim();
2353-
if (!trimmedPlanText) {
2376+
if (source.status !== "completed") {
2377+
throw new Error(`Session "${sourceSessionId}" is not completed.`);
2378+
}
2379+
if (!expectedPlan.trim()) {
23542380
throw new Error("The approved plan text must not be empty.");
23552381
}
23562382

23572383
const sourceMessages = this.listSessionMessages(sourceSessionId);
2358-
const sourceMessage = sourceMessages.at(-1);
2359-
if (!sourceMessage || typeof sourceMessage.id !== "string" || !sourceMessage.id) {
2360-
throw new Error(`Session "${sourceSessionId}" has no messages to derive from.`);
2384+
let sourceMessage: SessionMessage | undefined;
2385+
let planText: string | null = null;
2386+
for (let index = sourceMessages.length - 1; index >= 0; index -= 1) {
2387+
const message = sourceMessages[index];
2388+
if (message?.role !== "assistant") {
2389+
continue;
2390+
}
2391+
const proposedPlan = extractProposedPlan(message.content);
2392+
if (proposedPlan !== null) {
2393+
sourceMessage = message;
2394+
planText = proposedPlan;
2395+
break;
2396+
}
2397+
}
2398+
if (!sourceMessage || planText === null) {
2399+
throw new Error(`Session "${sourceSessionId}" has no complete proposed plan to implement.`);
2400+
}
2401+
if (expectedPlan !== planText) {
2402+
throw new Error("The approved plan no longer matches the latest proposed plan in the source session.");
23612403
}
23622404

2405+
const advertisedSkillNames = new Set<string>();
2406+
for (const message of sourceMessages) {
2407+
if (message.meta?.skill?.name) {
2408+
advertisedSkillNames.add(message.meta.skill.name);
2409+
}
2410+
for (const skill of message.meta?.skillCatalog ?? []) {
2411+
if (skill?.name) {
2412+
advertisedSkillNames.add(skill.name);
2413+
}
2414+
}
2415+
}
2416+
const currentSkills = await this.listSkills();
2417+
const skillCatalog = currentSkills
2418+
.filter((skill) => advertisedSkillNames.has(skill.name))
2419+
.map((skill) => ({ name: skill.name, description: skill.description }));
2420+
23632421
const sessionId = crypto.randomUUID();
23642422
const now = new Date().toISOString();
23652423
const entry: SessionEntry = {
@@ -2378,7 +2436,8 @@ ${agentInstructions}
23782436
updateTime: now,
23792437
processes: null,
23802438
planMode: false,
2381-
forkedFrom: {
2439+
derivedFrom: {
2440+
kind: "plan-implementation",
23822441
sessionId: sourceSessionId,
23832442
messageId: sourceMessage.id,
23842443
},
@@ -2401,19 +2460,21 @@ ${agentInstructions}
24012460
if (agentInstructions) {
24022461
messages.push(this.buildSystemMessage(sessionId, agentInstructions));
24032462
}
2404-
2405-
messages.push(
2406-
this.buildSystemMessage(sessionId, buildPlanImplementationMessage(trimmedPlanText), null, false, {
2407-
isPlan: true,
2408-
})
2409-
);
2463+
if (skillCatalog.length > 0) {
2464+
messages.push(
2465+
this.buildSystemMessage(sessionId, buildSkillCatalogPrompt(skillCatalog), null, false, { skillCatalog })
2466+
);
2467+
}
24102468

24112469
this.saveSessionMessages(sessionId, messages);
24122470
this.getFileHistory().forkSession(sourceSessionId, sessionId);
24132471

24142472
this.registerSessionEntry(entry);
24152473

2416-
return sessionId;
2474+
return {
2475+
sessionId,
2476+
implementationPrompt: buildPlanImplementationHandoff(planText),
2477+
};
24172478
}
24182479

24192480
/**
@@ -3167,7 +3228,7 @@ ${agentInstructions}
31673228
const hooks: ToolExecutionHooks = {
31683229
onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command),
31693230
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
3170-
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
3231+
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk, sessionId),
31713232
onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control),
31723233
onBackgroundProcessComplete: (completion) => this.addBackgroundProcessCompletionMessage(sessionId, completion),
31733234
onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath),
@@ -3654,6 +3715,7 @@ ${agentInstructions}
36543715
planMode: value.planMode === true,
36553716
pluginRateLimitedTool: this.normalizePluginRateLimitedTool(value.pluginRateLimitedTool),
36563717
forkedFrom: this.normalizeForkedFrom(value.forkedFrom),
3718+
derivedFrom: this.normalizeDerivedFrom(value.derivedFrom),
36573719
};
36583720
}
36593721

@@ -3680,6 +3742,27 @@ ${agentInstructions}
36803742
};
36813743
}
36823744

3745+
private normalizeDerivedFrom(value: unknown): SessionEntry["derivedFrom"] {
3746+
if (!value || typeof value !== "object" || Array.isArray(value)) {
3747+
return undefined;
3748+
}
3749+
const derivedFrom = value as Record<string, unknown>;
3750+
if (
3751+
derivedFrom.kind !== "plan-implementation" ||
3752+
typeof derivedFrom.sessionId !== "string" ||
3753+
!derivedFrom.sessionId ||
3754+
typeof derivedFrom.messageId !== "string" ||
3755+
!derivedFrom.messageId
3756+
) {
3757+
return undefined;
3758+
}
3759+
return {
3760+
kind: "plan-implementation",
3761+
sessionId: derivedFrom.sessionId,
3762+
messageId: derivedFrom.messageId,
3763+
};
3764+
}
3765+
36833766
private normalizeSessionStatus(status: unknown): SessionStatus {
36843767
if (
36853768
status === "failed" ||

0 commit comments

Comments
 (0)