Skip to content

Commit b9e0e94

Browse files
committed
feat: add per-chat project switching
1 parent c669fb3 commit b9e0e94

8 files changed

Lines changed: 274 additions & 34 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ PROACTIVE_USER_IDS=123456789
44

55
CODEX_COMMAND=codex
66
CODEX_ARGS=
7+
WORKSPACE_ROOT=.
78
CODEX_WORKDIR=.
89
STREAM_THROTTLE_MS=1200
910
STREAM_BUFFER_CHARS=120000

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Minimum required:
4747
```bash
4848
BOT_TOKEN=123456789:telegram-token
4949
ALLOWED_USER_IDS=123456789
50+
WORKSPACE_ROOT=.
5051
CODEX_WORKDIR=.
5152
```
5253

@@ -110,6 +111,9 @@ General:
110111
- `/start` - bootstrap message
111112
- `/help` - command summary
112113
- `/status` - show current chat status, active runner mode, workdir, model override, MCP servers
114+
- `/pwd` - show the current project directory for this chat
115+
- `/repo` - list switchable git projects under `WORKSPACE_ROOT`
116+
- `/repo <name>` - switch the current chat to another project
113117
- `/new` - close current session and start fresh on the next message
114118
- `/exec <task>` - force a one-off `codex exec`
115119
- `/auto <task>` - force a one-off `codex exec --full-auto`
@@ -139,6 +143,7 @@ Telegram adaptation notes:
139143
- `/auto` behaves like `codex exec --full-auto "task"`
140144
- `/new` is implemented by the bot and resets the current chat session
141145
- `/status` is implemented by the bot and reports local runtime state
146+
- `/repo` is implemented by the bot and switches the per-chat working directory inside `WORKSPACE_ROOT`
142147
- `/plan` translates to a planning-only prompt instead of passing a raw `/plan` slash command to Codex
143148

144149
## Streaming and Reasoning Visualization
@@ -170,6 +175,7 @@ Required:
170175
```bash
171176
BOT_TOKEN=...
172177
ALLOWED_USER_IDS=123456789,987654321
178+
WORKSPACE_ROOT=.
173179
CODEX_WORKDIR=.
174180
```
175181

@@ -178,6 +184,7 @@ Common options:
178184
```bash
179185
CODEX_COMMAND=codex
180186
CODEX_ARGS=
187+
WORKSPACE_ROOT=/Users/yourname/projects
181188
STREAM_THROTTLE_MS=1200
182189
STREAM_BUFFER_CHARS=120000
183190
REASONING_RENDER_MODE=spoiler
@@ -208,6 +215,7 @@ E2E_TEST_COMMAND=npx playwright test --reporter=line
208215
- Do not commit `.env`, tokens, or session artifacts
209216
- Run bot under a restricted OS user in production
210217
- Keep `CODEX_WORKDIR` scoped to a safe workspace root
218+
- Keep `WORKSPACE_ROOT` limited to a parent directory that only contains projects you want the bot to access
211219
- Prefer least-privilege GitHub PAT
212220

213221
## Troubleshooting

src/bot/handlers.js

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function registerHandlers({ bot, router, ptyManager, skills, scheduler })
4545
"codex-telegram-claws ready.",
4646
"普通消息和编码任务会路由到 Codex。",
4747
"MCP 只在显式 /mcp 命令下调用。",
48-
"试试: /status, /exec, /auto, /plan, /model, /new",
48+
"试试: /status, /repo, /pwd, /exec, /auto, /plan, /model, /new",
4949
"GitHub 指令示例: /gh commit \"feat: init\""
5050
].join("\n")
5151
);
@@ -58,6 +58,9 @@ export function registerHandlers({ bot, router, ptyManager, skills, scheduler })
5858
"Commands:",
5959
"/help - 显示帮助",
6060
"/status - 查看当前 chat 的运行状态",
61+
"/pwd - 查看当前项目目录",
62+
"/repo - 列出可切换项目",
63+
"/repo <name> - 切换当前 chat 的项目",
6164
"/new - 新建会话并清空当前上下文",
6265
"/exec <task> - 强制用 codex exec 运行一次任务",
6366
"/auto <task> - 强制用 codex exec --full-auto 运行任务",
@@ -87,12 +90,63 @@ export function registerHandlers({ bot, router, ptyManager, skills, scheduler })
8790
}`,
8891
`preferred model: ${status.preferredModel || "inherit codex default"}`,
8992
`command: ${status.command}`,
93+
`workspace root: ${status.workspaceRoot}`,
9094
`workdir: ${status.workdir}`,
9195
`mcp servers: ${status.mcpServers.length ? status.mcpServers.join(", ") : "none"}`
9296
].join("\n")
9397
);
9498
});
9599

100+
bot.command("pwd", async (ctx) => {
101+
const status = ptyManager.getStatus(ctx.chat.id);
102+
await sendChunkedMarkdown(
103+
ctx,
104+
[
105+
`workspace root: ${status.workspaceRoot}`,
106+
`current project: ${status.relativeWorkdir}`,
107+
`workdir: ${status.workdir}`
108+
].join("\n")
109+
);
110+
});
111+
112+
bot.command("repo", async (ctx) => {
113+
const payload = extractCommandPayload(ctx.message.text, "repo");
114+
if (!payload) {
115+
const status = ptyManager.getStatus(ctx.chat.id);
116+
const projects = ptyManager.listProjects();
117+
const lines = projects.map((project) => {
118+
const marker = project.path === status.workdir ? " <current>" : "";
119+
return `- ${project.relativePath}${marker}`;
120+
});
121+
122+
await sendChunkedMarkdown(
123+
ctx,
124+
[
125+
`workspace root: ${status.workspaceRoot}`,
126+
"Available projects:",
127+
...(lines.length ? lines : ["- (no git repos found under workspace root)"]),
128+
"",
129+
"Usage: /repo <name>"
130+
].join("\n")
131+
);
132+
return;
133+
}
134+
135+
try {
136+
const result = ptyManager.switchWorkdir(ctx.chat.id, payload);
137+
await sendChunkedMarkdown(
138+
ctx,
139+
[
140+
"Project switched.",
141+
`current project: ${result.relativePath}`,
142+
`workdir: ${result.workdir}`
143+
].join("\n")
144+
);
145+
} catch (error) {
146+
await sendChunkedMarkdown(ctx, `切换项目失败: ${error.message}`);
147+
}
148+
});
149+
96150
bot.command("new", async (ctx) => {
97151
const closed = ptyManager.closeSession(ctx.chat.id);
98152
await sendChunkedMarkdown(
@@ -219,7 +273,11 @@ export function registerHandlers({ bot, router, ptyManager, skills, scheduler })
219273
bot.command("gh", async (ctx) => {
220274
try {
221275
const text = extractCommandPayload(ctx.message.text, "gh") || "help";
222-
const result = await skills.github.execute({ text: `/gh ${text}`, ctx });
276+
const result = await skills.github.execute({
277+
text: `/gh ${text}`,
278+
ctx,
279+
workdir: ptyManager.getStatus(ctx.chat.id).workdir
280+
});
223281
await sendSkillResult(ctx, result);
224282
} catch (error) {
225283
await sendChunkedMarkdown(ctx, `GitHub skill 执行失败: ${error.message}`);

src/config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,20 @@ export function loadConfig() {
8383
? rawMcpServers.map((server, index) => normalizeMcpServer(server, index)).filter(Boolean)
8484
: [];
8585
const runnerCwd = resolveDirectory(process.env.CODEX_WORKDIR, "CODEX_WORKDIR");
86+
const workspaceRoot = resolveDirectory(
87+
process.env.WORKSPACE_ROOT,
88+
"WORKSPACE_ROOT",
89+
runnerCwd
90+
);
8691
const githubDefaultWorkdir = resolveDirectory(process.env.GITHUB_DEFAULT_WORKDIR, "GITHUB_DEFAULT_WORKDIR");
8792

8893
return {
8994
app: {
9095
name: "codex-telegram-claws"
9196
},
97+
workspace: {
98+
root: workspaceRoot
99+
},
92100
telegram: {
93101
botToken: required("BOT_TOKEN"),
94102
allowedUserIds,

src/orchestrator/skills/githubSkill.js

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,17 @@ function pickJobId(text, fallbackJobId) {
3939
export class GitHubSkill {
4040
constructor({ config }) {
4141
this.config = config;
42-
this.git = simpleGit({
43-
baseDir: config.github.defaultWorkdir
44-
});
4542
this.octokit = config.github.token ? new Octokit({ auth: config.github.token }) : null;
4643
this.testJobs = new Map();
4744
this.latestTestJobId = "";
4845
}
4946

47+
getGit(workdir) {
48+
return simpleGit({
49+
baseDir: workdir || this.config.github.defaultWorkdir
50+
});
51+
}
52+
5053
supports(text) {
5154
const normalized = text.toLowerCase();
5255
return (
@@ -55,7 +58,7 @@ export class GitHubSkill {
5558
);
5659
}
5760

58-
async execute({ text }) {
61+
async execute({ text, workdir }) {
5962
const stripped = text.replace(/^\/gh(@\w+)?\s*/i, "").trim();
6063
const normalized = stripped.toLowerCase();
6164

@@ -64,23 +67,23 @@ export class GitHubSkill {
6467
}
6568

6669
if (/|create repo|new repo/.test(normalized)) {
67-
return this.createRepoFromText(stripped);
70+
return this.createRepoFromText(stripped, workdir);
6871
}
6972

7073
if (/|test status|status/.test(normalized)) {
7174
return this.readTestStatusFromText(stripped);
7275
}
7376

7477
if (/|run test|playwright|e2e/.test(normalized)) {
75-
return this.startTests();
78+
return this.startTests(workdir);
7679
}
7780

7881
if ((/|\bpush\b/.test(normalized) && !/|commit/.test(normalized))) {
79-
return this.pushOnly();
82+
return this.pushOnly(workdir);
8083
}
8184

8285
if (/||commit|push/.test(normalized)) {
83-
return this.commitAndPush(stripped);
86+
return this.commitAndPush(stripped, workdir);
8487
}
8588

8689
return { text: this.helpText() };
@@ -97,30 +100,37 @@ export class GitHubSkill {
97100
].join("\n");
98101
}
99102

100-
async commitAndPush(rawText) {
101-
const status = await this.git.status();
103+
async commitAndPush(rawText, workdir) {
104+
const git = this.getGit(workdir);
105+
const status = await git.status();
102106
if (!status.files.length) {
103107
return { text: "没有检测到变更,跳过 commit。" };
104108
}
105109

106110
const explicitMessage = extractQuotedMessage(rawText);
107111
const commitMessage = explicitMessage || buildAutoCommitMessage(status);
108112

109-
await this.git.add(".");
110-
await this.git.commit(commitMessage);
113+
await git.add(".");
114+
await git.commit(commitMessage);
111115

112-
const branchInfo = await this.git.branch();
116+
const branchInfo = await git.branch();
113117
const branch = branchInfo.current || this.config.github.defaultBranch;
114118

115119
try {
116-
await this.git.push("origin", branch);
120+
await git.push("origin", branch);
117121
return {
118-
text: `提交并推送成功。\nbranch: ${branch}\nmessage: ${commitMessage}`
122+
text: [
123+
"提交并推送成功。",
124+
`workdir: ${workdir || this.config.github.defaultWorkdir}`,
125+
`branch: ${branch}`,
126+
`message: ${commitMessage}`
127+
].join("\n")
119128
};
120129
} catch (error) {
121130
return {
122131
text: [
123132
"提交完成,但推送失败。",
133+
`workdir: ${workdir || this.config.github.defaultWorkdir}`,
124134
`branch: ${branch}`,
125135
`message: ${commitMessage}`,
126136
`error: ${error.message}`
@@ -129,16 +139,17 @@ export class GitHubSkill {
129139
}
130140
}
131141

132-
async pushOnly() {
133-
const branchInfo = await this.git.branch();
142+
async pushOnly(workdir) {
143+
const git = this.getGit(workdir);
144+
const branchInfo = await git.branch();
134145
const branch = branchInfo.current || this.config.github.defaultBranch;
135-
await this.git.push("origin", branch);
146+
await git.push("origin", branch);
136147
return {
137-
text: `推送成功。\nbranch: ${branch}`
148+
text: `推送成功。\nworkdir: ${workdir || this.config.github.defaultWorkdir}\nbranch: ${branch}`
138149
};
139150
}
140151

141-
async createRepoFromText(rawText) {
152+
async createRepoFromText(rawText, workdir) {
142153
if (!this.octokit) {
143154
return { text: "缺少 GITHUB_TOKEN,无法调用 GitHub API 创建仓库。" };
144155
}
@@ -148,42 +159,45 @@ export class GitHubSkill {
148159
return { text: "无法解析仓库名。示例: /gh create repo codex-telegram-claws-demo" };
149160
}
150161

162+
const git = this.getGit(workdir);
151163
const isPrivate = !/public|/.test(rawText.toLowerCase());
152164
const { data: repo } = await this.octokit.repos.createForAuthenticatedUser({
153165
name: repoName,
154166
private: isPrivate,
155167
auto_init: false
156168
});
157169

158-
const remotes = await this.git.getRemotes(true);
170+
const remotes = await git.getRemotes(true);
159171
const origin = remotes.find((remote) => remote.name === "origin");
160172
if (!origin) {
161-
await this.git.addRemote("origin", repo.clone_url);
173+
await git.addRemote("origin", repo.clone_url);
162174
} else {
163-
await this.git.remote(["set-url", "origin", repo.clone_url]);
175+
await git.remote(["set-url", "origin", repo.clone_url]);
164176
}
165177

166-
const branchInfo = await this.git.branch();
178+
const branchInfo = await git.branch();
167179
const branch = branchInfo.current || this.config.github.defaultBranch;
168180

169-
await this.git.push(["-u", "origin", branch]);
181+
await git.push(["-u", "origin", branch]);
170182

171183
return {
172184
text: [
173185
"仓库创建并关联成功。",
186+
`workdir: ${workdir || this.config.github.defaultWorkdir}`,
174187
`repo: ${repo.full_name}`,
175188
`url: ${repo.html_url}`,
176189
`branch: ${branch}`
177190
].join("\n")
178191
};
179192
}
180193

181-
async startTests() {
194+
async startTests(workdir) {
182195
const jobId = `job-${Date.now()}`;
183196
const command = this.config.github.e2eCommand;
184197
const job = {
185198
jobId,
186199
status: "running",
200+
workdir: workdir || this.config.github.defaultWorkdir,
187201
command,
188202
startedAt: new Date().toISOString(),
189203
finishedAt: "",
@@ -192,7 +206,7 @@ export class GitHubSkill {
192206
};
193207

194208
const child = spawn(command, {
195-
cwd: this.config.github.defaultWorkdir,
209+
cwd: workdir || this.config.github.defaultWorkdir,
196210
env: process.env,
197211
shell: true
198212
});
@@ -225,6 +239,7 @@ export class GitHubSkill {
225239
text: [
226240
"已触发自动化测试任务。",
227241
`jobId: ${jobId}`,
242+
`workdir: ${job.workdir}`,
228243
`command: ${command}`,
229244
"使用 /gh test status <jobId> 查询状态。"
230245
].join("\n"),
@@ -247,6 +262,7 @@ export class GitHubSkill {
247262
text: [
248263
`jobId: ${job.jobId}`,
249264
`status: ${job.status}`,
265+
`workdir: ${job.workdir}`,
250266
`startedAt: ${job.startedAt}`,
251267
job.finishedAt ? `finishedAt: ${job.finishedAt}` : "finishedAt: running",
252268
job.exitCode === null ? "exitCode: running" : `exitCode: ${job.exitCode}`,

0 commit comments

Comments
 (0)