@jsayubi/ccgram 1.1.1 → 1.2.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.
- package/.env.example +3 -0
- package/README.md +82 -51
- package/dist/elicitation-notify.d.ts +20 -0
- package/dist/elicitation-notify.d.ts.map +1 -0
- package/dist/elicitation-notify.js +241 -0
- package/dist/elicitation-notify.js.map +1 -0
- package/dist/enhanced-hook-notify.d.ts +8 -1
- package/dist/enhanced-hook-notify.d.ts.map +1 -1
- package/dist/enhanced-hook-notify.js +119 -5
- package/dist/enhanced-hook-notify.js.map +1 -1
- package/dist/permission-denied-notify.d.ts +11 -0
- package/dist/permission-denied-notify.d.ts.map +1 -0
- package/dist/permission-denied-notify.js +193 -0
- package/dist/permission-denied-notify.js.map +1 -0
- package/dist/permission-hook.js +43 -18
- package/dist/permission-hook.js.map +1 -1
- package/dist/pre-compact-notify.d.ts +13 -0
- package/dist/pre-compact-notify.d.ts.map +1 -0
- package/dist/pre-compact-notify.js +197 -0
- package/dist/pre-compact-notify.js.map +1 -0
- package/dist/question-notify.d.ts +6 -5
- package/dist/question-notify.d.ts.map +1 -1
- package/dist/question-notify.js +107 -23
- package/dist/question-notify.js.map +1 -1
- package/dist/setup.js +26 -10
- package/dist/setup.js.map +1 -1
- package/dist/src/types/callbacks.d.ts +11 -1
- package/dist/src/types/callbacks.d.ts.map +1 -1
- package/dist/src/types/session.d.ts +13 -1
- package/dist/src/types/session.d.ts.map +1 -1
- package/dist/src/utils/callback-parser.d.ts +7 -5
- package/dist/src/utils/callback-parser.d.ts.map +1 -1
- package/dist/src/utils/callback-parser.js +11 -5
- package/dist/src/utils/callback-parser.js.map +1 -1
- package/dist/src/utils/deep-link.d.ts +22 -0
- package/dist/src/utils/deep-link.d.ts.map +1 -0
- package/dist/src/utils/deep-link.js +43 -0
- package/dist/src/utils/deep-link.js.map +1 -0
- package/dist/src/utils/ghostty-session-manager.d.ts +81 -0
- package/dist/src/utils/ghostty-session-manager.d.ts.map +1 -0
- package/dist/src/utils/ghostty-session-manager.js +370 -0
- package/dist/src/utils/ghostty-session-manager.js.map +1 -0
- package/dist/src/utils/transcript-reader.d.ts +57 -0
- package/dist/src/utils/transcript-reader.d.ts.map +1 -0
- package/dist/src/utils/transcript-reader.js +229 -0
- package/dist/src/utils/transcript-reader.js.map +1 -0
- package/dist/workspace-router.d.ts +19 -4
- package/dist/workspace-router.d.ts.map +1 -1
- package/dist/workspace-router.js +57 -1
- package/dist/workspace-router.js.map +1 -1
- package/dist/workspace-telegram-bot.js +515 -114
- package/dist/workspace-telegram-bot.js.map +1 -1
- package/package.json +1 -1
- package/src/types/callbacks.ts +15 -1
- 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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
430
|
+
const raw = await sessionCaptureOutput(tmuxName, session);
|
|
431
|
+
paneOutput = raw.trim().split('\n').slice(-20).join('\n');
|
|
268
432
|
}
|
|
269
|
-
catch {
|
|
270
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
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
|
|
570
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
const
|
|
698
|
-
?
|
|
699
|
-
:
|
|
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
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
798
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
-
/**
|
|
892
|
-
|
|
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
|
|
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'
|
|
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:
|
|
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
|
|
1030
|
-
await answerCallbackQuery(query.id, 'Session
|
|
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:
|
|
1064
|
-
//
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
1450
|
+
// Multi-select submit: write response file so hook returns updatedInput
|
|
1097
1451
|
const pending = (0, prompt_bridge_1.readPending)(promptId);
|
|
1098
|
-
if (!pending
|
|
1099
|
-
await answerCallbackQuery(query.id, 'Session
|
|
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
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|