pi-telegram-plus 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-agent";
5
+ import type { SessionNameDeps, CommandRegistry } from "./register.ts";
6
+ import { escapeHtml } from "../html.ts";
7
+ import type { CapturedAgentSession } from "../types.ts";
8
+
9
+ function truncate(text: string, max: number): string {
10
+ return text.length <= max ? text : text.slice(0, max - 1) + "…";
11
+ }
12
+
13
+ function isMessageEntry(entry: SessionEntry): entry is SessionEntry & { type: "message"; message: { role: string; content?: unknown } } {
14
+ return entry.type === "message";
15
+ }
16
+
17
+ function getEntryText(entry: { role: string; content?: unknown }): string {
18
+ if (!entry.content || !Array.isArray(entry.content)) return "(empty)";
19
+ return (entry.content as Array<{ type?: string; text?: string }>)
20
+ .filter((c) => c.type === "text" && typeof c.text === "string")
21
+ .map((c) => c.text!)
22
+ .join(" ")
23
+ .replace(/\n/g, " ") || "(empty)";
24
+ }
25
+
26
+ function stripMatchingQuotes(input: string): string {
27
+ if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) {
28
+ return input.slice(1, -1);
29
+ }
30
+ return input;
31
+ }
32
+
33
+ function resolveTargetCwd(input: string, cwd: string): string {
34
+ const unquoted = stripMatchingQuotes(input.trim());
35
+ const expanded = unquoted === "~" || unquoted.startsWith("~/")
36
+ ? resolve(homedir(), unquoted.slice(2))
37
+ : unquoted;
38
+ return resolve(cwd, expanded);
39
+ }
40
+
41
+ /**
42
+ * All command handlers capture ctx.ui at entry and use the captured reference.
43
+ * See model.ts for explanation.
44
+ */
45
+ export function registerSessionCommands(
46
+ registry: CommandRegistry,
47
+ deps: SessionNameDeps,
48
+ ): void {
49
+ // ── /new ──────────────────────────────────────────────────────────────
50
+ registry.registerCommand("new", {
51
+ description: "Start a new session",
52
+ handler: async (_args, ctx) => {
53
+ const ui = ctx.ui;
54
+ const confirmed = await ui.confirm("New session", "Start a new session? Current session will be saved.");
55
+ if (!confirmed) return;
56
+ const result = await ctx.newSession({
57
+ withSession: async () => {
58
+ ui.notify("✅ New session started.", "info");
59
+ },
60
+ });
61
+ if (result.cancelled) ui.notify("Cancelled.", "info");
62
+ },
63
+ });
64
+
65
+ // ── /fork ────────────────────────────────────────────────────────────
66
+ registry.registerCommand("fork", {
67
+ description: "Fork from a previous user message",
68
+ handler: async (args, ctx) => {
69
+ const ui = ctx.ui;
70
+ const session = deps.getSession();
71
+ if (!session) {
72
+ ui.notify("No active session", "error");
73
+ return;
74
+ }
75
+
76
+ if (args) {
77
+ await ctx.fork(args, { position: "before" });
78
+ ui.notify("✅ Forked session.", "info");
79
+ return;
80
+ }
81
+
82
+ const entries = session.sessionManager.getEntries();
83
+ const userEntries = entries.filter(isMessageEntry).filter((e) => e.message.role === "user");
84
+ if (userEntries.length === 0) {
85
+ ui.notify("No user messages to fork from.", "info");
86
+ return;
87
+ }
88
+
89
+ const shown = userEntries;
90
+ const labels = shown.map((e) => truncate(getEntryText(e.message), 50));
91
+ const choice = await ui.select("Fork from message", labels);
92
+ if (!choice) return;
93
+ const index = labels.indexOf(choice);
94
+ if (index < 0) return;
95
+ await ctx.fork(shown[index].id, { position: "before" });
96
+ ui.notify("✅ Forked session.", "info");
97
+ },
98
+ });
99
+
100
+ // ── /clone ────────────────────────────────────────────────────────────
101
+ registry.registerCommand("clone", {
102
+ description: "Clone at a previous user message",
103
+ handler: async (args, ctx) => {
104
+ const ui = ctx.ui;
105
+ const session = deps.getSession();
106
+ if (!session) {
107
+ ui.notify("No active session", "error");
108
+ return;
109
+ }
110
+
111
+ if (args) {
112
+ await ctx.fork(args, { position: "at" });
113
+ ui.notify("✅ Cloned session.", "info");
114
+ return;
115
+ }
116
+
117
+ const entries = session.sessionManager.getEntries();
118
+ const userEntries = entries.filter(isMessageEntry).filter((e) => e.message.role === "user");
119
+ if (userEntries.length === 0) {
120
+ ui.notify("No user messages to clone from.", "info");
121
+ return;
122
+ }
123
+
124
+ const shown = userEntries;
125
+ const labels = shown.map((e) => truncate(getEntryText(e.message), 50));
126
+ const choice = await ui.select("Clone at message", labels);
127
+ if (!choice) return;
128
+ const index = labels.indexOf(choice);
129
+ if (index < 0) return;
130
+ await ctx.fork(shown[index].id, { position: "at" });
131
+ ui.notify("✅ Cloned session.", "info");
132
+ },
133
+ });
134
+
135
+ // ── /tree ─────────────────────────────────────────────────────────────
136
+ registry.registerCommand("tree", {
137
+ description: "Navigate session tree to a previous message",
138
+ handler: async (args, ctx) => {
139
+ const ui = ctx.ui;
140
+ const session = deps.getSession();
141
+ if (!session) {
142
+ ui.notify("No active session", "error");
143
+ return;
144
+ }
145
+
146
+ if (args) {
147
+ const wantsSummary = await ui.confirm("Navigate", "Summarize the abandoned branch?");
148
+ await ctx.navigateTree(args, { summarize: wantsSummary });
149
+ ui.notify("✅ Navigated to selected point.", "info");
150
+ return;
151
+ }
152
+
153
+ const entries = session.sessionManager.getEntries();
154
+ const userEntries = entries.filter(isMessageEntry).filter((e) => e.message.role === "user");
155
+ if (userEntries.length === 0) {
156
+ ui.notify("No entries to navigate to.", "info");
157
+ return;
158
+ }
159
+
160
+ const shown = userEntries;
161
+ const labels = shown.map((e) => truncate(getEntryText(e.message), 50));
162
+ const choice = await ui.select("Navigate to message", labels);
163
+ if (!choice) return;
164
+ const index = labels.indexOf(choice);
165
+ if (index < 0) return;
166
+ const entry = shown[index];
167
+ const wantsSummary = await ui.confirm("Navigate", "Summarize the abandoned branch?");
168
+ await ctx.navigateTree(entry.id, { summarize: wantsSummary });
169
+ ui.notify("✅ Navigated to selected point.", "info");
170
+ },
171
+ });
172
+
173
+ // ── /cwd ──────────────────────────────────────────────────────────────
174
+ registry.registerCommand("cwd", {
175
+ description: "Show current working directory",
176
+ handler: async (_args, ctx) => {
177
+ ctx.ui.notify(`cwd: ${escapeHtml(ctx.cwd)}`, "info");
178
+ },
179
+ });
180
+
181
+ // ── /cd ───────────────────────────────────────────────────────────────
182
+ registry.registerCommand("cd", {
183
+ description: "Switch pi working directory",
184
+ handler: async (args, ctx) => {
185
+ const ui = ctx.ui;
186
+ const rawPath = args.trim() || await ui.input("Working directory", ctx.cwd);
187
+ if (!rawPath) return;
188
+
189
+ const targetCwd = resolveTargetCwd(rawPath, ctx.cwd);
190
+ const info = await stat(targetCwd).catch(() => undefined);
191
+ if (!info?.isDirectory()) {
192
+ ui.notify(`Not a directory: ${escapeHtml(targetCwd)}`, "error");
193
+ return;
194
+ }
195
+
196
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
197
+ const sessionManager = SessionManager.create(targetCwd, undefined, {
198
+ ...(currentSessionFile ? { parentSession: currentSessionFile } : {}),
199
+ });
200
+ // Ensure the header-only session file exists before switchSession opens it.
201
+ (sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
202
+ const sessionPath = sessionManager.getSessionFile();
203
+ if (!sessionPath) {
204
+ ui.notify("Cannot switch cwd from an ephemeral session.", "error");
205
+ return;
206
+ }
207
+
208
+ const result = await ctx.switchSession(sessionPath, {
209
+ withSession: async (nextCtx: { ui: import("@earendil-works/pi-coding-agent").ExtensionUIContext }) => {
210
+ nextCtx.ui.notify(`✅ Switched cwd:\n${escapeHtml(targetCwd)}`, "info");
211
+ },
212
+ });
213
+ if (result.cancelled) ui.notify("Cwd switch cancelled.", "info");
214
+ },
215
+ });
216
+
217
+ // ── /resume ───────────────────────────────────────────────────────────
218
+ registry.registerCommand("resume", {
219
+ description: "Resume a previous session",
220
+ handler: async (_args, ctx) => {
221
+ const ui = ctx.ui;
222
+ const sessions = await SessionManager.list(ctx.cwd);
223
+ if (sessions.length === 0) {
224
+ ui.notify("No sessions found.", "info");
225
+ return;
226
+ }
227
+
228
+ const shown = sessions;
229
+ const labels = shown.map((s) => truncate(s.name ?? s.id, 50));
230
+ const choice = await ui.select("Resume session", labels);
231
+ if (!choice) return;
232
+ const index = labels.indexOf(choice);
233
+ if (index < 0) return;
234
+ await ctx.switchSession(shown[index].path);
235
+ ui.notify("✅ Switched session.", "info");
236
+ },
237
+ });
238
+
239
+ // ── /name ──────────────────────────────────────────────────────────────
240
+ registry.registerCommand("name", {
241
+ description: "Set or show session name",
242
+ handler: async (args, ctx) => {
243
+ const ui = ctx.ui;
244
+ if (args) {
245
+ deps.setSessionName(args);
246
+ ui.notify(`Session name set: ${args}`, "info");
247
+ return;
248
+ }
249
+ const name = deps.getSessionName();
250
+ ui.notify(name ? `Session name: ${name}` : "No session name set. Use /name <name> to set one.", "info");
251
+ },
252
+ });
253
+
254
+ // ── /session ──────────────────────────────────────────────────────────
255
+ registry.registerCommand("session", {
256
+ description: "Show session statistics",
257
+ handler: async (_args, ctx) => {
258
+ const ui = ctx.ui;
259
+ const session = deps.getSession();
260
+ if (!session) {
261
+ ui.notify("No active session", "error");
262
+ return;
263
+ }
264
+ const stats = session.getSessionStats();
265
+ const usage = session.getContextUsage();
266
+ const lines = [
267
+ `Session Info`,
268
+ `id: ${escapeHtml(stats.sessionId)}`,
269
+ `file: ${escapeHtml(stats.sessionFile ?? "ephemeral")}`,
270
+ `messages: ${stats.userMessages}u / ${stats.assistantMessages}a`,
271
+ `tool calls: ${stats.toolCalls}`,
272
+ `tokens: ${stats.tokens.total}`,
273
+ `cost: ${stats.cost.toFixed(4)}`,
274
+ ];
275
+ if (usage) {
276
+ lines.push(`context: ${usage.tokens ?? "?"} / ${usage.contextWindow}`);
277
+ }
278
+ ui.notify(lines.join("\n"), "info");
279
+ },
280
+ });
281
+ }
@@ -0,0 +1,129 @@
1
+ import type { CommandRegistry } from "./register.ts";
2
+ import type { CapturedAgentSession } from "../types.ts";
3
+
4
+ export function registerSettingsCommands(
5
+ registry: CommandRegistry,
6
+ deps: { getSession: () => CapturedAgentSession | undefined },
7
+ ): void {
8
+ registry.registerCommand("settings", {
9
+ description: "View or change common settings",
10
+ handler: async (_args, ctx) => {
11
+ const ui = ctx.ui;
12
+ const session = deps.getSession();
13
+ if (!session) {
14
+ ui.notify("No active session", "error");
15
+ return;
16
+ }
17
+
18
+ const s = session.settingsManager;
19
+ const options = [
20
+ `Hide thinking: ${s.getHideThinkingBlock() ? "on" : "off"}`,
21
+ `Compaction: ${s.getCompactionEnabled() ? "on" : "off"}`,
22
+ `Retry: ${s.getRetryEnabled() ? "on" : "off"}`,
23
+ `Show images: ${s.getShowImages() ? "on" : "off"}`,
24
+ `Terminal progress: ${s.getShowTerminalProgress() ? "on" : "off"}`,
25
+ `Quiet startup: ${s.getQuietStartup() ? "on" : "off"}`,
26
+ `Theme: ${s.getTheme() ?? "default"}`,
27
+ `Default thinking: ${s.getDefaultThinkingLevel() ?? "unset"}`,
28
+ `Block images: ${s.getBlockImages() ? "on" : "off"}`,
29
+ `Image auto resize: ${s.getImageAutoResize() ? "on" : "off"}`,
30
+ `Clear on shrink: ${s.getClearOnShrink() ? "on" : "off"}`,
31
+ `Double escape: ${s.getDoubleEscapeAction()}`,
32
+ `Tree filter: ${s.getTreeFilterMode()}`,
33
+ `Shell prefix: ${s.getShellCommandPrefix() ?? "unset"}`,
34
+ ];
35
+
36
+ const choice = await ui.select("Settings", options);
37
+ if (!choice) return;
38
+
39
+ if (choice.startsWith("Hide thinking")) {
40
+ const next = !s.getHideThinkingBlock();
41
+ s.setHideThinkingBlock(next);
42
+ ui.notify(`Hide thinking: ${next ? "on" : "off"}`, "info");
43
+ return;
44
+ }
45
+ if (choice.startsWith("Compaction")) {
46
+ const next = !s.getCompactionEnabled();
47
+ s.setCompactionEnabled(next);
48
+ ui.notify(`Compaction: ${next ? "on" : "off"}`, "info");
49
+ return;
50
+ }
51
+ if (choice.startsWith("Retry")) {
52
+ const next = !s.getRetryEnabled();
53
+ s.setRetryEnabled(next);
54
+ ui.notify(`Retry: ${next ? "on" : "off"}`, "info");
55
+ return;
56
+ }
57
+ if (choice.startsWith("Show images")) {
58
+ const next = !s.getShowImages();
59
+ s.setShowImages(next);
60
+ ui.notify(`Show images: ${next ? "on" : "off"}`, "info");
61
+ return;
62
+ }
63
+ if (choice.startsWith("Terminal progress")) {
64
+ const next = !s.getShowTerminalProgress();
65
+ s.setShowTerminalProgress(next);
66
+ ui.notify(`Terminal progress: ${next ? "on" : "off"}`, "info");
67
+ return;
68
+ }
69
+ if (choice.startsWith("Quiet startup")) {
70
+ const next = !s.getQuietStartup();
71
+ s.setQuietStartup(next);
72
+ ui.notify(`Quiet startup: ${next ? "on" : "off"}`, "info");
73
+ return;
74
+ }
75
+ if (choice.startsWith("Theme")) {
76
+ const theme = await ui.input("Theme name", s.getTheme() ?? "dark");
77
+ if (!theme) return;
78
+ s.setTheme(theme.trim());
79
+ ui.notify(`Theme set: ${theme.trim()}. Use /reload to apply.`, "info");
80
+ return;
81
+ }
82
+ if (choice.startsWith("Default thinking")) {
83
+ const levels = ["off", "minimal", "low", "medium", "high", "xhigh"];
84
+ const level = await ui.select("Default thinking", levels);
85
+ if (!level) return;
86
+ s.setDefaultThinkingLevel(level as any);
87
+ ui.notify(`Default thinking: ${level}`, "info");
88
+ return;
89
+ }
90
+ if (choice.startsWith("Block images")) {
91
+ const next = !s.getBlockImages();
92
+ s.setBlockImages(next);
93
+ ui.notify(`Block images: ${next ? "on" : "off"}`, "info");
94
+ return;
95
+ }
96
+ if (choice.startsWith("Image auto resize")) {
97
+ const next = !s.getImageAutoResize();
98
+ s.setImageAutoResize(next);
99
+ ui.notify(`Image auto resize: ${next ? "on" : "off"}`, "info");
100
+ return;
101
+ }
102
+ if (choice.startsWith("Clear on shrink")) {
103
+ const next = !s.getClearOnShrink();
104
+ s.setClearOnShrink(next);
105
+ ui.notify(`Clear on shrink: ${next ? "on" : "off"}`, "info");
106
+ return;
107
+ }
108
+ if (choice.startsWith("Double escape")) {
109
+ const value = await ui.select("Double escape action", ["fork", "tree", "none"]);
110
+ if (!value) return;
111
+ s.setDoubleEscapeAction(value as any);
112
+ ui.notify(`Double escape: ${value}`, "info");
113
+ return;
114
+ }
115
+ if (choice.startsWith("Tree filter")) {
116
+ const value = await ui.select("Tree filter", ["default", "no-tools", "user-only", "labeled-only", "all"]);
117
+ if (!value) return;
118
+ s.setTreeFilterMode(value as any);
119
+ ui.notify(`Tree filter: ${value}`, "info");
120
+ return;
121
+ }
122
+ if (choice.startsWith("Shell prefix")) {
123
+ const value = await ui.input("Shell command prefix", s.getShellCommandPrefix() ?? "!");
124
+ s.setShellCommandPrefix(value?.trim() || undefined);
125
+ ui.notify(`Shell prefix: ${value?.trim() || "unset"}`, "info");
126
+ }
127
+ },
128
+ });
129
+ }
@@ -0,0 +1,162 @@
1
+ import { resolve } from "node:path";
2
+ import { bindWorkspaceTelegramConfig, readTelegramConfigStore, unbindWorkspaceTelegramConfig } from "../config.ts";
3
+ import { escapeHtml } from "../html.ts";
4
+ import { getTelegramBotUsername } from "../telegram-api.ts";
5
+ import type { ResolvedTelegramConfig, TelegramConfig, TelegramTransport } from "../types.ts";
6
+ import type { TelegramPollingRuntime } from "../polling.ts";
7
+
8
+ export type TelegramCommandDeps = {
9
+ getConfig: () => TelegramConfig;
10
+ setConfig: (c: TelegramConfig) => void;
11
+ persistConfig: (c: TelegramConfig) => Promise<void>;
12
+ getResolvedConfig: () => ResolvedTelegramConfig | undefined;
13
+ switchResolvedConfig: (next: ResolvedTelegramConfig) => void;
14
+ isTelegramEnabled: () => boolean;
15
+ transport: TelegramTransport;
16
+ getPolling: () => TelegramPollingRuntime;
17
+ refreshStatus: () => void;
18
+ syncTelegramCommands: () => Promise<void>;
19
+ startStatusHeartbeat: () => void;
20
+ clearStatusError: () => void;
21
+ };
22
+
23
+ /** Shared "connect and start" sequence used by both /tg-setup and /tg-connect. */
24
+ async function connectAndStart(
25
+ deps: TelegramCommandDeps,
26
+ token: string,
27
+ botUsername: string | undefined,
28
+ ): Promise<void> {
29
+ const config = deps.getConfig();
30
+ deps.setConfig({ ...config, botToken: token, botUsername, telegramEnabled: true });
31
+ await deps.persistConfig(deps.getConfig());
32
+ deps.getPolling().start();
33
+ await deps.syncTelegramCommands();
34
+ deps.refreshStatus();
35
+ }
36
+
37
+ export function configureTelegramToken(
38
+ ui: { input: (title: string, placeholder?: string) => Promise<string | undefined>; inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> },
39
+ deps: TelegramCommandDeps,
40
+ ): Promise<boolean> {
41
+ return (async () => {
42
+ const token = await (ui.inputSecret?.("Telegram bot token") ?? ui.input("Telegram bot token"));
43
+ if (!token) return false;
44
+ const botUsername = await getTelegramBotUsername(token).catch(() => undefined);
45
+ if (!deps.getResolvedConfig()) {
46
+ deps.switchResolvedConfig({ store: { version: 2, global: {}, workspaces: [] }, scope: "global", config: {} });
47
+ }
48
+ await connectAndStart(deps, token, botUsername);
49
+ return true;
50
+ })();
51
+ }
52
+
53
+ export function registerTelegramCommands(
54
+ registry: { registerCommand: (name: string, options: { description?: string; handler: (args: string, ctx: any) => Promise<void> }) => void },
55
+ deps: TelegramCommandDeps,
56
+ ): void {
57
+ // ── /tg-setup ─────────────────────────────────────────────────────────
58
+ registry.registerCommand("tg-setup", {
59
+ description: "Configure Telegram bot token",
60
+ handler: async (_args, ctx) => {
61
+ const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
62
+ if (!(await configureTelegramToken(ui, deps))) return;
63
+ deps.clearStatusError();
64
+ deps.startStatusHeartbeat();
65
+ ui.notify("Telegram bot token saved and connected.", "info");
66
+ },
67
+ });
68
+
69
+ // ── /tg-connect ───────────────────────────────────────────────────────
70
+ registry.registerCommand("tg-connect", {
71
+ description: "Enable/start Telegram connection",
72
+ handler: async (_args, ctx) => {
73
+ const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
74
+ try {
75
+ if (!deps.getConfig().botToken) {
76
+ if (!(await configureTelegramToken(ui, deps))) return;
77
+ } else {
78
+ const botUsername = deps.getConfig().botUsername ?? await getTelegramBotUsername(deps.getConfig().botToken!).catch(() => undefined);
79
+ await connectAndStart(deps, deps.getConfig().botToken!, botUsername);
80
+ }
81
+ } catch { /* connection errors reported via polling onError */ }
82
+ deps.clearStatusError();
83
+ deps.startStatusHeartbeat();
84
+ ui.notify("Telegram connected.", "info");
85
+ },
86
+ });
87
+
88
+ // ── /tg-disconnect ────────────────────────────────────────────────────
89
+ registry.registerCommand("tg-disconnect", {
90
+ description: "Disable/stop Telegram connection without deleting the token",
91
+ handler: async (_args, ctx) => {
92
+ await deps.getPolling().stop();
93
+ deps.setConfig({ ...deps.getConfig(), telegramEnabled: false });
94
+ await deps.persistConfig(deps.getConfig());
95
+ deps.clearStatusError();
96
+ deps.refreshStatus();
97
+ ctx.ui.notify("Telegram disconnected. Token is kept; use /tg-connect to reconnect.", "info");
98
+ },
99
+ });
100
+
101
+ // ── /tg-bind-cwd ──────────────────────────────────────────────────────
102
+ registry.registerCommand("tg-bind-cwd", {
103
+ description: "Bind current directory to a Telegram bot",
104
+ handler: async (args, ctx) => {
105
+ const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
106
+ const config = deps.getConfig();
107
+ const workspacePath = resolve(args.trim() || ctx.cwd || process.cwd());
108
+ const token = await (ui.inputSecret?.(`Telegram bot token for ${workspacePath}`) ?? ui.input(`Telegram bot token for ${workspacePath}`));
109
+ if (!token) return;
110
+ const botUsername = await getTelegramBotUsername(token).catch(() => undefined);
111
+ await deps.getPolling().stop();
112
+ deps.switchResolvedConfig(await bindWorkspaceTelegramConfig(workspacePath, {
113
+ botToken: token,
114
+ botUsername,
115
+ telegramEnabled: true,
116
+ tool: config.tool,
117
+ thinking: config.thinking,
118
+ messageMode: config.messageMode,
119
+ }));
120
+ deps.getPolling().start();
121
+ await deps.syncTelegramCommands();
122
+ deps.startStatusHeartbeat();
123
+ deps.refreshStatus();
124
+ ui.notify(`Telegram workspace bot bound:\n${escapeHtml(workspacePath)}\n${botUsername ? `@${botUsername}` : "bot username unknown"}`, "info");
125
+ },
126
+ });
127
+
128
+ // ── /tg-unbind-cwd ────────────────────────────────────────────────────
129
+ registry.registerCommand("tg-unbind-cwd", {
130
+ description: "Remove current directory Telegram bot binding",
131
+ handler: async (_args, ctx) => {
132
+ const previous = deps.getResolvedConfig();
133
+ if (previous?.scope !== "workspace") {
134
+ ctx.ui.notify("Current directory is using the global Telegram bot; no workspace binding to remove.", "info");
135
+ return;
136
+ }
137
+ await deps.getPolling().stop();
138
+ deps.switchResolvedConfig(await unbindWorkspaceTelegramConfig(ctx.cwd || process.cwd()));
139
+ if (deps.isTelegramEnabled()) deps.getPolling().start();
140
+ await deps.syncTelegramCommands();
141
+ deps.refreshStatus();
142
+ ctx.ui.notify(`Removed Telegram workspace binding:\n${escapeHtml(previous.workspacePath ?? "")}`, "info");
143
+ },
144
+ });
145
+
146
+ // ── /tg-list ───────────────────────────────────────────────────────────
147
+ registry.registerCommand("tg-list", {
148
+ description: "List Telegram bot bindings",
149
+ handler: async (_args, ctx) => {
150
+ const store = await readTelegramConfigStore();
151
+ const lines = [
152
+ `global: ${store.global?.botUsername ? `@${store.global.botUsername}` : store.global?.botToken ? "configured" : "not configured"}`,
153
+ "",
154
+ "workspaces:",
155
+ ...((store.workspaces ?? []).length
156
+ ? (store.workspaces ?? []).map((workspace) => `- ${escapeHtml(workspace.path)}\n ${workspace.config.botUsername ? `@${workspace.config.botUsername}` : workspace.config.botToken ? "configured" : "not configured"}`)
157
+ : ["none"]),
158
+ ];
159
+ ctx.ui.notify(lines.join("\n"), "info");
160
+ },
161
+ });
162
+ }