@jsayubi/ccgram 1.1.1 → 1.2.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 (55) hide show
  1. package/.env.example +3 -0
  2. package/README.md +18 -7
  3. package/dist/elicitation-notify.d.ts +20 -0
  4. package/dist/elicitation-notify.d.ts.map +1 -0
  5. package/dist/elicitation-notify.js +241 -0
  6. package/dist/elicitation-notify.js.map +1 -0
  7. package/dist/enhanced-hook-notify.d.ts +8 -1
  8. package/dist/enhanced-hook-notify.d.ts.map +1 -1
  9. package/dist/enhanced-hook-notify.js +119 -5
  10. package/dist/enhanced-hook-notify.js.map +1 -1
  11. package/dist/permission-denied-notify.d.ts +11 -0
  12. package/dist/permission-denied-notify.d.ts.map +1 -0
  13. package/dist/permission-denied-notify.js +193 -0
  14. package/dist/permission-denied-notify.js.map +1 -0
  15. package/dist/permission-hook.js +43 -18
  16. package/dist/permission-hook.js.map +1 -1
  17. package/dist/pre-compact-notify.d.ts +13 -0
  18. package/dist/pre-compact-notify.d.ts.map +1 -0
  19. package/dist/pre-compact-notify.js +197 -0
  20. package/dist/pre-compact-notify.js.map +1 -0
  21. package/dist/question-notify.d.ts +6 -5
  22. package/dist/question-notify.d.ts.map +1 -1
  23. package/dist/question-notify.js +107 -23
  24. package/dist/question-notify.js.map +1 -1
  25. package/dist/setup.js +26 -10
  26. package/dist/setup.js.map +1 -1
  27. package/dist/src/types/callbacks.d.ts +11 -1
  28. package/dist/src/types/callbacks.d.ts.map +1 -1
  29. package/dist/src/types/session.d.ts +13 -1
  30. package/dist/src/types/session.d.ts.map +1 -1
  31. package/dist/src/utils/callback-parser.d.ts +7 -5
  32. package/dist/src/utils/callback-parser.d.ts.map +1 -1
  33. package/dist/src/utils/callback-parser.js +11 -5
  34. package/dist/src/utils/callback-parser.js.map +1 -1
  35. package/dist/src/utils/deep-link.d.ts +22 -0
  36. package/dist/src/utils/deep-link.d.ts.map +1 -0
  37. package/dist/src/utils/deep-link.js +43 -0
  38. package/dist/src/utils/deep-link.js.map +1 -0
  39. package/dist/src/utils/ghostty-session-manager.d.ts +81 -0
  40. package/dist/src/utils/ghostty-session-manager.d.ts.map +1 -0
  41. package/dist/src/utils/ghostty-session-manager.js +370 -0
  42. package/dist/src/utils/ghostty-session-manager.js.map +1 -0
  43. package/dist/src/utils/transcript-reader.d.ts +57 -0
  44. package/dist/src/utils/transcript-reader.d.ts.map +1 -0
  45. package/dist/src/utils/transcript-reader.js +229 -0
  46. package/dist/src/utils/transcript-reader.js.map +1 -0
  47. package/dist/workspace-router.d.ts +19 -4
  48. package/dist/workspace-router.d.ts.map +1 -1
  49. package/dist/workspace-router.js +57 -1
  50. package/dist/workspace-router.js.map +1 -1
  51. package/dist/workspace-telegram-bot.js +515 -114
  52. package/dist/workspace-telegram-bot.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/types/callbacks.ts +15 -1
  55. package/src/types/session.ts +14 -1
@@ -34,6 +34,9 @@ const workspace_router_1 = require("./workspace-router");
34
34
  const prompt_bridge_1 = require("./prompt-bridge");
35
35
  const callback_parser_1 = require("./src/utils/callback-parser");
36
36
  const pty_session_manager_1 = require("./src/utils/pty-session-manager");
37
+ const ghostty_session_manager_1 = require("./src/utils/ghostty-session-manager");
38
+ const deep_link_1 = require("./src/utils/deep-link");
39
+ const transcript_reader_1 = require("./src/utils/transcript-reader");
37
40
  const logger_1 = __importDefault(require("./src/core/logger"));
38
41
  const logger = new logger_1.default('bot');
39
42
  const INJECTION_MODE = process.env.INJECTION_MODE || 'tmux';
@@ -46,6 +49,23 @@ const TMUX_AVAILABLE = (() => {
46
49
  return false;
47
50
  }
48
51
  })();
52
+ /**
53
+ * Determine the active injection backend.
54
+ * Respects INJECTION_MODE env var, falls back in order: ghostty → pty → tmux.
55
+ */
56
+ function getEffectiveMode() {
57
+ if (INJECTION_MODE === 'ghostty' && ghostty_session_manager_1.ghosttySessionManager.isAvailable())
58
+ return 'ghostty';
59
+ if (INJECTION_MODE === 'pty' && pty_session_manager_1.ptySessionManager.isAvailable())
60
+ return 'pty';
61
+ if (TMUX_AVAILABLE && INJECTION_MODE !== 'pty' && INJECTION_MODE !== 'ghostty')
62
+ return 'tmux';
63
+ if (ghostty_session_manager_1.ghosttySessionManager.isAvailable())
64
+ return 'ghostty';
65
+ if (pty_session_manager_1.ptySessionManager.isAvailable())
66
+ return 'pty';
67
+ return 'tmux';
68
+ }
49
69
  const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
50
70
  const CHAT_ID = process.env.TELEGRAM_CHAT_ID;
51
71
  if (!BOT_TOKEN || BOT_TOKEN === 'YOUR_BOT_TOKEN_HERE') {
@@ -152,11 +172,14 @@ async function registerBotCommands() {
152
172
  const commands = [
153
173
  { command: 'new', description: 'Start Claude in a project directory' },
154
174
  { command: 'resume', description: 'Resume a past Claude conversation' },
175
+ { command: 'link', description: 'Generate deep link to open Claude' },
155
176
  { command: 'sessions', description: 'List all active Claude sessions' },
156
177
  { command: 'use', description: 'Set or show default workspace' },
157
178
  { command: 'status', description: 'Show current session output' },
158
179
  { command: 'stop', description: 'Interrupt the running prompt' },
159
180
  { command: 'compact', description: 'Compact context in the current session' },
181
+ { command: 'effort', description: 'Set thinking effort (low/medium/high)' },
182
+ { command: 'model', description: 'Switch Claude model (sonnet/opus/haiku)' },
160
183
  { command: 'help', description: 'Show available commands' },
161
184
  ];
162
185
  try {
@@ -188,6 +211,17 @@ function editMessageText(chatId, messageId, text, replyMarkup) {
188
211
  return telegramAPI('editMessageText', body);
189
212
  }
190
213
  // ── Command handlers ────────────────────────────────────────────
214
+ /**
215
+ * Helper to resolve workspace from arg or default.
216
+ * Returns the resolved result with workspace and session.
217
+ */
218
+ function resolveDefaultWorkspace() {
219
+ const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
220
+ if (!defaultWs) {
221
+ return { type: 'none' };
222
+ }
223
+ return (0, workspace_router_1.resolveWorkspace)(defaultWs);
224
+ }
191
225
  async function handleHelp() {
192
226
  const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
193
227
  const msg = [
@@ -200,6 +234,9 @@ async function handleHelp() {
200
234
  '`/compact [workspace]` — Compact context in workspace',
201
235
  '`/new [project]` — Start Claude in a project (shows recent if no arg)',
202
236
  '`/resume [project]` — Resume a past Claude conversation',
237
+ '`/link <prompt>` — Generate deep link to open Claude',
238
+ '`/effort [workspace] low|medium|high` — Set thinking effort',
239
+ '`/model [workspace] <model>` — Switch Claude model',
203
240
  '`/sessions` — List active sessions',
204
241
  '`/status [workspace]` — Show tmux output',
205
242
  '`/stop [workspace]` — Interrupt running prompt',
@@ -212,6 +249,127 @@ async function handleHelp() {
212
249
  ].join('\n');
213
250
  await sendMessage(msg);
214
251
  }
252
+ async function handleLink(prompt) {
253
+ if (!prompt) {
254
+ await sendMessage('Usage: `/link <prompt>`\n\nGenerates a clickable link that opens Claude Code with your prompt.');
255
+ return;
256
+ }
257
+ if (!(0, deep_link_1.canGenerateDeepLink)(prompt)) {
258
+ await sendMessage('\u26a0\ufe0f Prompt too long for deep link (max ~4500 characters).');
259
+ return;
260
+ }
261
+ const deepLink = (0, deep_link_1.generateDeepLink)(prompt);
262
+ if (!deepLink) {
263
+ await sendMessage('\u26a0\ufe0f Failed to generate deep link.');
264
+ return;
265
+ }
266
+ // Send the deep link as a clickable button
267
+ const keyboard = {
268
+ inline_keyboard: [[
269
+ { text: '\ud83d\udcbb Open in Claude Code', url: deepLink },
270
+ ]],
271
+ };
272
+ await telegramAPI('sendMessage', {
273
+ chat_id: CHAT_ID,
274
+ text: `*Deep Link Generated*\n\n_Tap the button to open Claude Code with:_\n\`${escapeMarkdown(prompt.slice(0, 100))}${prompt.length > 100 ? '...' : ''}\``,
275
+ parse_mode: 'Markdown',
276
+ reply_markup: keyboard,
277
+ });
278
+ }
279
+ /**
280
+ * /effort [workspace] low|medium|high — Set Claude's thinking effort level
281
+ */
282
+ async function handleEffort(args) {
283
+ const validLevels = ['low', 'medium', 'high'];
284
+ if (!args) {
285
+ await sendMessage('Usage: `/effort [workspace] low|medium|high`\n\nSet Claude\'s thinking effort level.');
286
+ return;
287
+ }
288
+ const parts = args.split(/\s+/);
289
+ let workspaceArg = null;
290
+ let level;
291
+ // Check if first arg is a valid level or a workspace
292
+ if (validLevels.includes(parts[0].toLowerCase())) {
293
+ level = parts[0].toLowerCase();
294
+ }
295
+ else if (parts.length >= 2 && validLevels.includes(parts[1].toLowerCase())) {
296
+ workspaceArg = parts[0];
297
+ level = parts[1].toLowerCase();
298
+ }
299
+ else {
300
+ await sendMessage(`Invalid effort level. Use: \`low\`, \`medium\`, or \`high\``);
301
+ return;
302
+ }
303
+ // Resolve workspace
304
+ const resolved = workspaceArg ? (0, workspace_router_1.resolveWorkspace)(workspaceArg) : resolveDefaultWorkspace();
305
+ if (resolved.type === 'none') {
306
+ await sendMessage(workspaceArg
307
+ ? `No session found for workspace \`${escapeMarkdown(workspaceArg)}\``
308
+ : 'No default workspace set. Use `/use <workspace>` first.');
309
+ return;
310
+ }
311
+ if (resolved.type === 'ambiguous') {
312
+ const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
313
+ await sendMessage(`Multiple matches: ${names}. Be more specific.`);
314
+ return;
315
+ }
316
+ const session = resolved.match.session;
317
+ const workspace = resolved.workspace;
318
+ const slashCommand = `/effort ${level}`;
319
+ const injected = await injectAndRespond(session, slashCommand, workspace);
320
+ if (injected) {
321
+ startTypingIndicator();
322
+ await sendMessage(`\u2699\ufe0f Effort set to *${level}* in *${escapeMarkdown(workspace)}*`);
323
+ }
324
+ }
325
+ /**
326
+ * /model [workspace] <model> — Switch Claude model
327
+ */
328
+ async function handleModel(args) {
329
+ if (!args) {
330
+ await sendMessage('Usage: `/model [workspace] <model>`\n\nSwitch Claude model (e.g., `sonnet`, `opus`, `haiku`).');
331
+ return;
332
+ }
333
+ const parts = args.split(/\s+/);
334
+ let workspaceArg = null;
335
+ let model;
336
+ // If 2+ parts, first might be workspace
337
+ if (parts.length >= 2) {
338
+ // Try to resolve first part as workspace
339
+ const maybeWs = (0, workspace_router_1.resolveWorkspace)(parts[0]);
340
+ if (maybeWs.type === 'exact' || maybeWs.type === 'prefix') {
341
+ workspaceArg = parts[0];
342
+ model = parts.slice(1).join(' ');
343
+ }
344
+ else {
345
+ model = parts.join(' ');
346
+ }
347
+ }
348
+ else {
349
+ model = parts[0];
350
+ }
351
+ // Resolve workspace
352
+ const resolved = workspaceArg ? (0, workspace_router_1.resolveWorkspace)(workspaceArg) : resolveDefaultWorkspace();
353
+ if (resolved.type === 'none') {
354
+ await sendMessage(workspaceArg
355
+ ? `No session found for workspace \`${escapeMarkdown(workspaceArg)}\``
356
+ : 'No default workspace set. Use `/use <workspace>` first.');
357
+ return;
358
+ }
359
+ if (resolved.type === 'ambiguous') {
360
+ const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
361
+ await sendMessage(`Multiple matches: ${names}. Be more specific.`);
362
+ return;
363
+ }
364
+ const session = resolved.match.session;
365
+ const workspace = resolved.workspace;
366
+ const slashCommand = `/model ${model}`;
367
+ const injected = await injectAndRespond(session, slashCommand, workspace);
368
+ if (injected) {
369
+ startTypingIndicator();
370
+ await sendMessage(`\ud83e\udde0 Model switched to *${escapeMarkdown(model)}* in *${escapeMarkdown(workspace)}*`);
371
+ }
372
+ }
215
373
  async function handleSessions() {
216
374
  (0, workspace_router_1.pruneExpired)();
217
375
  const sessions = (0, workspace_router_1.listActiveSessions)();
@@ -258,22 +416,124 @@ async function handleStatus(workspaceArg) {
258
416
  const match = resolved.match;
259
417
  const resolvedName = resolved.workspace;
260
418
  const tmuxName = match.session.tmuxSession;
261
- try {
262
- const output = await sessionCaptureOutput(tmuxName);
263
- // Trim and take last 20 lines to avoid message length limits
264
- const trimmed = output.trim().split('\n').slice(-20).join('\n');
265
- const htmlMsg = `<b>${escapeHtml(resolvedName)}</b> session output:\n<pre>${escapeHtml(trimmed)}</pre>`;
419
+ const session = match.session;
420
+ // Read transcript status (model, context, branch, last message, ...).
421
+ // Available for any terminal type Ghostty, tmux, PTY, bare.
422
+ const transcript = (0, transcript_reader_1.readTranscriptStatus)(session.cwd, session.sessionId ?? undefined);
423
+ // For tmux/PTY, capture the pane output as before.
424
+ // For Ghostty (where scrollback capture doesn't work via AppleScript),
425
+ // skip the pane capture and rely on the transcript's last assistant message.
426
+ const useGhostty = (isGhosttySession(session) || ghostty_session_manager_1.ghosttySessionManager.has(tmuxName));
427
+ let paneOutput = null;
428
+ if (!useGhostty) {
266
429
  try {
267
- await sendHtmlMessage(htmlMsg);
430
+ const raw = await sessionCaptureOutput(tmuxName, session);
431
+ paneOutput = raw.trim().split('\n').slice(-20).join('\n');
268
432
  }
269
- catch {
270
- // Fallback to plain text if HTML fails
271
- await telegramAPI('sendMessage', { chat_id: CHAT_ID, text: `${resolvedName} session output:\n${trimmed}` });
433
+ catch (err) {
434
+ paneOutput = `(capture failed: ${err.message})`;
272
435
  }
273
436
  }
274
- catch (err) {
275
- await sendMessage(`Could not read session \`${tmuxName}\`: ${err.message}`);
437
+ const htmlMsg = buildStatusMessage(resolvedName, session, transcript, paneOutput);
438
+ try {
439
+ await sendHtmlMessage(htmlMsg);
440
+ }
441
+ catch {
442
+ // Fallback: strip HTML tags and send as plain text.
443
+ const plain = htmlMsg.replace(/<[^>]+>/g, '');
444
+ await telegramAPI('sendMessage', { chat_id: CHAT_ID, text: plain });
445
+ }
446
+ }
447
+ /** Format the rich /status message as HTML for Telegram. */
448
+ function buildStatusMessage(workspace, session, transcript, paneOutput) {
449
+ const lines = [];
450
+ lines.push(`\u{1F4CA} <b>${escapeHtml(workspace)}</b>`);
451
+ // Model + version
452
+ if (transcript?.model) {
453
+ const versionStr = transcript.version ? ` <i>(cc ${escapeHtml(transcript.version)})</i>` : '';
454
+ lines.push(`\u{1F916} <b>Model:</b> <code>${escapeHtml(transcript.model)}</code>${versionStr}`);
455
+ }
456
+ // Working directory
457
+ const cwd = transcript?.cwd || session.cwd;
458
+ if (cwd)
459
+ lines.push(`\u{1F4C1} <b>Path:</b> <code>${escapeHtml(cwd)}</code>`);
460
+ // Git branch
461
+ if (transcript?.gitBranch) {
462
+ lines.push(`\u{1F33F} <b>Branch:</b> <code>${escapeHtml(transcript.gitBranch)}</code>`);
463
+ }
464
+ // Session
465
+ if (transcript?.sessionId || session.sessionId) {
466
+ const sid = (transcript?.sessionId || session.sessionId);
467
+ const slugStr = transcript?.slug ? ` <i>(${escapeHtml(transcript.slug)})</i>` : '';
468
+ lines.push(`\u{1F194} <b>Session:</b> <code>${escapeHtml(sid.slice(0, 8))}</code>${slugStr}`);
469
+ }
470
+ // Context window usage
471
+ if (transcript?.contextTokens !== undefined) {
472
+ const tokensStr = transcript.contextTokens.toLocaleString();
473
+ if (transcript.contextLimit && transcript.contextPct !== undefined) {
474
+ const limitStr = transcript.contextLimit.toLocaleString();
475
+ lines.push(`\u{1F4C8} <b>Context:</b> ${tokensStr} / ${limitStr} (${transcript.contextPct}%)`);
476
+ }
477
+ else {
478
+ lines.push(`\u{1F4C8} <b>Context:</b> ${tokensStr} tokens`);
479
+ }
480
+ }
481
+ // Last activity
482
+ if (transcript?.lastAssistantTimestamp) {
483
+ const ago = formatRelativeTime(new Date(transcript.lastAssistantTimestamp));
484
+ if (ago)
485
+ lines.push(`\u23F1 <b>Last activity:</b> ${ago}`);
486
+ }
487
+ // Rate limit
488
+ const rateLimit = (0, workspace_router_1.getSessionRateLimit)(workspace);
489
+ if (rateLimit && rateLimit.remaining !== undefined) {
490
+ const pct = rateLimit.limit ? Math.round((rateLimit.remaining / rateLimit.limit) * 100) : null;
491
+ const pctStr = pct !== null ? ` (${pct}%)` : '';
492
+ let resetStr = '';
493
+ if (rateLimit.resetsAt) {
494
+ const resetMs = rateLimit.resetsAt * 1000 - Date.now();
495
+ if (resetMs > 0) {
496
+ const mins = Math.ceil(resetMs / 60000);
497
+ resetStr = mins > 60 ? ` \u2022 resets in ${Math.round(mins / 60)}h` : ` \u2022 resets in ${mins}m`;
498
+ }
499
+ }
500
+ lines.push(`\u{1F4E1} <b>Rate limit:</b> ${rateLimit.remaining}/${rateLimit.limit || '?'}${pctStr}${resetStr}`);
501
+ }
502
+ // Last assistant message (Ghostty only — for tmux/PTY we show pane output instead)
503
+ if (paneOutput === null && transcript?.lastAssistantMessage) {
504
+ lines.push('');
505
+ lines.push('\u{1F4AC} <b>Last message:</b>');
506
+ lines.push(`<pre>${escapeHtml(transcript.lastAssistantMessage)}</pre>`);
276
507
  }
508
+ // Pane output for tmux/PTY
509
+ if (paneOutput !== null) {
510
+ lines.push('');
511
+ lines.push('\u{1F4DD} <b>Recent output:</b>');
512
+ lines.push(`<pre>${escapeHtml(paneOutput)}</pre>`);
513
+ }
514
+ // Fallback when we have nothing
515
+ if (!transcript && paneOutput === null) {
516
+ lines.push('');
517
+ lines.push('<i>No transcript or pane output available.</i>');
518
+ }
519
+ return lines.join('\n');
520
+ }
521
+ /** Format a Date as "Xs ago" / "Xm ago" / "Xh ago" relative to now. */
522
+ function formatRelativeTime(d) {
523
+ const ms = Date.now() - d.getTime();
524
+ if (ms < 0)
525
+ return null;
526
+ const sec = Math.round(ms / 1000);
527
+ if (sec < 60)
528
+ return `${sec}s ago`;
529
+ const min = Math.round(sec / 60);
530
+ if (min < 60)
531
+ return `${min} min ago`;
532
+ const hr = Math.round(min / 60);
533
+ if (hr < 24)
534
+ return `${hr}h ago`;
535
+ const day = Math.round(hr / 24);
536
+ return `${day}d ago`;
277
537
  }
278
538
  async function handleStop(workspaceArg) {
279
539
  let workspace;
@@ -302,12 +562,12 @@ async function handleStop(workspaceArg) {
302
562
  }
303
563
  const resolvedName = resolved.workspace;
304
564
  const tmuxName = resolved.match.session.tmuxSession;
305
- if (!await sessionExists(tmuxName)) {
565
+ if (!await sessionExists(tmuxName, resolved.match.session)) {
306
566
  await sendMessage(`Session \`${tmuxName}\` not found.`);
307
567
  return;
308
568
  }
309
569
  try {
310
- await sessionInterrupt(tmuxName);
570
+ await sessionInterrupt(tmuxName, resolved.match.session);
311
571
  await sendMessage(`\u26d4 Sent interrupt to *${escapeMarkdown(resolvedName)}*`);
312
572
  }
313
573
  catch (err) {
@@ -399,6 +659,7 @@ async function handleCompact(workspaceArg) {
399
659
  return;
400
660
  }
401
661
  const tmuxName = resolved.match.session.tmuxSession;
662
+ const compactSession = resolved.match.session;
402
663
  // Inject /compact into tmux
403
664
  const injected = await injectAndRespond(resolved.match.session, '/compact', resolved.workspace);
404
665
  if (!injected)
@@ -411,7 +672,7 @@ async function handleCompact(workspaceArg) {
411
672
  for (let i = 0; i < 5; i++) {
412
673
  await sleep(2000);
413
674
  try {
414
- const output = await sessionCaptureOutput(tmuxName);
675
+ const output = await sessionCaptureOutput(tmuxName, compactSession);
415
676
  if (output.includes('Compacting')) {
416
677
  started = true;
417
678
  break;
@@ -424,7 +685,7 @@ async function handleCompact(workspaceArg) {
424
685
  if (!started) {
425
686
  // Command may have finished very quickly or failed to start
426
687
  try {
427
- const output = await sessionCaptureOutput(tmuxName);
688
+ const output = await sessionCaptureOutput(tmuxName, compactSession);
428
689
  if (output.includes('Compacted')) {
429
690
  const lines = output.trim().split('\n').slice(-10).join('\n');
430
691
  await sendMessage(`\u2705 *${escapeMarkdown(resolved.workspace)}* compact done:\n\`\`\`\n${lines}\n\`\`\``);
@@ -439,7 +700,7 @@ async function handleCompact(workspaceArg) {
439
700
  for (let i = 0; i < 30; i++) {
440
701
  await sleep(2000);
441
702
  try {
442
- const output = await sessionCaptureOutput(tmuxName);
703
+ const output = await sessionCaptureOutput(tmuxName, compactSession);
443
704
  if (!output.includes('Compacting')) {
444
705
  const lines = output.trim().split('\n').slice(-10).join('\n');
445
706
  await sendMessage(`\u2705 *${escapeMarkdown(resolved.workspace)}* compact done:\n\`\`\`\n${lines}\n\`\`\``);
@@ -452,7 +713,7 @@ async function handleCompact(workspaceArg) {
452
713
  }
453
714
  // Timeout — show current session state
454
715
  try {
455
- const output = await sessionCaptureOutput(tmuxName);
716
+ const output = await sessionCaptureOutput(tmuxName, compactSession);
456
717
  const trimmed = output.trim().split('\n').slice(-5).join('\n');
457
718
  await sendMessage(`\u23f3 *${escapeMarkdown(resolved.workspace)}* compact may still be running:\n\`\`\`\n${trimmed}\n\`\`\``);
458
719
  }
@@ -556,8 +817,11 @@ async function startProject(name) {
556
817
  }
557
818
  // 3. Sanitize tmux session name (dots, colons, spaces are invalid in tmux)
558
819
  const tmuxName = name.replace(/[.:\s]/g, '-');
559
- // 4. Check existing session (PTY or tmux)
560
- const alreadyRunning = await sessionExists(tmuxName);
820
+ // 4. Check existing session (PTY, Ghostty, or tmux)
821
+ const existingEntry = Object.values((0, workspace_router_1.readSessionMap)()).find(s => s.tmuxSession === tmuxName && !(0, workspace_router_1.isExpired)(s));
822
+ const alreadyRunning = existingEntry
823
+ ? await sessionExists(tmuxName, existingEntry)
824
+ : await sessionExists(tmuxName);
561
825
  if (alreadyRunning) {
562
826
  (0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'waiting', sessionId: null });
563
827
  (0, workspace_router_1.recordProjectUsage)(name, projectDir);
@@ -565,9 +829,9 @@ async function startProject(name) {
565
829
  await sendMessage(`Session \`${tmuxName}\` already running.\nSet as default — send messages directly.`);
566
830
  return;
567
831
  }
568
- // 5. Create session — PTY or tmux
569
- const usePty = !TMUX_AVAILABLE || INJECTION_MODE === 'pty';
570
- if (!usePty) {
832
+ // 5. Create session — Ghostty, PTY, or tmux
833
+ const mode = getEffectiveMode();
834
+ if (mode === 'tmux') {
571
835
  // tmux path (existing behaviour)
572
836
  try {
573
837
  await tmuxExec(`tmux new-session -d -s "${tmuxName}" -c "${projectDir}"`);
@@ -589,7 +853,27 @@ async function startProject(name) {
589
853
  (0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'new-session');
590
854
  }
591
855
  }
592
- else if (pty_session_manager_1.ptySessionManager.isAvailable()) {
856
+ else if (mode === 'ghostty') {
857
+ // Ghostty path — opens a new tab in the front Ghostty window
858
+ const ok = await ghostty_session_manager_1.ghosttySessionManager.openNewTab(projectDir, 'claude');
859
+ if (!ok) {
860
+ await sendMessage('Failed to open Ghostty tab.');
861
+ return;
862
+ }
863
+ ghostty_session_manager_1.ghosttySessionManager.register(tmuxName, projectDir);
864
+ (0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId: null, sessionType: 'ghostty' });
865
+ (0, workspace_router_1.recordProjectUsage)(name, projectDir);
866
+ (0, workspace_router_1.setDefaultWorkspace)(name);
867
+ const msg = await sendMessage(`Started Claude in *${escapeMarkdown(name)}*\n\n` +
868
+ `*Path:* \`${projectDir}\`\n` +
869
+ `*Session:* \`${tmuxName}\`\n\n` +
870
+ `Default workspace set — send messages directly.\n\n` +
871
+ `_Ghostty tab — visible in your Ghostty window._`);
872
+ if (msg && msg.message_id) {
873
+ (0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'new-session');
874
+ }
875
+ }
876
+ else if (mode === 'pty') {
593
877
  // PTY path — spawns 'claude' directly (no separate send-keys step)
594
878
  const ok = pty_session_manager_1.ptySessionManager.spawn(tmuxName, projectDir);
595
879
  if (!ok) {
@@ -609,8 +893,8 @@ async function startProject(name) {
609
893
  }
610
894
  }
611
895
  else {
612
- await sendMessage('\u26a0\ufe0f tmux not found and node-pty not installed.\n' +
613
- 'Install tmux or run: `npm install node-pty` in ~/.ccgram/');
896
+ await sendMessage('\u26a0\ufe0f No injection backend available.\n' +
897
+ 'Install tmux, run Ghostty, or run: `npm install node-pty` in ~/.ccgram/');
614
898
  }
615
899
  }
616
900
  // ── Resume feature ───────────────────────────────────────────────
@@ -691,12 +975,12 @@ async function resumeSession(projectName, sessionIdx, force = false) {
691
975
  }
692
976
  const sessionId = sessions[sessionIdx].id;
693
977
  const tmuxName = projectName.replace(/[.:\s]/g, '-');
694
- const running = await sessionExists(tmuxName);
695
- // Look up the bot's tracked session for this workspace (used by multiple checks below)
696
- const map = running ? (0, workspace_router_1.readSessionMap)() : {};
697
- const currentEntry = running
698
- ? Object.values(map).find(s => s.tmuxSession === tmuxName && !(0, workspace_router_1.isExpired)(s))
699
- : undefined;
978
+ // Look up the bot's tracked session BEFORE checking sessionExists
979
+ const map = (0, workspace_router_1.readSessionMap)();
980
+ const currentEntry = Object.values(map).find(s => s.tmuxSession === tmuxName && !(0, workspace_router_1.isExpired)(s));
981
+ const running = currentEntry
982
+ ? await sessionExists(tmuxName, currentEntry)
983
+ : (isPtySession(tmuxName) || (TMUX_AVAILABLE && await sessionExists(tmuxName)));
700
984
  const botOwnsThisSession = currentEntry?.sessionId === sessionId;
701
985
  // If the bot already has this exact session running, just re-route to it
702
986
  if (running && botOwnsThisSession) {
@@ -753,6 +1037,26 @@ async function resumeSession(projectName, sessionIdx, force = false) {
753
1037
  pty_session_manager_1.ptySessionManager.kill(tmuxName);
754
1038
  await sleep(300);
755
1039
  }
1040
+ else if (currentEntry && isGhosttySession(currentEntry)) {
1041
+ // Ghostty: old tab stays open idle — warn before opening a new tab
1042
+ if (!force) {
1043
+ await telegramAPI('sendMessage', {
1044
+ chat_id: CHAT_ID,
1045
+ text: `\u26a0\ufe0f *${escapeMarkdown(projectName)}* has an active Ghostty session\n\n` +
1046
+ `Resuming will open a new tab. The existing tab will stay open but idle.\n\n` +
1047
+ `_You can close the old tab manually._`,
1048
+ parse_mode: 'Markdown',
1049
+ reply_markup: {
1050
+ inline_keyboard: [[
1051
+ { text: '\u25b6\ufe0f Resume anyway', callback_data: `rc:${projectName}:${sessionIdx}` },
1052
+ ]],
1053
+ },
1054
+ });
1055
+ return;
1056
+ }
1057
+ // Confirmed — unregister handle so startProjectResume opens a fresh tab
1058
+ ghostty_session_manager_1.ghosttySessionManager.unregister(tmuxName);
1059
+ }
756
1060
  // tmux: no warning needed — startProjectResume switches inline
757
1061
  }
758
1062
  await startProjectResume(projectName, project.path, sessionId);
@@ -760,9 +1064,15 @@ async function resumeSession(projectName, sessionIdx, force = false) {
760
1064
  async function startProjectResume(name, projectDir, sessionId) {
761
1065
  const tmuxName = name.replace(/[.:\s]/g, '-');
762
1066
  const shortId = sessionId.slice(0, 8);
763
- // If a tmux session is already running, switch Claude inline (exit + resume)
764
- // instead of killing the tmux session. This keeps the user's terminal attached.
765
- if (!isPtySession(tmuxName) && await sessionExists(tmuxName)) {
1067
+ // Look up the current session entry BEFORE checking sessionExists
1068
+ const map = (0, workspace_router_1.readSessionMap)();
1069
+ const currentEntry = Object.values(map).find(s => s.tmuxSession === tmuxName && !(0, workspace_router_1.isExpired)(s));
1070
+ const running = currentEntry
1071
+ ? await sessionExists(tmuxName, currentEntry)
1072
+ : (isPtySession(tmuxName) || (TMUX_AVAILABLE && await sessionExists(tmuxName)));
1073
+ // If a tmux session is already running (and not PTY/Ghostty), switch Claude inline
1074
+ // (exit + resume) instead of killing the tmux session. This keeps the terminal attached.
1075
+ if (!isPtySession(tmuxName) && !(currentEntry && isGhosttySession(currentEntry)) && running) {
766
1076
  try {
767
1077
  // Double Ctrl+C: first interrupts any running Claude task,
768
1078
  // second clears the input line if Claude returned to its prompt
@@ -793,9 +1103,9 @@ async function startProjectResume(name, projectDir, sessionId) {
793
1103
  }
794
1104
  return;
795
1105
  }
796
- // No session running — create a new one
797
- const usePty = !TMUX_AVAILABLE || INJECTION_MODE === 'pty';
798
- if (!usePty) {
1106
+ // No session running (or was PTY/Ghostty that's been killed) — create a new one
1107
+ const mode = getEffectiveMode();
1108
+ if (mode === 'tmux') {
799
1109
  try {
800
1110
  await tmuxExec(`tmux new-session -d -s "${tmuxName}" -c "${projectDir}"`);
801
1111
  await sleep(300);
@@ -817,7 +1127,27 @@ async function startProjectResume(name, projectDir, sessionId) {
817
1127
  (0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'resume-session');
818
1128
  }
819
1129
  }
820
- else if (pty_session_manager_1.ptySessionManager.isAvailable()) {
1130
+ else if (mode === 'ghostty') {
1131
+ const ok = await ghostty_session_manager_1.ghosttySessionManager.openNewTab(projectDir, `claude --resume ${sessionId}`);
1132
+ if (!ok) {
1133
+ await sendMessage('Failed to open Ghostty tab.');
1134
+ return;
1135
+ }
1136
+ ghostty_session_manager_1.ghosttySessionManager.register(tmuxName, projectDir);
1137
+ (0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId, sessionType: 'ghostty' });
1138
+ (0, workspace_router_1.recordProjectUsage)(name, projectDir);
1139
+ (0, workspace_router_1.setDefaultWorkspace)(name);
1140
+ const msg = await sendMessage(`Resumed Claude in *${escapeMarkdown(name)}*\n\n` +
1141
+ `*Path:* \`${projectDir}\`\n` +
1142
+ `*Session:* \`${tmuxName}\`\n` +
1143
+ `*Resumed:* \`${shortId}...\`\n\n` +
1144
+ `Default workspace set — send messages directly.\n\n` +
1145
+ `_Ghostty tab — visible in your Ghostty window._`);
1146
+ if (msg && msg.message_id) {
1147
+ (0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'resume-session');
1148
+ }
1149
+ }
1150
+ else if (mode === 'pty') {
821
1151
  const ok = pty_session_manager_1.ptySessionManager.spawn(tmuxName, projectDir, ['--resume', sessionId]);
822
1152
  if (!ok) {
823
1153
  await sendMessage('Failed to spawn PTY session.');
@@ -837,13 +1167,13 @@ async function startProjectResume(name, projectDir, sessionId) {
837
1167
  }
838
1168
  }
839
1169
  else {
840
- await sendMessage('\u26a0\ufe0f tmux not found and node-pty not installed.\n' +
841
- 'Install tmux or run: `npm install node-pty` in ~/.ccgram/');
1170
+ await sendMessage('\u26a0\ufe0f No injection backend available.\n' +
1171
+ 'Install tmux, run Ghostty, or run: `npm install node-pty` in ~/.ccgram/');
842
1172
  }
843
1173
  }
844
1174
  async function injectAndRespond(session, command, workspace) {
845
1175
  const tmuxName = session.tmuxSession;
846
- if (!await sessionExists(tmuxName)) {
1176
+ if (!await sessionExists(tmuxName, session)) {
847
1177
  await sendMessage(`\u26a0\ufe0f Session not found. Start Claude via /new for full remote control, or use tmux.`);
848
1178
  return false;
849
1179
  }
@@ -856,6 +1186,13 @@ async function injectAndRespond(session, command, workspace) {
856
1186
  await sleep(150);
857
1187
  pty_session_manager_1.ptySessionManager.write(tmuxName, '\r'); // Enter
858
1188
  }
1189
+ else if (isGhosttySession(session)) {
1190
+ // Ghostty: inject via AppleScript input text
1191
+ ghostty_session_manager_1.ghosttySessionManager.register(tmuxName, session.cwd);
1192
+ await ghostty_session_manager_1.ghosttySessionManager.sendKey(tmuxName, 'C-u'); // Ctrl+U: clear line
1193
+ await sleep(150);
1194
+ await ghostty_session_manager_1.ghosttySessionManager.writeLine(tmuxName, command); // text + send key "return" atomically
1195
+ }
859
1196
  else {
860
1197
  // tmux: existing shell-escaped path
861
1198
  const escapedCommand = command.replace(/'/g, "'\"'\"'");
@@ -883,15 +1220,23 @@ function tmuxExec(cmd) {
883
1220
  });
884
1221
  });
885
1222
  }
886
- // ── PTY / tmux dispatch helpers ──────────────────────────────────
1223
+ // ── PTY / tmux / Ghostty dispatch helpers ────────────────────────
887
1224
  /** Is this session managed as a live PTY handle by this bot process? */
888
1225
  function isPtySession(sessionName) {
889
1226
  return pty_session_manager_1.ptySessionManager.has(sessionName);
890
1227
  }
891
- /** Check session exists (PTY handle OR tmux session). */
892
- async function sessionExists(name) {
1228
+ /** Is this session a Ghostty session (by stored sessionType)? */
1229
+ function isGhosttySession(session) {
1230
+ return session.sessionType === 'ghostty';
1231
+ }
1232
+ /** Check session exists (PTY handle, Ghostty, OR tmux session). */
1233
+ async function sessionExists(name, session) {
893
1234
  if (pty_session_manager_1.ptySessionManager.has(name))
894
1235
  return true;
1236
+ if (session && isGhosttySession(session)) {
1237
+ ghostty_session_manager_1.ghosttySessionManager.register(name, session.cwd);
1238
+ return ghostty_session_manager_1.ghosttySessionManager.isAvailable();
1239
+ }
895
1240
  if (TMUX_AVAILABLE) {
896
1241
  try {
897
1242
  await tmuxExec(`tmux has-session -t ${name} 2>/dev/null`);
@@ -906,32 +1251,51 @@ async function sessionExists(name) {
906
1251
  /**
907
1252
  * Send a named key (Down, Up, Enter, C-m, C-c, C-u, Space) to a session.
908
1253
  * For PTY: translates to escape sequence via ptySessionManager.sendKey.
1254
+ * For Ghostty: translates via ghosttySessionManager.sendKey (ANSI or modifiers).
909
1255
  * For tmux: passes key name directly to tmux send-keys.
910
1256
  */
911
- async function sessionSendKey(name, key) {
1257
+ async function sessionSendKey(name, key, session) {
912
1258
  if (isPtySession(name)) {
913
1259
  pty_session_manager_1.ptySessionManager.sendKey(name, key);
914
1260
  }
1261
+ else if ((session && isGhosttySession(session)) || ghostty_session_manager_1.ghosttySessionManager.has(name)) {
1262
+ if (session)
1263
+ ghostty_session_manager_1.ghosttySessionManager.register(name, session.cwd);
1264
+ await ghostty_session_manager_1.ghosttySessionManager.sendKey(name, key);
1265
+ }
915
1266
  else {
916
1267
  await tmuxExec(`tmux send-keys -t ${name} ${key}`);
917
1268
  }
918
1269
  await sleep(100);
919
1270
  }
920
- /** Capture session output (last 20 lines). */
921
- async function sessionCaptureOutput(name) {
1271
+ /** Capture session output. */
1272
+ async function sessionCaptureOutput(name, session) {
922
1273
  if (isPtySession(name))
923
1274
  return pty_session_manager_1.ptySessionManager.capture(name, 20) ?? '';
1275
+ if ((session && isGhosttySession(session)) || ghostty_session_manager_1.ghosttySessionManager.has(name)) {
1276
+ if (session)
1277
+ ghostty_session_manager_1.ghosttySessionManager.register(name, session.cwd);
1278
+ return await ghostty_session_manager_1.ghosttySessionManager.capture(name) ?? '(Ghostty scrollback capture unavailable)';
1279
+ }
924
1280
  return capturePane(name);
925
1281
  }
926
1282
  /** Send Ctrl+C interrupt to a session. */
927
- async function sessionInterrupt(name) {
1283
+ async function sessionInterrupt(name, session) {
928
1284
  if (isPtySession(name))
929
1285
  pty_session_manager_1.ptySessionManager.interrupt(name);
930
- else
1286
+ else if ((session && isGhosttySession(session)) || ghostty_session_manager_1.ghosttySessionManager.has(name)) {
1287
+ if (session)
1288
+ ghostty_session_manager_1.ghosttySessionManager.register(name, session.cwd);
1289
+ await ghostty_session_manager_1.ghosttySessionManager.interrupt(name);
1290
+ }
1291
+ else {
931
1292
  await tmuxExec(`tmux send-keys -t ${name} C-c`);
1293
+ }
932
1294
  }
933
1295
  /** Icon for /sessions listing based on session type and live status. */
934
1296
  function sessionIcon(s) {
1297
+ if (s.session.sessionType === 'ghostty')
1298
+ return '\u{1F47B}'; // 👻
935
1299
  if (s.session.sessionType === 'pty') {
936
1300
  return pty_session_manager_1.ptySessionManager.has(s.session.tmuxSession) ? '\u{1F916}' : '\u{1F4A4}'; // 🤖 or 💤
937
1301
  }
@@ -1003,7 +1367,10 @@ async function processCallbackQuery(query) {
1003
1367
  return;
1004
1368
  }
1005
1369
  const { action } = parsed;
1006
- const label = action === 'allow' ? '\u2705 Allowed' : action === 'always' ? '\ud83d\udd13 Always Allowed' : '\u274c Denied';
1370
+ const label = action === 'allow' ? '\u2705 Allowed'
1371
+ : action === 'always' ? '\ud83d\udd13 Always Allowed'
1372
+ : action === 'defer' ? '\u23F8 Deferred'
1373
+ : '\u274c Denied';
1007
1374
  // Write response file — the permission-hook.js is polling for this
1008
1375
  try {
1009
1376
  (0, prompt_bridge_1.writeResponse)(promptId, { action });
@@ -1024,10 +1391,11 @@ async function processCallbackQuery(query) {
1024
1391
  }
1025
1392
  }
1026
1393
  else if (type === 'opt') {
1027
- // Question option: inject keystroke via tmux
1394
+ // Question option: write response file so hook returns updatedInput
1395
+ // (No keystroke injection needed — hook polls for this file)
1028
1396
  const pending = (0, prompt_bridge_1.readPending)(promptId);
1029
- if (!pending || !pending.tmuxSession) {
1030
- await answerCallbackQuery(query.id, 'Session not found');
1397
+ if (!pending) {
1398
+ await answerCallbackQuery(query.id, 'Session expired');
1031
1399
  return;
1032
1400
  }
1033
1401
  const optIdx = parsed.optionIndex - 1;
@@ -1060,43 +1428,29 @@ async function processCallbackQuery(query) {
1060
1428
  }
1061
1429
  return;
1062
1430
  }
1063
- // Single-select: inject arrow keys + Enter into session
1064
- // Claude Code's AskUserQuestion UI: first option pre-highlighted, Down (N-1) times + Enter
1065
- const downPresses = optIdx;
1066
- const tmuxSessOpt = pending.tmuxSession;
1067
- try {
1068
- for (let i = 0; i < downPresses; i++) {
1069
- await sessionSendKey(tmuxSessOpt, 'Down');
1070
- }
1071
- await sessionSendKey(tmuxSessOpt, 'Enter');
1072
- // For multi-question flows: after the last question, send an extra
1073
- // Enter to confirm the preview/submit step
1074
- if (pending.isLast) {
1075
- await sleep(500);
1076
- await sessionSendKey(tmuxSessOpt, 'Enter');
1077
- }
1078
- await answerCallbackQuery(query.id, `Selected: ${optionLabel}`);
1079
- startTypingIndicator(); // ensure Stop hook routes response back to Telegram
1080
- }
1081
- catch (err) {
1082
- logger.error(`Failed to inject keystroke: ${err.message}`);
1083
- await answerCallbackQuery(query.id, 'Failed to send selection');
1084
- return;
1085
- }
1431
+ // Single-select: write response file so hook can return updatedInput
1432
+ // (No keystroke injection needed hook polls for this file)
1433
+ (0, prompt_bridge_1.writeResponse)(promptId, {
1434
+ action: 'answer',
1435
+ selectedOption: parsed.optionIndex,
1436
+ selectedLabel: optionLabel,
1437
+ });
1438
+ logger.info(`Wrote question response for promptId=${promptId}: selectedLabel=${optionLabel}`);
1439
+ await answerCallbackQuery(query.id, `Selected: ${optionLabel}`);
1086
1440
  // Edit message to show selection and remove buttons
1087
1441
  try {
1088
- await editMessageText(chatId, messageId, `${originalText}\n\n Selected: *${escapeMarkdown(optionLabel)}*`);
1442
+ await editMessageText(chatId, messageId, `${originalText}\n\n\u2714 Selected: *${escapeMarkdown(optionLabel)}*`);
1089
1443
  }
1090
1444
  catch (err) {
1091
1445
  logger.error(`Failed to edit message: ${err.message}`);
1092
1446
  }
1093
- (0, prompt_bridge_1.cleanPrompt)(promptId);
1447
+ // Note: hook will clean up the prompt files after reading the response
1094
1448
  }
1095
1449
  else if (type === 'opt-submit') {
1096
- // Multi-select submit: inject Space toggles for selected options, then Enter
1450
+ // Multi-select submit: write response file so hook returns updatedInput
1097
1451
  const pending = (0, prompt_bridge_1.readPending)(promptId);
1098
- if (!pending || !pending.tmuxSession) {
1099
- await answerCallbackQuery(query.id, 'Session not found');
1452
+ if (!pending) {
1453
+ await answerCallbackQuery(query.id, 'Session expired');
1100
1454
  return;
1101
1455
  }
1102
1456
  const selected = pending.selectedOptions || [];
@@ -1105,44 +1459,26 @@ async function processCallbackQuery(query) {
1105
1459
  await answerCallbackQuery(query.id, 'No options selected');
1106
1460
  return;
1107
1461
  }
1108
- // Inject keystrokes: iterate each option from top, Space if selected, Down to next
1109
- // Claude Code multi-select UI starts with cursor on first option
1110
- // After the listed options, Claude Code adds an auto-generated "Other" option,
1111
- // then Submit. So we need: options.length Downs + 1 more Down to skip "Other"
1112
- const tmuxSessSubmit = pending.tmuxSession;
1113
- try {
1114
- for (let i = 0; i < pending.options.length; i++) {
1115
- if (selected[i]) {
1116
- await sessionSendKey(tmuxSessSubmit, 'Space');
1117
- }
1118
- await sessionSendKey(tmuxSessSubmit, 'Down');
1119
- }
1120
- // Skip past the auto-added "Other" option to reach Submit
1121
- await sessionSendKey(tmuxSessSubmit, 'Down');
1122
- // Cursor is now on Submit — press Enter
1123
- await sessionSendKey(tmuxSessSubmit, 'Enter');
1124
- // For multi-question flows: extra Enter to confirm
1125
- if (pending.isLast) {
1126
- await sleep(500);
1127
- await sessionSendKey(tmuxSessSubmit, 'Enter');
1128
- }
1129
- await answerCallbackQuery(query.id, `Submitted ${selectedLabels.length} options`);
1130
- startTypingIndicator(); // ensure Stop hook routes response back to Telegram
1131
- }
1132
- catch (err) {
1133
- logger.error(`Failed to inject keystrokes: ${err.message}`);
1134
- await answerCallbackQuery(query.id, 'Failed to send selections');
1135
- return;
1136
- }
1462
+ // Multi-select submit: write response file so hook can return updatedInput
1463
+ // (No keystroke injection needed hook polls for this file)
1464
+ const selectedIndices = selected
1465
+ .map((sel, idx) => sel ? idx + 1 : null)
1466
+ .filter((idx) => idx !== null);
1467
+ (0, prompt_bridge_1.writeResponse)(promptId, {
1468
+ action: 'answer',
1469
+ selectedOptions: selectedIndices,
1470
+ selectedLabels,
1471
+ });
1472
+ await answerCallbackQuery(query.id, `Submitted ${selectedLabels.length} options`);
1137
1473
  // Edit message to show selections and remove buttons
1138
1474
  const selectionText = selectedLabels.map(l => `\u2022 ${escapeMarkdown(l)}`).join('\n');
1139
1475
  try {
1140
- await editMessageText(chatId, messageId, `${originalText}\n\n Selected:\n${selectionText}`);
1476
+ await editMessageText(chatId, messageId, `${originalText}\n\n\u2714 Selected:\n${selectionText}`);
1141
1477
  }
1142
1478
  catch (err) {
1143
1479
  logger.error(`Failed to edit message: ${err.message}`);
1144
1480
  }
1145
- (0, prompt_bridge_1.cleanPrompt)(promptId);
1481
+ // Note: hook will clean up the prompt files after reading the response
1146
1482
  }
1147
1483
  else if (type === 'qperm') {
1148
1484
  // Combined question+permission: allow permission AND inject answer keystroke
@@ -1170,12 +1506,13 @@ async function processCallbackQuery(query) {
1170
1506
  if (pending.tmuxSession) {
1171
1507
  const tmux = pending.tmuxSession;
1172
1508
  const downPresses = optIdx;
1509
+ const sessionEntryQperm = (0, workspace_router_1.findSessionByTmuxName)(tmux);
1173
1510
  setTimeout(async () => {
1174
1511
  try {
1175
1512
  for (let i = 0; i < downPresses; i++) {
1176
- await sessionSendKey(tmux, 'Down');
1513
+ await sessionSendKey(tmux, 'Down', sessionEntryQperm);
1177
1514
  }
1178
- await sessionSendKey(tmux, 'Enter');
1515
+ await sessionSendKey(tmux, 'Enter', sessionEntryQperm);
1179
1516
  startTypingIndicator(); // ensure Stop hook routes response back to Telegram
1180
1517
  logger.info(`Injected question answer into ${tmux}: option ${parsed.optionIndex}`);
1181
1518
  }
@@ -1192,6 +1529,52 @@ async function processCallbackQuery(query) {
1192
1529
  logger.error(`Failed to edit message: ${err.message}`);
1193
1530
  }
1194
1531
  }
1532
+ else if (type === 'perm-denied') {
1533
+ // Permission denied retry/dismiss
1534
+ const { action } = parsed;
1535
+ const label = action === 'retry' ? '\ud83d\udd04 Retrying...' : '\u274c Dismissed';
1536
+ // Write response file for the polling hook
1537
+ try {
1538
+ (0, prompt_bridge_1.writeResponse)(promptId, { action });
1539
+ logger.info(`Wrote perm-denied response for promptId=${promptId}: action=${action}`);
1540
+ await answerCallbackQuery(query.id, label);
1541
+ }
1542
+ catch (err) {
1543
+ logger.error(`Failed to write perm-denied response: ${err.message}`);
1544
+ await answerCallbackQuery(query.id, 'Failed to save response');
1545
+ return;
1546
+ }
1547
+ // Edit message to show result and remove buttons
1548
+ try {
1549
+ await editMessageText(chatId, messageId, `${originalText}\n\n— ${label}`);
1550
+ }
1551
+ catch (err) {
1552
+ logger.error(`Failed to edit message: ${err.message}`);
1553
+ }
1554
+ }
1555
+ else if (type === 'pre-compact') {
1556
+ // Pre-compact proceed/block
1557
+ const { action } = parsed;
1558
+ const label = action === 'block' ? '\ud83d\uded1 Blocked' : '\u2705 Proceeding...';
1559
+ // Write response file for the polling hook
1560
+ try {
1561
+ (0, prompt_bridge_1.writeResponse)(promptId, { action });
1562
+ logger.info(`Wrote pre-compact response for promptId=${promptId}: action=${action}`);
1563
+ await answerCallbackQuery(query.id, label);
1564
+ }
1565
+ catch (err) {
1566
+ logger.error(`Failed to write pre-compact response: ${err.message}`);
1567
+ await answerCallbackQuery(query.id, 'Failed to save response');
1568
+ return;
1569
+ }
1570
+ // Edit message to show result and remove buttons
1571
+ try {
1572
+ await editMessageText(chatId, messageId, `${originalText}\n\n— ${label}`);
1573
+ }
1574
+ catch (err) {
1575
+ logger.error(`Failed to edit message: ${err.message}`);
1576
+ }
1577
+ }
1195
1578
  }
1196
1579
  // ── Message router ──────────────────────────────────────────────
1197
1580
  async function processMessage(msg) {
@@ -1252,6 +1635,24 @@ async function processMessage(msg) {
1252
1635
  await handleResume(resumeMatch[1] ? resumeMatch[1].trim() : null);
1253
1636
  return;
1254
1637
  }
1638
+ // /link <prompt>
1639
+ const linkMatch = text.match(/^\/link(?:\s+(.+))?$/s);
1640
+ if (linkMatch) {
1641
+ await handleLink(linkMatch[1] ? linkMatch[1].trim() : '');
1642
+ return;
1643
+ }
1644
+ // /effort [workspace] low|medium|high
1645
+ const effortMatch = text.match(/^\/effort(?:\s+(.+))?$/);
1646
+ if (effortMatch) {
1647
+ await handleEffort(effortMatch[1] ? effortMatch[1].trim() : '');
1648
+ return;
1649
+ }
1650
+ // /model [workspace] <model>
1651
+ const modelMatch = text.match(/^\/model(?:\s+(.+))?$/);
1652
+ if (modelMatch) {
1653
+ await handleModel(modelMatch[1] ? modelMatch[1].trim() : '');
1654
+ return;
1655
+ }
1255
1656
  // /cmd TOKEN command
1256
1657
  const cmdMatch = text.match(/^\/cmd\s+(\S+)\s+(.+)/s);
1257
1658
  if (cmdMatch) {