grok-telegram-bot 2.4.0 → 2.6.0

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.
Files changed (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Auto-injected context for General manager turns: topic catalog, memory hits,
3
+ * recent General chat history, and active dispatch jobs.
4
+ */
5
+ import { join } from "node:path";
6
+ import type { ForumManager } from "../forum/manager.js";
7
+ import { readHistory } from "../sessions/history.js";
8
+ import type { SessionStore } from "../sessions/store.js";
9
+ import { searchGroupMemory } from "./group-memory.js";
10
+ import { listActiveManagerJobs, listRecentManagerJobs, type ManagerJob } from "./manager-jobs.js";
11
+
12
+ export const MANAGER_CONTEXT_MARKER = "MANAGER CONTEXT (auto — use before dispatching work):";
13
+
14
+ const CONTEXT_MAX = 6500;
15
+
16
+ export interface ManagerContextOpts {
17
+ userText: string;
18
+ sessionsDir: string;
19
+ store: SessionStore;
20
+ forum?: ForumManager;
21
+ /** Override jobs list (tests). */
22
+ jobs?: ManagerJob[];
23
+ }
24
+
25
+ /** Build a capped context block for manager prompts. */
26
+ export function buildManagerContextBlock(opts: ManagerContextOpts): string {
27
+ const lines: string[] = [MANAGER_CONTEXT_MARKER, ""];
28
+
29
+ const topics = opts.forum?.isReady ? opts.forum.store.all() : [];
30
+ lines.push("## Topics");
31
+ if (topics.length === 0) {
32
+ lines.push("(no forum topics mapped)");
33
+ } else {
34
+ const topicCap = 40;
35
+ for (const t of topics.slice(0, topicCap)) {
36
+ const path = t.projectPath ?? "(unbound)";
37
+ lines.push(`- **${t.name}** #${t.threadId} [${t.kind}] \`${path}\``);
38
+ }
39
+ if (topics.length > topicCap) {
40
+ lines.push(`… +${topics.length - topicCap} more (use list_topics)`);
41
+ }
42
+ }
43
+
44
+ const workspace =
45
+ topics.find((t) => t.kind === "general")?.projectPath ||
46
+ topics.find((t) => t.kind === "ai_chat")?.projectPath ||
47
+ undefined;
48
+ const preferPaths = [
49
+ workspace,
50
+ ...topics
51
+ .filter((t) => t.kind === "project" && t.projectPath)
52
+ .map((t) => t.projectPath!)
53
+ .slice(0, 12),
54
+ ].filter(Boolean) as string[];
55
+
56
+ // Always surface recent General chat so the manager "remembers" this room.
57
+ lines.push("", "## Recent General chat (always available)");
58
+ const generalSnips = recentGeneralHistory(opts, workspace, 10);
59
+ if (generalSnips.length === 0) {
60
+ lines.push("(no prior General history on disk yet)");
61
+ } else {
62
+ for (const s of generalSnips) lines.push(`- ${s}`);
63
+ }
64
+
65
+ lines.push(
66
+ "",
67
+ "## Memory hits (ranked: relevance + recency — newest work first, not git)",
68
+ );
69
+ const hits = searchGroupMemory({
70
+ query: opts.userText,
71
+ limit: 14,
72
+ sessionsDir: opts.sessionsDir,
73
+ store: opts.store,
74
+ topics: topics.length ? topics : undefined,
75
+ preferPaths,
76
+ preferGeneral: true,
77
+ maxSessions: 32,
78
+ });
79
+ if (hits.length === 0) {
80
+ lines.push(
81
+ "(no hits — call search_memory; do NOT run git until memory is exhausted)",
82
+ );
83
+ } else {
84
+ for (const h of hits) {
85
+ const where =
86
+ h.threadId !== undefined
87
+ ? ` #${h.threadId}`
88
+ : h.sessionId
89
+ ? ` session=${h.sessionId.slice(0, 8)}`
90
+ : "";
91
+ lines.push(`- [${h.kind}] ${h.title}${where}: ${h.snippet}`);
92
+ }
93
+ }
94
+
95
+ lines.push(
96
+ "",
97
+ "Rules: for \"last modifications / last work\" trust hits with [Xm/h/d ago] stamps — prefer the newest session for that project path.",
98
+ "Ignore older OmniRoute-style notes if a newer session for the same path exists.",
99
+ "Do not use git log/status as the first step unless the user asked for git.",
100
+ );
101
+
102
+ const jobs = opts.jobs ?? [...listActiveManagerJobs(8), ...listRecentManagerJobs(4)];
103
+ const seen = new Set<string>();
104
+ const uniq: ManagerJob[] = [];
105
+ for (const j of jobs) {
106
+ if (seen.has(j.id)) continue;
107
+ seen.add(j.id);
108
+ uniq.push(j);
109
+ if (uniq.length >= 10) break;
110
+ }
111
+ lines.push("", "## Manager jobs (this process)");
112
+ if (uniq.length === 0) {
113
+ lines.push("(none)");
114
+ } else {
115
+ for (const j of uniq) {
116
+ const ageMin = Math.max(0, Math.round((Date.now() - j.createdAt) / 60_000));
117
+ lines.push(
118
+ `- ${j.status} **${j.targetName}** #${j.targetThreadId} (${ageMin}m ago) job=${j.id}` +
119
+ (j.childSessionId ? ` session=${j.childSessionId.slice(0, 8)}` : ""),
120
+ );
121
+ if (j.userAskPreview) lines.push(` ask: ${clamp(j.userAskPreview, 160)}`);
122
+ }
123
+ }
124
+
125
+ lines.push(
126
+ "",
127
+ "Use this context to pick the right topic and write a strong send_prompt.",
128
+ "When a hit shows session=XXXXXXXX and the user wants follow-up there, pass that exact",
129
+ "prefix as session_id on send_prompt (do NOT rely on the topic's currently open session).",
130
+ "Prefer search_memory again if you need deeper history before dispatching.",
131
+ );
132
+
133
+ let block = lines.join("\n");
134
+ if (block.length > CONTEXT_MAX) {
135
+ block = block.slice(0, CONTEXT_MAX - 1) + "\u2026";
136
+ }
137
+ return block;
138
+ }
139
+
140
+ /** Last user/assistant lines from General-named or workspace sessions. */
141
+ function recentGeneralHistory(
142
+ opts: ManagerContextOpts,
143
+ workspace: string | undefined,
144
+ limit: number,
145
+ ): string[] {
146
+ const out: string[] = [];
147
+ let metas;
148
+ try {
149
+ metas = opts.store.list(40);
150
+ } catch {
151
+ return out;
152
+ }
153
+ const ws = workspace?.replace(/\\/g, "/").toLowerCase();
154
+ const ranked = metas
155
+ .map((m) => {
156
+ const title = (m.title || "").toLowerCase();
157
+ const cwd = (m.cwd || "").replace(/\\/g, "/").toLowerCase();
158
+ let rank = 0;
159
+ // Prefer sessions explicitly labeled General — never treat every project
160
+ // under the workspace root as "General chat" (path prefix trap).
161
+ if (title === "general") rank += 12;
162
+ else if (/\bgeneral\b/.test(title)) rank += 6;
163
+ // Exact workspace cwd only (General/AI Chat bind), not child project paths.
164
+ if (ws && cwd === ws) rank += 4;
165
+ return { m, rank };
166
+ })
167
+ .filter((x) => x.rank > 0)
168
+ .sort(
169
+ (a, b) =>
170
+ b.rank - a.rank ||
171
+ String(b.m.updatedAt || "").localeCompare(String(a.m.updatedAt || "")),
172
+ );
173
+
174
+ for (const { m } of ranked.slice(0, 4)) {
175
+ try {
176
+ const path = join(opts.sessionsDir, `${m.sessionId}.jsonl`);
177
+ const entries = readHistory(path, 8);
178
+ for (const e of entries) {
179
+ if (!e.text?.trim()) continue;
180
+ // Skip huge manager context dumps in history.
181
+ if (e.text.includes(MANAGER_CONTEXT_MARKER)) continue;
182
+ if (e.text.startsWith("MANAGER MODE")) continue;
183
+ const role = e.role === "user" ? "user" : e.role === "assistant" ? "bot" : e.role;
184
+ out.push(
185
+ `${role} · ${m.sessionId.slice(0, 8)}: ${clamp(e.text.replace(/\s+/g, " "), 180)}`,
186
+ );
187
+ if (out.length >= limit) return out;
188
+ }
189
+ } catch {
190
+ /* skip */
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /** Prepend context block to a user/manager prompt body (idempotent). */
197
+ export function injectManagerContext(text: string, contextBlock: string): string {
198
+ const body = text.trim();
199
+ if (!contextBlock.trim()) return body;
200
+ if (body.includes(MANAGER_CONTEXT_MARKER)) return body;
201
+ return `${contextBlock}\n\n---\n\n${body}`;
202
+ }
203
+
204
+ function clamp(s: string, max: number): string {
205
+ const t = s.replace(/\s+/g, " ").trim();
206
+ if (t.length <= max) return t;
207
+ return t.slice(0, max - 1) + "\u2026";
208
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * In-memory tracking of work dispatched from General (manager) into project topics.
3
+ * Used for status, context inject, and report-back when a child turn finishes.
4
+ */
5
+
6
+ export type ManagerJobStatus = "dispatched" | "running" | "done" | "failed" | "cancelled";
7
+
8
+ export interface ManagerJob {
9
+ id: string;
10
+ originChatId: number;
11
+ originThreadId: number;
12
+ targetThreadId: number;
13
+ targetName: string;
14
+ targetPath: string;
15
+ childSessionId?: string;
16
+ dispatchPrompt: string;
17
+ userAskPreview: string;
18
+ createdAt: number;
19
+ updatedAt: number;
20
+ status: ManagerJobStatus;
21
+ resultSummary?: string;
22
+ }
23
+
24
+ export interface ReportBackMeta {
25
+ jobId: string;
26
+ originChatId: number;
27
+ originThreadId: number;
28
+ userAskPreview: string;
29
+ targetName: string;
30
+ targetPath: string;
31
+ dispatchPrompt: string;
32
+ }
33
+
34
+ const MAX_JOBS = 100;
35
+
36
+ const jobsById = new Map<string, ManagerJob>();
37
+ const jobIdBySession = new Map<string, string>();
38
+
39
+ let seq = 0;
40
+
41
+ export function newManagerJobId(): string {
42
+ seq = (seq + 1) % 1_000_000;
43
+ return `mj_${Date.now().toString(36)}_${seq.toString(36)}`;
44
+ }
45
+
46
+ export function registerManagerJob(input: {
47
+ originChatId: number;
48
+ originThreadId: number;
49
+ targetThreadId: number;
50
+ targetName: string;
51
+ targetPath: string;
52
+ dispatchPrompt: string;
53
+ userAskPreview: string;
54
+ childSessionId?: string;
55
+ }): ManagerJob {
56
+ const now = Date.now();
57
+ const job: ManagerJob = {
58
+ id: newManagerJobId(),
59
+ originChatId: input.originChatId,
60
+ originThreadId: input.originThreadId,
61
+ targetThreadId: input.targetThreadId,
62
+ targetName: input.targetName,
63
+ targetPath: input.targetPath,
64
+ childSessionId: input.childSessionId,
65
+ dispatchPrompt: input.dispatchPrompt,
66
+ userAskPreview: input.userAskPreview,
67
+ createdAt: now,
68
+ updatedAt: now,
69
+ status: "dispatched",
70
+ };
71
+ jobsById.set(job.id, job);
72
+ if (job.childSessionId) jobIdBySession.set(job.childSessionId, job.id);
73
+ trimJobs();
74
+ return job;
75
+ }
76
+
77
+ export function bindJobSession(jobId: string, sessionId: string): void {
78
+ const job = jobsById.get(jobId);
79
+ if (!job) return;
80
+ job.childSessionId = sessionId;
81
+ job.updatedAt = Date.now();
82
+ if (job.status === "dispatched") job.status = "running";
83
+ jobIdBySession.set(sessionId, jobId);
84
+ }
85
+
86
+ export function updateManagerJob(
87
+ jobId: string,
88
+ patch: Partial<Pick<ManagerJob, "status" | "resultSummary" | "childSessionId">>,
89
+ ): ManagerJob | undefined {
90
+ const job = jobsById.get(jobId);
91
+ if (!job) return undefined;
92
+ if (patch.status !== undefined) job.status = patch.status;
93
+ if (patch.resultSummary !== undefined) job.resultSummary = patch.resultSummary;
94
+ if (patch.childSessionId !== undefined) {
95
+ job.childSessionId = patch.childSessionId;
96
+ jobIdBySession.set(patch.childSessionId, jobId);
97
+ }
98
+ job.updatedAt = Date.now();
99
+ return job;
100
+ }
101
+
102
+ export function getManagerJob(jobId: string): ManagerJob | undefined {
103
+ return jobsById.get(jobId);
104
+ }
105
+
106
+ export function getJobBySession(sessionId: string): ManagerJob | undefined {
107
+ const id = jobIdBySession.get(sessionId);
108
+ return id ? jobsById.get(id) : undefined;
109
+ }
110
+
111
+ /** Active (not terminal) jobs, newest first. */
112
+ export function listActiveManagerJobs(limit = 12): ManagerJob[] {
113
+ return [...jobsById.values()]
114
+ .filter((j) => j.status === "dispatched" || j.status === "running")
115
+ .sort((a, b) => b.updatedAt - a.updatedAt)
116
+ .slice(0, limit);
117
+ }
118
+
119
+ /** Recent jobs including finished, newest first. */
120
+ export function listRecentManagerJobs(limit = 12): ManagerJob[] {
121
+ return [...jobsById.values()]
122
+ .sort((a, b) => b.updatedAt - a.updatedAt)
123
+ .slice(0, Math.max(1, Math.min(50, limit)));
124
+ }
125
+
126
+ function trimJobs(): void {
127
+ if (jobsById.size <= MAX_JOBS) return;
128
+ const ordered = [...jobsById.values()].sort((a, b) => a.updatedAt - b.updatedAt);
129
+ const drop = ordered.length - MAX_JOBS;
130
+ for (let i = 0; i < drop; i++) {
131
+ const j = ordered[i]!;
132
+ jobsById.delete(j.id);
133
+ if (j.childSessionId) jobIdBySession.delete(j.childSessionId);
134
+ }
135
+ }
136
+
137
+ /** Test helper: wipe job state. */
138
+ export function clearManagerJobsForTests(): void {
139
+ jobsById.clear();
140
+ jobIdBySession.clear();
141
+ seq = 0;
142
+ }
@@ -95,11 +95,24 @@ export class Ephemeral {
95
95
  async reply(ctx: Context, text: string, extra: Record<string, unknown> = {}): Promise<number | undefined> {
96
96
  const chatId = ctx.chat?.id;
97
97
  if (chatId === undefined) return undefined;
98
+ // Stay in the forum topic when the trigger message was in a thread.
99
+ const msg = ctx.message ?? ctx.callbackQuery?.message;
100
+ const threadId =
101
+ msg && "message_thread_id" in msg
102
+ ? (msg as { message_thread_id?: number }).message_thread_id
103
+ : undefined;
104
+ // Omit General (1) — Bot API rejects message_thread_id=1.
105
+ const merged =
106
+ threadId !== undefined &&
107
+ threadId !== 1 &&
108
+ extra.message_thread_id === undefined
109
+ ? { ...extra, message_thread_id: threadId }
110
+ : extra;
98
111
  return this.serialize(chatId, async () => {
99
112
  try {
100
- const msg = await ctx.reply(text, extra);
101
- this.remember(chatId, msg.message_id);
102
- return msg.message_id;
113
+ const m = await ctx.reply(text, merged);
114
+ this.remember(chatId, m.message_id);
115
+ return m.message_id;
103
116
  } catch {
104
117
  return undefined;
105
118
  }
@@ -1,32 +1,71 @@
1
1
  /**
2
2
  * Menu surfaces:
3
- * - a tiny PERSISTENT bar (☰ Menu · 🧭 Running · ⏹ Stop) — minimal footprint;
4
- * - a full, organized INLINE menu opened on demand (and hideable).
5
- * Live state (project/model/reasoning/context) lives in the pinned panel,
6
- * so the bar stays clean. (Agent picker removed Grok has no useful headless
7
- * agent switch; plan mode is entered automatically when the agent judges the task complex.)
3
+ * - PERSISTENT bar (reply keyboard): ☰ Menu · 🆕 New session / 🧭 Running · ⏹ Stop
4
+ * primary place for New session in private chats (not the inline Menu message).
5
+ * - INLINE menu message (opened via ☰ Menu or /menu) settings & navigation.
6
+ * Forum topics: reply keyboards are unreliable, so New stays on the topic inline menu.
7
+ * Live state lives in the pinned status panel so the bar stays clean.
8
8
  */
9
9
  import { InlineKeyboard, Keyboard } from "grammy";
10
10
 
11
11
  export const MENU_BTN = "\u2630 Menu"; // ☰
12
+ /** Persistent bar + forum topic control — brand-new session (same as /new). */
13
+ export const NEW_BTN = "\u{1F195} New session";
12
14
  export const RUNNING_BTN = "\u{1F9ED} Running";
13
15
  export const STOP_BTN = "\u23F9 Stop";
14
- export const BAR_LABELS = [MENU_BTN, RUNNING_BTN, STOP_BTN];
16
+ export const BAR_LABELS = [MENU_BTN, NEW_BTN, RUNNING_BTN, STOP_BTN];
15
17
 
16
- /** The always-visible compact bar. */
18
+ /** The always-visible compact bar (private chats; best-effort in groups). */
17
19
  export function compactKeyboard(): Keyboard {
18
- return new Keyboard().text(MENU_BTN).text(RUNNING_BTN).text(STOP_BTN).resized().persistent();
20
+ return new Keyboard()
21
+ .text(MENU_BTN)
22
+ .text(NEW_BTN)
23
+ .row()
24
+ .text(RUNNING_BTN)
25
+ .text(STOP_BTN)
26
+ .resized()
27
+ .persistent();
19
28
  }
20
29
 
21
30
  /** The full, grouped inline menu (opened via ☰ Menu or /menu). */
22
- export function mainMenuInline(state: { model: string; reasoning: string }): InlineKeyboard {
31
+ export function mainMenuInline(state: {
32
+ model: string;
33
+ reasoning: string;
34
+ /** Forum topic scope — hide project switch; label topic sessions. */
35
+ forumTopic?: { name: string; account?: string };
36
+ }): InlineKeyboard {
23
37
  const t = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "\u2026" : s);
24
- return new InlineKeyboard()
25
- .text("\u{1F4C1} Project", "m:project")
26
- .text("\u{1F195} New", "m:new")
38
+ const kb = new InlineKeyboard();
39
+
40
+ if (state.forumTopic) {
41
+ // Groups/topics: control first (no reliable reply-keyboard bar). New session here.
42
+ kb.text("\u23F9 Stop", "m:stop")
43
+ .text("\u{1F9ED} Running", "m:running")
44
+ .row()
45
+ .text(NEW_BTN, "m:new")
46
+ .text("\u{1F5C2} Sessions", "m:sessions")
47
+ .row()
48
+ .text(`\u{1F4C1} ${t(state.forumTopic.name, 28)}`, "m:topicinfo")
49
+ .row()
50
+ .text("\u{1F4E5} Import session", "m:import")
51
+ .row()
52
+ .text(`\u{1F9E9} Model \u00B7 ${t(state.model, 24)}`, "m:model")
53
+ .row()
54
+ .text(`\u{1F9E0} Reasoning \u00B7 ${t(state.reasoning, 24)}`, "m:reasoning")
55
+ .row();
56
+ if (state.forumTopic.account) {
57
+ kb.text(`\u{1F465} Account \u00B7 ${t(state.forumTopic.account, 20)}`, "m:accounts").row();
58
+ }
59
+ kb.text("\u{1F4CA} Status", "m:status").text("\u2716 Close", "m:close");
60
+ return kb;
61
+ }
62
+
63
+ // Private chat: New session is on the persistent bar + /new — not on this message.
64
+ kb.text("\u{1F4C1} Project", "m:project")
65
+ .text("\u{1F5C2} Sessions", "m:sessions")
27
66
  .row()
67
+ .text("\u23F9 Stop", "m:stop")
28
68
  .text("\u{1F9ED} Running", "m:running")
29
- .text("\u{1F5C2} Sessions", "m:sessions")
30
69
  .row()
31
70
  .text("\u{1F4E5} Import session", "m:import")
32
71
  .row()
@@ -41,10 +80,10 @@ export function mainMenuInline(state: { model: string; reasoning: string }): Inl
41
80
  .text("\u{1F465} Accounts", "m:accounts")
42
81
  .row()
43
82
  .text("\u{1F9E9} MCP", "m:mcp")
44
- .text("\u23F9 Stop", "m:stop")
45
83
  .text("\u{1F6D1} Kill all", "m:killall")
46
84
  .row()
47
85
  .text("\u2328\uFE0F Show bar", "m:showbar")
48
86
  .text("\u{1F648} Hide bar", "m:hidebar")
49
87
  .text("\u2716 Close", "m:close");
88
+ return kb;
50
89
  }
@@ -4,10 +4,12 @@
4
4
  */
5
5
  import type { Context } from "grammy";
6
6
  import type { BotDeps } from "../deps.js";
7
+ import { resolveScope } from "../scope.js";
7
8
  import { compactKeyboard } from "./keyboard.js";
8
9
 
9
10
  export async function refreshMenu(ctx: Context, deps: BotDeps, text: string): Promise<void> {
10
11
  const chatId = ctx.chat!.id;
11
- await ctx.reply(text, { reply_markup: compactKeyboard() });
12
+ const scope = resolveScope(ctx, deps);
13
+ await ctx.reply(text, { reply_markup: compactKeyboard(), ...scope.threadExtra });
12
14
  await deps.statusPanel.refresh(chatId);
13
15
  }
@@ -65,13 +65,19 @@ export class StatusPanel {
65
65
  if (subagents) activity.push(`\u{1F465} ${subagents}`);
66
66
  lines.push(activity.join(SEP));
67
67
 
68
- // 3b) What is happening now / last summary (same source as Running cards).
69
- // Prefer plan one-liner over a redundant tool-step when a plan is active.
70
- const comment = rt.planSummary && rt.isBusy ? undefined : rt.cardComment;
68
+ // 3b) Last user prompt (+ thinking while busy) same source as Running cards.
69
+ // When a plan board is already shown above, still surface the user prompt;
70
+ // skip only a pure thinking second line if plan is active (less noise).
71
+ const comment = rt.cardComment;
71
72
  if (comment) {
72
- const icon = rt.isBusy ? "\u23F3" : "\u{1F4AC}";
73
- const shown = comment.length > 120 ? `${comment.slice(0, 119)}\u2026` : comment;
74
- lines.push(`${icon} ${shown}`);
73
+ const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
74
+ const hideThinking = !!(rt.planSummary && rt.isBusy);
75
+ parts.forEach((part, i) => {
76
+ if (hideThinking && i > 0) return;
77
+ const icon = i === 0 ? (rt.isBusy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
78
+ const shown = part.length > 250 ? `${part.slice(0, 249)}\u2026` : part;
79
+ lines.push(`${icon} ${shown}`);
80
+ });
75
81
  }
76
82
 
77
83
  // 4) Where: project | session | context usage.
@@ -161,6 +161,25 @@ export class PermissionService {
161
161
  return this.pending.get(reqId)?.sessionId;
162
162
  }
163
163
 
164
+ /**
165
+ * ACP: when a session is cancelled, all pending permission requests for that
166
+ * session must complete with `cancelled`. Returns how many were cancelled.
167
+ * Does not touch other sessions.
168
+ */
169
+ cancelForSession(sessionId: string): number {
170
+ let n = 0;
171
+ for (const [reqId, p] of [...this.pending.entries()]) {
172
+ if (p.sessionId !== sessionId) continue;
173
+ clearTimeout(p.timer);
174
+ this.pending.delete(reqId);
175
+ void this.finishPrompt(p, "\u{1F510} (cancelled — turn stopped)");
176
+ p.resolve({ outcome: { outcome: "cancelled" } });
177
+ n++;
178
+ }
179
+ if (n > 0) log.info(`cancelled ${n} pending permission(s) for session ${sessionId.slice(0, 8)}`);
180
+ return n;
181
+ }
182
+
164
183
  /** Unpin (if pinned) and optionally rewrite the prompt message. */
165
184
  private async finishPrompt(p: Pending, text?: string): Promise<void> {
166
185
  if (p.messageId !== undefined && text) {