Skip to content

Commit e34bfdd

Browse files
committed
Merge branch 'implement-fork-command'
2 parents ed7ddd0 + 6ef6fbb commit e34bfdd

20 files changed

Lines changed: 497 additions & 26 deletions

README-en.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Skills are discovered from these locations, in priority order:
7575
| `/` | Open the skills / commands menu |
7676
| `/new` | Start a fresh conversation |
7777
| `/resume` | Choose a previous conversation to continue |
78+
| `/fork` | Fork the current conversation |
7879
| `/continue` | Continue the active conversation or pick one to resume |
7980
| `/model` | Switch model, thinking mode, and reasoning effort |
8081
| `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) |

README-zh_CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Skills 会按以下优先级扫描:
7474
| `/` | 打开 skills / 命令菜单 |
7575
| `/new` | 开始新对话 |
7676
| `/resume` | 选择历史对话继续 |
77+
| `/fork` | 从当前对话创建独立的新会话 |
7778
| `/continue` | 继续当前对话,或选择历史对话恢复 |
7879
| `/model` | 切换模型、思考模式和推理强度 |
7980
| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) |

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Skills 会按以下优先级扫描:
7474
| `/` | 打开 skills / 命令菜单 |
7575
| `/new` | 开始新对话 |
7676
| `/resume` | 选择历史对话继续 |
77+
| `/fork` | 从当前对话创建独立的新会话 |
7778
| `/continue` | 继续当前对话,或选择历史对话恢复 |
7879
| `/model` | 切换模型、思考模式和推理强度 |
7980
| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) |

docs/quickstart.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ deepcode -p "总结这个项目"
120120
| ---- | ---- |
121121
| `/new` | 开始新对话 |
122122
| `/resume` | 选择历史对话继续 |
123+
| `/fork` | 从当前对话创建独立的新会话 |
123124
| `/continue` | 继续当前对话,或恢复最近的对话 |
124125
| `/model` | 切换模型、思考模式和推理强度 |
125126
| `/init` | 为当前项目生成 `AGENTS.md` 指令文件 |

docs/quickstart_en.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Type `/` in the input box to open the command menu.
120120
| ------- | ------ |
121121
| `/new` | Start a new conversation |
122122
| `/resume` | Choose a previous conversation to continue |
123+
| `/fork` | Fork the current conversation |
123124
| `/continue` | Continue the current conversation or resume the latest one |
124125
| `/model` | Switch model, thinking mode, and reasoning effort |
125126
| `/init` | Create an `AGENTS.md` instruction file for the current project |

docs/session-persistence.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 会话持久化机制
22

3-
Deep Code 会把每个项目的会话记录保存在本机用户目录中。会话历史用于 `/resume``/continue``/undo`,不依赖当前终端进程是否仍在运行。
3+
Deep Code 会把每个项目的会话记录保存在本机用户目录中。会话历史用于 `/resume``/fork``/continue``/undo`,不依赖当前终端进程是否仍在运行。
44

55
## 存储位置
66

@@ -31,6 +31,7 @@ Deep Code 会把每个项目的会话记录保存在本机用户目录中。会
3131
- 最近一次 assistant 回复、思考内容、拒绝原因和失败原因。
3232
- 最近一次工具调用信息、token 用量和活跃 token 数。
3333
- 当前会话中仍被跟踪的子进程信息。
34+
- fork 会话的直接来源 `sessionId` 和源会话最后一条消息的 `messageId`
3435

3536
会话标题默认来自首次用户输入的前 100 个字符。使用会话列表中的重命名功能会更新索引里的标题。
3637

@@ -86,6 +87,12 @@ Deep Code 使用 `file-history/.git` 保存代码快照。这个仓库只作为
8687

8788
继续会话时,Deep Code 会读取会话消息文件,过滤已压缩的旧消息,修复未完成的工具调用上下文,并把可用历史转换为模型请求消息。
8889

90+
### Fork 会话
91+
92+
`/fork` 会复制当前活动会话并切换到新会话。命令行中的 `--fork <session-id>` 会复制指定会话;单独使用 `-f``--fork` 时,会复制当前项目最近更新的会话。`--fork` 不能与 `--resume``--last` 同时使用。
93+
94+
新会话保留消息历史、当前上下文 token 数和代码快照分支起点,但累计 `usage` 和按模型统计的 usage 会清零。之后新增的消息、模型用量和代码快照只更新 fork 出来的会话,不会修改源会话。
95+
8996
### 长会话压缩
9097

9198
当会话上下文过长时,Deep Code 会触发压缩流程:

docs/session-persistence_en.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Session Persistence
22

3-
Deep Code stores per-project session history in the local user directory. This history powers `/resume`, `/continue`, and `/undo`, and it remains available after the current terminal process exits.
3+
Deep Code stores per-project session history in the local user directory. This history powers `/resume`, `/fork`, `/continue`, and `/undo`, and it remains available after the current terminal process exits.
44

55
## Storage Location
66

@@ -31,6 +31,7 @@ The project storage directory contains these main files and directories:
3131
- Latest assistant reply, thinking content, refusal reason, and failure reason.
3232
- Latest tool-call data, token usage, and active token count.
3333
- Metadata for subprocesses still tracked by the session.
34+
- The direct source session ID and source head message ID for forked sessions.
3435

3536
The default session title comes from the first 100 characters of the first user prompt. Renaming a session from the session list updates the title in the index.
3637

@@ -86,6 +87,12 @@ The per-project session list keeps the 50 most recent entries. When the limit is
8687

8788
When continuing a session, Deep Code reads the message file, filters compacted old messages, repairs incomplete tool-call context, and converts the usable history into model request messages.
8889

90+
### Forking A Session
91+
92+
`/fork` copies the active conversation and switches to the new session. On the command line, `--fork <session-id>` copies a specific session; bare `-f` or `--fork` copies the most recently updated session in the current project. `--fork` cannot be combined with `--resume` or `--last`.
93+
94+
The new session keeps the message history, current context token count, and code-checkpoint starting point, while cumulative `usage` and per-model usage are reset. Subsequent messages, model usage, and checkpoints update only the forked session and leave the source session unchanged.
95+
8996
### Long-Session Compaction
9097

9198
When the conversation context grows too large, Deep Code can compact earlier messages:

packages/cli/src/cli-args.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface ParsedCliArgs {
3131
* - `string` — --resume <sessionId> was used
3232
*/
3333
resume: string | true | undefined;
34+
/** Fork source session. Bare --fork selects the most recent project session. */
35+
fork: string | true | undefined;
3436
/** True when --version / -v was passed */
3537
version: boolean;
3638
/** True when --help / -h was passed */
@@ -65,6 +67,7 @@ const EPILOG = [
6567
" /new Start a fresh conversation",
6668
" /init Initialize an AGENTS.md file with instructions for LLM",
6769
" /resume Pick a previous conversation to continue",
70+
" /fork Fork the current conversation",
6871
" /continue Continue the active conversation, or resume one if empty",
6972
" /undo Restore code and/or conversation to a previous point",
7073
" /mcp Show MCP server status and available tools",
@@ -97,6 +100,11 @@ async function configureYargs(argv?: string[]) {
97100
type: "string",
98101
describe: "Resume a specific session by its ID. Use without an ID to show session picker.",
99102
})
103+
.option("fork", {
104+
alias: "f",
105+
type: "string",
106+
describe: "Fork a specific session by its ID. Use without an ID to fork the most recent session.",
107+
})
100108
.option("last", {
101109
alias: "l",
102110
type: "boolean",
@@ -120,10 +128,19 @@ async function configureYargs(argv?: string[]) {
120128
if (argv["last"] === true && argv["resume"] !== undefined) {
121129
return "Cannot use --last together with --resume. Use --last to resume the most recent session, or --resume <sessionId> for a specific session.";
122130
}
131+
if (argv["fork"] !== undefined && argv["resume"] !== undefined) {
132+
return "Cannot use --fork together with --resume.";
133+
}
134+
if (argv["last"] === true && argv["fork"] !== undefined) {
135+
return "Cannot use --last together with --fork.";
136+
}
123137
// validate --resume <sessionId> format if provided
124138
if (argv["resume"] && argv["resume"] !== "" && !isValidSessionId(argv["resume"] as string)) {
125139
return `Invalid session ID: "${argv["resume"]}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
126140
}
141+
if (argv["fork"] && argv["fork"] !== "" && !isValidSessionId(argv["fork"] as string)) {
142+
return `Invalid session ID: "${argv["fork"]}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
143+
}
127144
// empty prompt is meaningless
128145
if (prompt !== undefined && prompt.trim() === "") {
129146
return "--prompt / -p requires a non-empty value.";
@@ -141,6 +158,7 @@ async function configureYargs(argv?: string[]) {
141158
.example("deepcode -p <prompt>", "Launch the TUI and submit a prompt")
142159
.example("deepcode -x -p <prompt>", "Run one prompt without launching the TUI")
143160
.example("deepcode -r, --resume [sessionId]", "Resume a session or show session picker")
161+
.example("deepcode -f, --fork [sessionId]", "Fork a session or the most recent session")
144162
.example('cat error.log | deepcode -x -p "Explain this error"', "Use piped stdin as additional context")
145163
.epilog(EPILOG)
146164
.strict()
@@ -181,10 +199,21 @@ export async function parseArguments(argv?: string[]): Promise<ParsedCliArgs> {
181199
resume = resumeRaw;
182200
}
183201

202+
const forkRaw = parsed.fork as string | undefined;
203+
let fork: ParsedCliArgs["fork"];
204+
if (forkRaw === undefined) {
205+
fork = undefined;
206+
} else if (forkRaw === "") {
207+
fork = true;
208+
} else {
209+
fork = forkRaw;
210+
}
211+
184212
return {
185213
prompt: parsed.prompt as string | undefined,
186214
exec: parsed.exec === true,
187215
resume,
216+
fork,
188217
version: parsed.version === true,
189218
help: parsed.help === true,
190219
last: parsed.last === true,

packages/cli/src/cli.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ async function main(): Promise<void> {
3030

3131
let initialPrompt = parsed.prompt;
3232
let resumeSessionId = parsed.resume;
33+
let forkSessionId = parsed.fork;
3334
const projectRoot = process.cwd();
3435

3536
// Resolve --last to the most recent session ID for the current project
@@ -51,11 +52,30 @@ async function main(): Promise<void> {
5152
}
5253
}
5354

55+
if (forkSessionId === true) {
56+
const projectCode = getProjectCode(projectRoot);
57+
const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json");
58+
try {
59+
const index = JSON.parse(readFileSync(indexPath, "utf-8"));
60+
const entries: { id: string; updateTime: string }[] = Array.isArray(index?.entries) ? index.entries : [];
61+
if (entries.length === 0) {
62+
writeStderrLine("No previous sessions found for the current project.\n");
63+
process.exit(1);
64+
}
65+
const mostRecent = entries.reduce((a, b) => (a.updateTime > b.updateTime ? a : b));
66+
forkSessionId = mostRecent.id;
67+
} catch {
68+
writeStderrLine("No previous sessions found for the current project.\n");
69+
process.exit(1);
70+
}
71+
}
72+
5473
if (parsed.exec) {
5574
process.exitCode = await runExecMode({
5675
prompt: parsed.prompt!,
5776
projectRoot,
58-
resumeSessionId: typeof parsed.resume === "string" ? parsed.resume : undefined,
77+
resumeSessionId: typeof resumeSessionId === "string" ? resumeSessionId : undefined,
78+
forkSessionId: typeof forkSessionId === "string" ? forkSessionId : undefined,
5979
});
6080
return;
6181
}
@@ -83,6 +103,22 @@ async function main(): Promise<void> {
83103
}
84104
}
85105

106+
if (typeof forkSessionId === "string") {
107+
const projectCode = getProjectCode(projectRoot);
108+
const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json");
109+
try {
110+
const index = JSON.parse(readFileSync(indexPath, "utf-8"));
111+
const found = Array.isArray(index?.entries) && index.entries.some((e: { id: string }) => e.id === forkSessionId);
112+
if (!found) {
113+
writeStderrLine(`No saved session found with ID "${forkSessionId}".\n`);
114+
process.exit(1);
115+
}
116+
} catch {
117+
writeStderrLine(`No saved session found with ID "${forkSessionId}".\n`);
118+
process.exit(1);
119+
}
120+
}
121+
86122
const updatePromptResult = await promptForPendingUpdate(packageInfo);
87123
if (updatePromptResult.installed) {
88124
process.exit(0);
@@ -96,12 +132,15 @@ async function main(): Promise<void> {
96132
initialPrompt = undefined;
97133
const appResumeSessionId = resumeSessionId;
98134
resumeSessionId = undefined;
135+
const appForkSessionId = forkSessionId;
136+
forkSessionId = undefined;
99137
const inkInstance = render(
100138
<AppContainer
101139
projectRoot={projectRoot}
102140
version={packageInfo?.version ?? CLI_VERSION}
103141
initialPrompt={appInitialPrompt}
104142
resumeSessionId={appResumeSessionId}
143+
forkSessionId={typeof appForkSessionId === "string" ? appForkSessionId : undefined}
105144
onRestart={() => restartRef.current?.()}
106145
/>,
107146
{ exitOnCtrlC: false }

packages/cli/src/exec-runner.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type ExecSessionManager = Pick<
1515
| "dispose"
1616
| "getActiveSessionId"
1717
| "getSession"
18+
| "forkSession"
1819
| "handleUserPrompt"
1920
| "initMcpServers"
2021
| "interruptActiveSession"
@@ -30,6 +31,7 @@ export interface ExecRunnerOptions {
3031
prompt: string;
3132
projectRoot: string;
3233
resumeSessionId?: string;
34+
forkSessionId?: string;
3335
input?: ExecInputStream;
3436
}
3537

@@ -88,13 +90,21 @@ export async function runExecMode(
8890
return 1;
8991
}
9092
}
93+
if (options.forkSessionId) {
94+
if (!manager.getSession(options.forkSessionId)) {
95+
deps.writeStderrLine(`No saved session found with ID "${options.forkSessionId}".`);
96+
return 1;
97+
}
98+
}
9199

92100
const prompt = await deps.buildPrompt(options.prompt, options.input ?? process.stdin);
93101
if (interrupted) {
94102
return 130;
95103
}
96104

97-
if (options.resumeSessionId) {
105+
if (options.forkSessionId) {
106+
manager.setActiveSessionId(manager.forkSession(options.forkSessionId));
107+
} else if (options.resumeSessionId) {
98108
manager.setActiveSessionId(options.resumeSessionId);
99109
}
100110

0 commit comments

Comments
 (0)