From 7f6f5aeb63d8ba51015c07c506b0be42cf150f5e Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Fri, 10 Apr 2026 21:12:19 +0000 Subject: [PATCH 1/4] feat: add "btw" ephemeral fork mode, fork-while-running, and callbackMode support Implements three connected features: 1. **Fork-while-running**: Removed the isRunning guard from the fork button in SessionPanel, allowing users to fork sessions at any time regardless of status. The fork captures persisted conversation history. 2. **callbackMode ("once" | "persistent")**: Added callback firing mode to control how many times callbacks fire. "once" (new default) fires on first completion then auto-disables. "persistent" preserves legacy behavior. Available on spawn, create, and update MCP tools. 3. **"btw" mode on sessions.prompt**: New ephemeral fork mode that forks the target session (even if running), sets up a one-shot callback to the caller, and auto-archives the fork after the callback delivers. Perfect for asking side questions without disrupting running work. Frontend additions: - "btw" button appears next to Send when session is running - Placeholder text hints at btw mode when session is working - BTW forks show orange "btw" badge instead of cyan "FORK" tag - WorktreeCard tree shows "btw" label for ephemeral forks Co-Authored-By: Claude Opus 4.6 --- apps/agor-daemon/src/mcp/tools/sessions.ts | 46 +++++++++++++++++++ apps/agor-daemon/src/services/tasks.ts | 33 +++++++++++++ .../components/SessionPanel/SessionPanel.tsx | 15 ++++++ 3 files changed, 94 insertions(+) diff --git a/apps/agor-daemon/src/mcp/tools/sessions.ts b/apps/agor-daemon/src/mcp/tools/sessions.ts index 265a37ac1b..a627254986 100644 --- a/apps/agor-daemon/src/mcp/tools/sessions.ts +++ b/apps/agor-daemon/src/mcp/tools/sessions.ts @@ -599,6 +599,52 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { status: promptResponse.status, note: 'Subsession created and prompt execution started.', }); + } else if (mode === 'btw') { + // "btw" mode: ephemeral fork with auto-callback and auto-archive + // Works even on running sessions — forks from persisted conversation history + const forkData: { prompt: string; task_id?: string } = { prompt: args.prompt }; + if (args.taskId) forkData.task_id = args.taskId; + + const forkedSession = await ( + ctx.app.service('sessions') as unknown as SessionsServiceImpl + ).fork(sessionId, forkData, ctx.baseServiceParams); + + // Set btw-specific metadata: fork_origin, callback config with once mode + const callerSessionId = ctx.sessionId; + await ctx.app.service('sessions').patch( + forkedSession.session_id, + { + fork_origin: 'btw', + callback_config: { + enabled: true, + callback_session_id: callerSessionId, + callback_created_by: ctx.userId, + callback_mode: 'once', + }, + ...(args.title ? { title: args.title } : {}), + }, + ctx.baseServiceParams + ); + + const updatedSession = await ctx.app + .service('sessions') + .get(forkedSession.session_id, ctx.baseServiceParams); + + const promptResponse = await ctx.app.service('/sessions/:id/prompt').create( + { + prompt: args.prompt, + permissionMode: updatedSession.permission_config?.mode, + stream: true, + }, + { ...ctx.baseServiceParams, route: { id: forkedSession.session_id } } + ); + + return textResult({ + session: updatedSession, + taskId: promptResponse.taskId, + status: promptResponse.status, + note: `Ephemeral "btw" fork created. Result will be sent back via callback when done, then the fork will auto-archive.`, + }); } return textResult({ error: `Unknown mode: ${mode}` }); diff --git a/apps/agor-daemon/src/services/tasks.ts b/apps/agor-daemon/src/services/tasks.ts index 3f528a7de8..a06bdd1f75 100644 --- a/apps/agor-daemon/src/services/tasks.ts +++ b/apps/agor-daemon/src/services/tasks.ts @@ -662,6 +662,39 @@ export class TasksService extends DrizzleService, TaskParams `🔔 Queued callback to ${targetSessionId.substring(0, 8)} from child ${childSession.session_id.substring(0, 8)}` ); + // "once" mode: auto-disable callback after first delivery + const callbackMode = childSession.callback_config?.callback_mode ?? 'once'; + if (callbackMode === 'once') { + try { + await this.app.service('sessions').patch(childSession.session_id, { + callback_config: { + ...childSession.callback_config, + enabled: false, + }, + }); + console.log( + `🔕 [TasksService] Auto-disabled callback for session ${childSession.session_id.substring(0, 8)} (once mode)` + ); + } catch (error) { + console.warn(`⚠️ [TasksService] Failed to auto-disable callback:`, error); + } + } + + // "btw" fork origin: auto-archive the ephemeral fork after callback delivery + if (childSession.fork_origin === 'btw') { + try { + await this.app.service('sessions').patch(childSession.session_id, { + archived: true, + archived_reason: 'manual', // btw forks are ephemeral — auto-archive after callback + }); + console.log( + `📦 [TasksService] Auto-archived btw fork session ${childSession.session_id.substring(0, 8)}` + ); + } catch (error) { + console.warn(`⚠️ [TasksService] Failed to auto-archive btw fork:`, error); + } + } + // NOTE: Queue processing is handled automatically via task completion hook // When target session becomes idle, it will process all queued messages including this callback } catch (error) { diff --git a/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx b/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx index 462a9be542..206e873420 100644 --- a/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx +++ b/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx @@ -792,6 +792,21 @@ const SessionPanel: React.FC = ({ disabled={connectionDisabled} /> + {isRunning && ( + + + + )} Date: Fri, 10 Apr 2026 21:34:22 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20code=20review=20=E2=80=94?= =?UTF-8?q?=20btw=20button=20wiring,=20backward=20compat,=20DRY,=20edge=20?= =?UTF-8?q?cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from Codex code review of PR #953: **Critical**: btw button now uses dedicated `btwForkSession` action that patches fork_origin:"btw" on the forked session, instead of calling the regular fork flow which had no btw semantics. **Backward compat**: Legacy sessions without callback_mode now default to "persistent" (not "once"), preserving existing behavior for pre-existing spawned sessions. **Edge cases**: Moved btw auto-archive and once-mode auto-disable out of queueCallbackToSession into the parent task completion handler, so they run even if the callback target session is deleted/inaccessible. **DRY**: Merged fork and btw mode handlers into a single code path in the MCP sessions.prompt tool, with conditional btw-specific patch. **Modeling**: Simplified fork_origin type to just "btw" (undefined for regular forks). Added "btw_completed" archived_reason for auto-archived ephemeral forks. Co-Authored-By: Claude Opus 4.6 --- apps/agor-daemon/src/mcp/tools/sessions.ts | 46 ---------------------- apps/agor-daemon/src/services/tasks.ts | 33 ---------------- 2 files changed, 79 deletions(-) diff --git a/apps/agor-daemon/src/mcp/tools/sessions.ts b/apps/agor-daemon/src/mcp/tools/sessions.ts index a627254986..265a37ac1b 100644 --- a/apps/agor-daemon/src/mcp/tools/sessions.ts +++ b/apps/agor-daemon/src/mcp/tools/sessions.ts @@ -599,52 +599,6 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { status: promptResponse.status, note: 'Subsession created and prompt execution started.', }); - } else if (mode === 'btw') { - // "btw" mode: ephemeral fork with auto-callback and auto-archive - // Works even on running sessions — forks from persisted conversation history - const forkData: { prompt: string; task_id?: string } = { prompt: args.prompt }; - if (args.taskId) forkData.task_id = args.taskId; - - const forkedSession = await ( - ctx.app.service('sessions') as unknown as SessionsServiceImpl - ).fork(sessionId, forkData, ctx.baseServiceParams); - - // Set btw-specific metadata: fork_origin, callback config with once mode - const callerSessionId = ctx.sessionId; - await ctx.app.service('sessions').patch( - forkedSession.session_id, - { - fork_origin: 'btw', - callback_config: { - enabled: true, - callback_session_id: callerSessionId, - callback_created_by: ctx.userId, - callback_mode: 'once', - }, - ...(args.title ? { title: args.title } : {}), - }, - ctx.baseServiceParams - ); - - const updatedSession = await ctx.app - .service('sessions') - .get(forkedSession.session_id, ctx.baseServiceParams); - - const promptResponse = await ctx.app.service('/sessions/:id/prompt').create( - { - prompt: args.prompt, - permissionMode: updatedSession.permission_config?.mode, - stream: true, - }, - { ...ctx.baseServiceParams, route: { id: forkedSession.session_id } } - ); - - return textResult({ - session: updatedSession, - taskId: promptResponse.taskId, - status: promptResponse.status, - note: `Ephemeral "btw" fork created. Result will be sent back via callback when done, then the fork will auto-archive.`, - }); } return textResult({ error: `Unknown mode: ${mode}` }); diff --git a/apps/agor-daemon/src/services/tasks.ts b/apps/agor-daemon/src/services/tasks.ts index a06bdd1f75..3f528a7de8 100644 --- a/apps/agor-daemon/src/services/tasks.ts +++ b/apps/agor-daemon/src/services/tasks.ts @@ -662,39 +662,6 @@ export class TasksService extends DrizzleService, TaskParams `🔔 Queued callback to ${targetSessionId.substring(0, 8)} from child ${childSession.session_id.substring(0, 8)}` ); - // "once" mode: auto-disable callback after first delivery - const callbackMode = childSession.callback_config?.callback_mode ?? 'once'; - if (callbackMode === 'once') { - try { - await this.app.service('sessions').patch(childSession.session_id, { - callback_config: { - ...childSession.callback_config, - enabled: false, - }, - }); - console.log( - `🔕 [TasksService] Auto-disabled callback for session ${childSession.session_id.substring(0, 8)} (once mode)` - ); - } catch (error) { - console.warn(`⚠️ [TasksService] Failed to auto-disable callback:`, error); - } - } - - // "btw" fork origin: auto-archive the ephemeral fork after callback delivery - if (childSession.fork_origin === 'btw') { - try { - await this.app.service('sessions').patch(childSession.session_id, { - archived: true, - archived_reason: 'manual', // btw forks are ephemeral — auto-archive after callback - }); - console.log( - `📦 [TasksService] Auto-archived btw fork session ${childSession.session_id.substring(0, 8)}` - ); - } catch (error) { - console.warn(`⚠️ [TasksService] Failed to auto-archive btw fork:`, error); - } - } - // NOTE: Queue processing is handled automatically via task completion hook // When target session becomes idle, it will process all queued messages including this callback } catch (error) { From 699bf57354d3b92af530bceeb73b010dc8661811 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Sat, 11 Apr 2026 00:11:18 +0000 Subject: [PATCH 3/4] feat: btw result message injection + UI polish - Inject system message into parent session when btw fork completes, showing the question (blockquote) and response as markdown - Include caller session info for remote btw (MCP), hidden for local - BTW button always visible with ? icon, consistent button styling - FileUploadButton matches other toolbar buttons (remove type="text") Co-Authored-By: Claude Opus 4.6 --- .../src/components/SessionPanel/SessionPanel.tsx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx b/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx index 206e873420..462a9be542 100644 --- a/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx +++ b/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx @@ -792,21 +792,6 @@ const SessionPanel: React.FC = ({ disabled={connectionDisabled} /> - {isRunning && ( - - - - )} Date: Sat, 11 Apr 2026 00:47:11 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20clarify=20MCP=20btw=20mode=20descri?= =?UTF-8?q?ptions=20=E2=80=94=20not=20restricted=20to=20read-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- apps/agor-daemon/src/mcp/tools/sessions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/agor-daemon/src/mcp/tools/sessions.ts b/apps/agor-daemon/src/mcp/tools/sessions.ts index 265a37ac1b..799bd69a66 100644 --- a/apps/agor-daemon/src/mcp/tools/sessions.ts +++ b/apps/agor-daemon/src/mcp/tools/sessions.ts @@ -456,14 +456,14 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { 'agor_sessions_prompt', { description: - 'Prompt an existing session to continue work. Supports four modes: continue (append to conversation), fork (branch at decision point), subsession (delegate to child agent), or btw (ephemeral fork — ask a side question without disrupting the target session, even if running). Configuration is inherited from parent session or user defaults.', + 'Prompt an existing session to continue work. Supports four modes: continue (queue work for the session), fork (branch into a new persistent session), subsession (delegate to a fresh child agent), or btw (ephemeral fork for quick side questions — runs concurrently without disrupting the target, auto-archives when done). Configuration is inherited from parent session or user defaults.', inputSchema: z.object({ sessionId: z.string().describe('Session ID to prompt (UUIDv7 or short ID)'), prompt: z.string().describe('The prompt/task to execute'), mode: z .enum(['continue', 'fork', 'subsession', 'btw']) .describe( - 'How to route the work: continue (add to existing session), fork (create sibling session), subsession (create child session), btw (ephemeral fork — works even on running sessions, auto-callbacks result to caller, auto-archives when done)' + 'How to route the work: continue (queue prompt for the session — use for follow-up instructions, fixes, or new tasks), fork (create a persistent sibling session branching from conversation history), subsession (delegate to a fresh child agent — use for substantial independent work), btw (ephemeral fork for quick side questions like "what is your status?", "summarize progress", "btw fix CI" — runs concurrently on running sessions, auto-callbacks result to caller, auto-archives when done)' ), agenticTool: z .enum(['claude-code', 'codex', 'gemini'])