@@ -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 ( / < p r o p o s e d _ p l a n > \s * ( [ \s \S ] * ?\S [ \s \S ] * ?) \s * < \/ p r o p o s e d _ p l a n > / 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
305327export 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