diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 9c013ea8928..f5a374f5264 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -40,6 +40,7 @@ import { type ServeSessionTasksStatus, } from './status.js'; import { + BranchWhilePromptActiveError, SessionNotFoundError, RestoreInProgressError, InvalidSessionScopeError, @@ -270,6 +271,7 @@ interface SessionEntry { * inline session updates / permission requests can safely inherit this id. */ activePromptOriginatorClientId?: string; + promptActive: boolean; /** * Per-prompt "already broadcast `prompt_cancelled`" latch. The explicit * `cancelSession` route and the `sendPrompt` abort path (originator SSE @@ -1698,6 +1700,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { clientLastSeenAt: new Map(), attachCount: 0, spawnOwnerWantedKill: false, + promptActive: false, }; ci.sessionIds.add(entry.sessionId); byId.set(entry.sessionId, entry); @@ -2283,30 +2286,38 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } else { entry.activePromptOriginatorClientId = originatorClientId; } - // Echo the user prompt to the session bus so other SSE-subscribed - // clients see the input alongside the agent response. - // - // The interactive prompt path was the only one not emitting - // `user_message_chunk` — `Session#executePrompt` (the agent - // side) forwards the prompt directly to the LLM; the cron path - // (Session.ts:1402) and `HistoryReplayer` (line 65) emit it - // explicitly. Without this echo, multi-client UIs only saw - // assistant text from peer prompts — no record of who said what. - // - // Originator dedup: SDK consumers' `normalizeDaemonEvent` with - // `suppressOwnUserEcho: true` filters the echo when - // `event.originatorClientId === opts.clientId`. So the - // originator's local UI doesn't double-render its own input. - // - // Multi-modal: one envelope per content block. Non-text blocks - // pass through verbatim (the agent's Core multimodal echo is a - // for now the common text path is the immediate fix. - entry.cancelBroadcast = false; - echoPromptToSessionBus(entry, normalized, originatorClientId); + entry.promptActive = true; + try { + // Echo the user prompt to the session bus so other SSE-subscribed + // clients see the input alongside the agent response. + // + // The interactive prompt path was the only one not emitting + // `user_message_chunk` — `Session#executePrompt` (the agent + // side) forwards the prompt directly to the LLM; the cron path + // (Session.ts:1402) and `HistoryReplayer` (line 65) emit it + // explicitly. Without this echo, multi-client UIs only saw + // assistant text from peer prompts — no record of who said what. + // + // Originator dedup: SDK consumers' `normalizeDaemonEvent` with + // `suppressOwnUserEcho: true` filters the echo when + // `event.originatorClientId === opts.clientId`. So the + // originator's local UI doesn't double-render its own input. + // + // Multi-modal: one envelope per content block. Non-text blocks + // pass through verbatim (the agent's Core multimodal echo is a + // for now the common text path is the immediate fix. + entry.cancelBroadcast = false; + echoPromptToSessionBus(entry, normalized, originatorClientId); + } catch (echoErr) { + entry.promptActive = false; + delete entry.activePromptOriginatorClientId; + throw echoErr; + } const promptPromise = entry.connection .prompt(normalized) .finally(() => { delete entry.activePromptOriginatorClientId; + entry.promptActive = false; }); // Race against channel termination: if the underlying transport @@ -2632,6 +2643,106 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } }, + async branchSession(sessionId, req, context) { + if (shuttingDown) throw new Error('AcpSessionBridge is shutting down'); + + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + + let originatorClientId: string | undefined; + if (context?.clientId !== undefined) { + originatorClientId = resolveTrustedClientId(entry, context.clientId); + } + + const branchResult = entry.promptQueue.then(async () => { + if (entry.promptActive) { + throw new BranchWhilePromptActiveError(sessionId); + } + + if ( + byId.size + inFlightSpawns.size + inFlightRestores.size >= + maxSessions + ) { + throw new SessionLimitExceededError(maxSessions); + } + + const ci = await ensureChannel(); + const result = (await withTimeout( + ci.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionBranch, { + sessionId, + cwd: boundWorkspace, + name: req.name, + }), + initTimeoutMs, + 'branchSession', + )) as { newSessionId: string; title: string }; + + if ( + !result || + typeof result.newSessionId !== 'string' || + typeof result.title !== 'string' + ) { + throw new Error( + `branchSession: agent returned invalid response: ${JSON.stringify(result)}`, + ); + } + + let restored; + try { + restored = await restoreSession('resume', { + sessionId: result.newSessionId, + workspaceCwd: boundWorkspace, + clientId: context?.clientId, + }); + } catch (restoreErr) { + writeStderrLine( + `qwen serve: branchSession resume failed for ${result.newSessionId}, attempting cleanup...`, + ); + try { + await ci.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionClose, + { sessionId: result.newSessionId, cwd: boundWorkspace }, + ); + } catch (cleanupErr) { + writeStderrLine( + `qwen serve: branchSession cleanup of ${result.newSessionId} failed: ${cleanupErr instanceof Error ? cleanupErr.message : cleanupErr}`, + ); + } + throw restoreErr; + } + + const newEntry = byId.get(result.newSessionId); + if (newEntry) newEntry.displayName = result.title; + + const eventData = { + sourceSessionId: sessionId, + newSessionId: result.newSessionId, + displayName: result.title, + }; + const branchEnvelope = { + type: 'session_branched' as const, + data: eventData, + ...(originatorClientId ? { originatorClientId } : {}), + }; + entry.events.publish(branchEnvelope); + broadcastWorkspaceEvent(branchEnvelope, sessionId); + + return { + ...restored, + title: result.title, + forkedFrom: { + sessionId, + title: entry.displayName ?? sessionId.slice(0, 8), + }, + }; + }); + entry.promptQueue = branchResult.then( + () => undefined, + () => undefined, + ); + return branchResult; + }, + async closeSession(sessionId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); @@ -2795,7 +2906,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { createdAt: entry.createdAt, displayName: entry.displayName, clientCount: entry.clientIds.size, - hasActivePrompt: entry.activePromptOriginatorClientId !== undefined, + hasActivePrompt: entry.promptActive, }); } } diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index 25855bc347e..982e4be9a69 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -445,3 +445,14 @@ export class InvalidRewindTargetError extends Error { this.sessionId = sessionId; } } + +export class BranchWhilePromptActiveError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super( + `Cannot branch session ${sessionId}: a prompt is currently active`, + ); + this.name = 'BranchWhilePromptActiveError'; + this.sessionId = sessionId; + } +} diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index d10ab10af26..6afc521b088 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -104,6 +104,15 @@ export interface BridgeRestoredSession extends BridgeSession { lastEventId?: number; } +export interface BridgeBranchSessionRequest { + name?: string; +} + +export interface BridgeBranchedSession extends BridgeRestoredSession { + title: string; + forkedFrom: { sessionId: string; title: string }; +} + /** Sparse summary used by `GET /workspace/:id/sessions`. */ export interface BridgeSessionSummary { sessionId: string; @@ -192,6 +201,16 @@ export interface AcpSessionBridge { req: BridgeRestoreSessionRequest, ): Promise; + /** + * Fork a live session's JSONL transcript and load the fork via resume + * semantics (no history replay). Source must be idle (no active prompt). + */ + branchSession( + sessionId: string, + req: BridgeBranchSessionRequest, + context?: BridgeClientRequestContext, + ): Promise; + /** * Forward a prompt to the agent. Concurrent prompts against the same * session FIFO-serialize through a per-session queue. Throws diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index fb73a3d613a..3f35f161758 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -124,6 +124,7 @@ export const SERVE_STATUS_EXT_METHODS = { export const SERVE_CONTROL_EXT_METHODS = { sessionClose: 'qwen/control/session/close', sessionApprovalMode: 'qwen/control/session/approval_mode', + sessionBranch: 'qwen/control/session/branch', sessionRecap: 'qwen/control/session/recap', sessionBtw: 'qwen/control/session/btw', sessionShellHistory: 'qwen/control/session/shell_history', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index c6797cd6fc2..32d13cb2747 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -40,7 +40,9 @@ import { MCPOAuthTokenStorage, subagentGenerator, redactUrlCredentials, + computeUniqueBranchTitle, } from '@qwen-code/qwen-code-core'; +import { randomUUID } from 'node:crypto'; import type { ApprovalMode, Config, @@ -3314,6 +3316,80 @@ class QwenAgent implements Agent { apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null, }; } + case SERVE_CONTROL_EXT_METHODS.sessionBranch: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const name = params['name']; + + const sourceSession = this.sessions.get(sessionId); + if (!sourceSession) { + throw new RequestError(-32004, `Session not found: ${sessionId}`, { + errorKind: 'session_not_found', + sessionId, + }); + } + + const recording = sourceSession.getConfig().getChatRecordingService(); + if (recording) { + await recording.flush(); + } + + const newSessionId = randomUUID(); + return await runWithAcpRuntimeOutputDir( + this.settings, + cwd, + async () => { + const sessionService = new SessionService(cwd); + await sessionService.forkSession(sessionId, newSessionId); + + let title: string; + try { + let baseName: string; + if (typeof name === 'string' && name.trim().length > 0) { + baseName = name.trim(); + } else { + const existingTitle = recording?.getCurrentCustomTitle(); + const stripped = existingTitle + ?.replace(/\s*\(Branch(?:\s+\d+)?\)\s*$/, '') + .trim(); + if (stripped && stripped.length > 0) { + baseName = stripped; + } else { + baseName = sessionId.slice(0, 8); + } + } + + title = await computeUniqueBranchTitle(baseName, sessionService); + const renamed = await sessionService.renameSession( + newSessionId, + title, + 'manual', + ); + if (!renamed) { + throw new RequestError( + -32603, + `Failed to set title on forked session ${newSessionId}`, + { errorKind: 'internal', sessionId: newSessionId }, + ); + } + } catch (err) { + sessionService.removeSession(newSessionId).catch((rmErr) => { + process.stderr.write( + `qwen serve: failed to clean up orphan session ${newSessionId}: ${rmErr instanceof Error ? rmErr.message : rmErr}\n`, + ); + }); + throw err; + } + + return { newSessionId, title }; + }, + ); + } default: throw RequestError.methodNotFound(method); } diff --git a/packages/cli/src/serve/acpSessionBridge.ts b/packages/cli/src/serve/acpSessionBridge.ts index 385f58e10e6..9b5d0d64480 100644 --- a/packages/cli/src/serve/acpSessionBridge.ts +++ b/packages/cli/src/serve/acpSessionBridge.ts @@ -77,6 +77,7 @@ export type { } from '@qwen-code/acp-bridge/bridgeTypes'; export { + BranchWhilePromptActiveError, SessionNotFoundError, RestoreInProgressError, InvalidSessionScopeError, diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 3789a170927..c069593efe1 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -219,6 +219,7 @@ export const SERVE_CAPABILITY_REGISTRY = { workspace_hooks: { since: 'v1' }, session_hooks: { since: 'v1' }, workspace_extensions: { since: 'v1' }, + session_branch: { since: 'v1' }, } as const satisfies Record; export type ServeFeature = keyof typeof SERVE_CAPABILITY_REGISTRY; diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index b980c39d86e..e3af89b8bc4 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -178,6 +178,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'workspace_hooks', 'session_hooks', 'workspace_extensions', + 'session_branch', ] as const; // Issue #4175 PR 15. `require_auth` is registered but conditionally @@ -209,7 +210,8 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'session_rewind' && f !== 'workspace_hooks' && f !== 'session_hooks' && - f !== 'workspace_extensions', + f !== 'workspace_extensions' && + f !== 'session_branch', ), 'workspace_settings', 'workspace_init', @@ -229,6 +231,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'workspace_hooks', 'session_hooks', 'workspace_extensions', + 'session_branch', ] as const; interface FakeBridgeOpts { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index d5671008eed..7da85e27e2a 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -53,6 +53,7 @@ import { mountAcpHttp } from './acpHttp/index.js'; import { canonicalizeWorkspace, CancelSentinelCollisionError, + BranchWhilePromptActiveError, createAcpSessionBridge, InvalidClientIdError, InvalidPermissionOptionError, @@ -1469,6 +1470,49 @@ export function createServeApp( app.post('/session/:id/load', mutate(), restoreSessionHandler('load')); app.post('/session/:id/resume', mutate(), restoreSessionHandler('resume')); + app.post('/session/:id/branch', mutate(), async (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + const body = safeBody(req); + let name = typeof body?.['name'] === 'string' ? body['name'] : undefined; + if (name) { + // eslint-disable-next-line no-control-regex + name = name.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); + if (name.length > 200) { + name = name.slice(0, 200); + } + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + try { + const result = await bridge.branchSession( + sessionId, + { name }, + { clientId }, + ); + if (!res.writable) { + if (!result.attached) { + bridge + .killSession(result.sessionId, { requireZeroAttaches: true }) + .catch(() => { + // Best-effort cleanup; channel.exited will eventually reap. + }); + } else { + bridge.detachClient(result.sessionId, result.clientId).catch(() => { + // Best-effort cleanup; channel.exited will eventually reap. + }); + } + return; + } + res.status(201).json(result); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /session/:id/branch', + sessionId, + }); + } + }); + app.get('/session/:id/context', async (req, res) => { const sessionId = requireSessionId(req, res); if (sessionId === null) return; @@ -3596,6 +3640,14 @@ function sendBridgeErrorImpl( }); return; } + if (err instanceof BranchWhilePromptActiveError) { + res.status(409).json({ + error: err.message, + code: 'branch_while_prompt_active', + sessionId: err.sessionId, + }); + return; + } if (err instanceof TrustGateError) { // Trust-folder rejection. 403 because the workspace's trust posture // forbids the privileged mode. diff --git a/packages/cli/src/ui/hooks/useBranchCommand.ts b/packages/cli/src/ui/hooks/useBranchCommand.ts index 5747f0a4ecd..7ce7a83fc58 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.ts @@ -8,24 +8,16 @@ import { useCallback } from 'react'; import { randomUUID } from 'node:crypto'; import { type Config, - type SessionService, type ChatRecord, type ResumedSessionData, SessionStartSource, + computeUniqueBranchTitle, } from '@qwen-code/qwen-code-core'; import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; import { restoreGoalFromHistory } from '../utils/restoreGoal.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { t } from '../../i18n/index.js'; -/** - * Cap for the `(Branch N)` collision suffix. We scan all matching titles - * once via `findSessionTitlesByPrefix` and then pick the first free slot - * in memory; 99 is generous for realistic use and bounds the timestamp- - * fallback path on pathologically dense title spaces. - */ -const MAX_BRANCH_COLLISION_SCAN = 99; - /** * Derives a short one-line title from the first *real* user message in the * transcript. Mirrors Claude Code's `deriveFirstPrompt` (see @@ -57,37 +49,6 @@ function deriveFirstPrompt(messages: ChatRecord[]): string { return 'Branched conversation'; } -/** - * Appends ` (Branch)` to `baseName`, bumping to ` (Branch 2)`, ` (Branch 3)`, - * ... when the exact name is already taken by another session's customTitle - * in the current project. Mirrors Claude's `getUniqueForkName`. - * - * Does ONE prefix scan instead of probing each candidate via - * `findSessionsByTitle`: in dense title spaces the per-probe scanner could - * walk the project's chat directory up to {@link MAX_BRANCH_COLLISION_SCAN} - * times, and `/branch` would visibly stall. We collect every existing - * `${trimmed} (Branch...` title once, then pick the first free slot in memory. - */ -async function computeUniqueBranchTitle( - baseName: string, - sessionService: SessionService, -): Promise { - const trimmed = baseName.trim(); - const taken = new Set( - (await sessionService.findSessionTitlesByPrefix(`${trimmed} (Branch`)).map( - (t) => t.toLowerCase().trim(), - ), - ); - const first = `${trimmed} (Branch)`; - if (!taken.has(first.toLowerCase())) return first; - for (let n = 2; n <= MAX_BRANCH_COLLISION_SCAN; n++) { - const candidate = `${trimmed} (Branch ${n})`; - if (!taken.has(candidate.toLowerCase())) return candidate; - } - // Pathological density — timestamp fallback keeps the fork unique. - return `${trimmed} (Branch ${Date.now()})`; -} - export interface UseBranchCommandOptions { config: Config | null; historyManager: Pick< diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index ffd0d0c7216..ba781702186 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1361,3 +1361,25 @@ export function getResumePromptTokenCount( return undefined; } + +const MAX_BRANCH_COLLISION_SCAN = 99; + +export async function computeUniqueBranchTitle( + baseName: string, + sessionService: SessionService, +): Promise { + const maxSuffixLen = ' (Branch 1234567890123)'.length; + const trimmed = baseName.trim().slice(0, SESSION_TITLE_MAX_LENGTH - maxSuffixLen); + const taken = new Set( + (await sessionService.findSessionTitlesByPrefix(`${trimmed} (Branch`)).map( + (t) => t.toLowerCase().trim(), + ), + ); + const first = `${trimmed} (Branch)`; + if (!taken.has(first.toLowerCase())) return first; + for (let n = 2; n <= MAX_BRANCH_COLLISION_SCAN; n++) { + const candidate = `${trimmed} (Branch ${n})`; + if (!taken.has(candidate.toLowerCase())) return candidate; + } + return `${trimmed} (Branch ${Date.now()})`; +} diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index d31f1ae63f0..3a0fcfac838 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -22,6 +22,8 @@ import type { DaemonEvent, DaemonSessionContextStatus, DaemonSessionContextUsageStatus, + BranchSessionRequest, + DaemonBranchedSession, DaemonRestoredSession, DaemonSession, DaemonSessionSummary, @@ -1018,6 +1020,30 @@ export class DaemonClient { return this.restoreSession('resume', sessionId, req, clientId); } + async branchSession( + sessionId: string, + req: BranchSessionRequest = {}, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/branch`, + { + method: 'POST', + headers: this.headers( + { 'Content-Type': 'application/json' }, + clientId, + ), + body: JSON.stringify({ name: req.name }), + }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'POST /session/:id/branch'); + } + return (await res.json()) as DaemonBranchedSession; + }, + ); + } + async sessionContext( sessionId: string, clientId?: string, diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 29f16bef280..1e02981a2c8 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -109,6 +109,7 @@ export const DAEMON_KNOWN_EVENT_TYPE_VALUES = [ 'turn_complete', 'turn_error', 'session_rewound', + 'session_branched', ] as const; const DAEMON_KNOWN_EVENT_TYPES: ReadonlySet = new Set( @@ -636,6 +637,14 @@ export interface DaemonSessionRewoundData { [key: string]: unknown; } +export interface DaemonSessionBranchedData { + sourceSessionId: string; + newSessionId: string; + displayName: string; + originatorClientId?: string; + [key: string]: unknown; +} + /** * Fired when `POST /workspace/mcp/servers` succeeds, including both * fresh additions and replace-on-existing-name. The event fans out to @@ -816,6 +825,10 @@ export type DaemonSessionRewoundEvent = DaemonEventEnvelope< 'session_rewound', DaemonSessionRewoundData >; +export type DaemonSessionBranchedEvent = DaemonEventEnvelope< + 'session_branched', + DaemonSessionBranchedData +>; export type DaemonAuthEvent = | DaemonAuthDeviceFlowStartedEvent @@ -830,7 +843,8 @@ export type DaemonSessionEvent = | DaemonModelSwitchFailedEvent | DaemonSessionDiedEvent | DaemonSessionClosedEvent - | DaemonSessionMetadataUpdatedEvent; + | DaemonSessionMetadataUpdatedEvent + | DaemonSessionBranchedEvent; export type DaemonControlEvent = | DaemonPermissionRequestEvent @@ -1079,6 +1093,7 @@ export interface DaemonSessionViewState { lastTurnError?: DaemonTurnErrorData; rewindCount: number; lastRewind?: DaemonSessionRewoundData; + lastBranch?: DaemonSessionBranchedData; } /** @@ -1175,6 +1190,7 @@ export function createDaemonSessionViewState( lastFollowupSuggestion: seed.lastFollowupSuggestion, rewindCount: seed.rewindCount ?? 0, lastRewind: seed.lastRewind, + lastBranch: seed.lastBranch, }; } @@ -1366,6 +1382,10 @@ export function asKnownDaemonEvent( return isSessionRewoundData(event.data) ? (event as DaemonSessionRewoundEvent) : undefined; + case 'session_branched': + return isSessionBranchedData(event.data) + ? (event as DaemonSessionBranchedEvent) + : undefined; default: return undefined; } @@ -1740,6 +1760,11 @@ export function reduceDaemonSessionEvent( rewindCount: base.rewindCount + 1, lastRewind: mergeOriginator(event.data, event), }; + case 'session_branched': + return { + ...base, + lastBranch: mergeOriginator(event.data, event), + }; default: { const _exhaustive: never = event; return _exhaustive; @@ -2479,6 +2504,17 @@ function isMcpServerRemovedData( return true; } +function isSessionBranchedData( + value: unknown, +): value is DaemonSessionBranchedData { + if (!isRecord(value)) return false; + return ( + isNonEmptyString(value['sourceSessionId']) && + isNonEmptyString(value['newSessionId']) && + isNonEmptyString(value['displayName']) + ); +} + function isPermissionOption(value: unknown): value is DaemonPermissionOption { return isRecord(value) && isNonEmptyString(value['optionId']); } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 1c57334db03..25e90d2e851 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -176,6 +176,8 @@ export type { DaemonMcpServerRestartRefusedEvent, DaemonSessionRewoundData, DaemonSessionRewoundEvent, + DaemonSessionBranchedData, + DaemonSessionBranchedEvent, DaemonToolToggledData, DaemonToolToggledEvent, DaemonWorkspaceInitializedData, @@ -292,6 +294,8 @@ export type { DaemonMcpTransport, DaemonMode, DaemonProtocolVersions, + BranchSessionRequest, + DaemonBranchedSession, DaemonRestoredSession, DaemonSession, DaemonAuthProviderId, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 9225db5f5f9..d80f25fcd6e 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -165,6 +165,15 @@ export interface DaemonRestoredSession extends DaemonSession { lastEventId?: number; } +export interface BranchSessionRequest { + name?: string; +} + +export interface DaemonBranchedSession extends DaemonRestoredSession { + title: string; + forkedFrom: { sessionId: string; title: string }; +} + /** Sparse session record returned by `GET /workspace/:id/sessions`. */ export interface DaemonSessionSummary { sessionId: string;