instar 0.4.8 → 0.4.10

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/dist/cli.js CHANGED
@@ -25,6 +25,7 @@ import { addUser, listUsers } from './commands/user.js';
25
25
  import { addJob, listJobs } from './commands/job.js';
26
26
  import pc from 'picocolors';
27
27
  import { getInstarVersion } from './core/Config.js';
28
+ import { listInstances } from './core/PortRegistry.js';
28
29
  /**
29
30
  * Add or update Telegram configuration in the project config.
30
31
  */
@@ -405,5 +406,100 @@ jobCmd
405
406
  .description('List all jobs')
406
407
  .option('-d, --dir <path>', 'Project directory')
407
408
  .action(listJobs);
409
+ // ── Lifeline ──────────────────────────────────────────────────────
410
+ const lifelineCmd = program
411
+ .command('lifeline')
412
+ .description('Manage the Telegram lifeline (persistent Telegram connection)');
413
+ lifelineCmd
414
+ .command('start')
415
+ .description('Start the Telegram lifeline (owns Telegram polling, supervises server)')
416
+ .option('-d, --dir <path>', 'Project directory')
417
+ .action(async (opts) => {
418
+ const { TelegramLifeline } = await import('./lifeline/TelegramLifeline.js');
419
+ try {
420
+ const lifeline = new TelegramLifeline(opts.dir);
421
+ await lifeline.start();
422
+ }
423
+ catch (err) {
424
+ console.error(pc.red(`Failed to start lifeline: ${err instanceof Error ? err.message : err}`));
425
+ process.exit(1);
426
+ }
427
+ });
428
+ lifelineCmd
429
+ .command('stop')
430
+ .description('Stop the Telegram lifeline')
431
+ .option('-d, --dir <path>', 'Project directory')
432
+ .action(async (opts) => {
433
+ // The lifeline runs in a tmux session — kill it
434
+ const { loadConfig, detectTmuxPath } = await import('./core/Config.js');
435
+ const config = loadConfig(opts.dir);
436
+ const tmuxPath = detectTmuxPath();
437
+ const sessionName = `${config.projectName}-lifeline`;
438
+ if (!tmuxPath) {
439
+ console.log(pc.red('tmux not found'));
440
+ process.exit(1);
441
+ }
442
+ try {
443
+ const { execFileSync } = await import('node:child_process');
444
+ execFileSync(tmuxPath, ['has-session', '-t', `=${sessionName}`], { stdio: 'ignore' });
445
+ execFileSync(tmuxPath, ['send-keys', '-t', `=${sessionName}:`, 'C-c'], { stdio: 'ignore' });
446
+ // Wait briefly for graceful shutdown
447
+ await new Promise(r => setTimeout(r, 3000));
448
+ try {
449
+ execFileSync(tmuxPath, ['kill-session', '-t', `=${sessionName}`], { stdio: 'ignore' });
450
+ }
451
+ catch { /* already dead */ }
452
+ console.log(pc.green(`Lifeline stopped (session: ${sessionName})`));
453
+ }
454
+ catch {
455
+ console.log(pc.yellow(`No lifeline running (no tmux session: ${sessionName})`));
456
+ }
457
+ });
458
+ lifelineCmd
459
+ .command('status')
460
+ .description('Check lifeline status')
461
+ .option('-d, --dir <path>', 'Project directory')
462
+ .action(async (opts) => {
463
+ const { loadConfig, detectTmuxPath } = await import('./core/Config.js');
464
+ const config = loadConfig(opts.dir);
465
+ const tmuxPath = detectTmuxPath();
466
+ const sessionName = `${config.projectName}-lifeline`;
467
+ if (!tmuxPath) {
468
+ console.log(pc.red('tmux not found'));
469
+ process.exit(1);
470
+ }
471
+ try {
472
+ const { execFileSync } = await import('node:child_process');
473
+ execFileSync(tmuxPath, ['has-session', '-t', `=${sessionName}`], { stdio: 'ignore' });
474
+ console.log(pc.green(`Lifeline is running (tmux session: ${sessionName})`));
475
+ console.log(` Attach: tmux attach -t '=${sessionName}'`);
476
+ }
477
+ catch {
478
+ console.log(pc.yellow('Lifeline is not running'));
479
+ console.log(` Start: instar lifeline start`);
480
+ }
481
+ });
482
+ // ── Instances ─────────────────────────────────────────────────────
483
+ program
484
+ .command('instances')
485
+ .description('List all Instar instances running on this machine')
486
+ .action(async () => {
487
+ const instances = listInstances();
488
+ if (instances.length === 0) {
489
+ console.log(pc.dim('No Instar instances registered.'));
490
+ console.log(pc.dim('Start a server with: instar server start'));
491
+ return;
492
+ }
493
+ console.log(pc.bold(`\n Instar Instances (${instances.length})\n`));
494
+ for (const entry of instances) {
495
+ const age = Math.round((Date.now() - new Date(entry.registeredAt).getTime()) / 60000);
496
+ const heartbeatAge = Math.round((Date.now() - new Date(entry.lastHeartbeat).getTime()) / 60000);
497
+ const alive = heartbeatAge < 3 ? pc.green('●') : pc.yellow('○');
498
+ console.log(` ${alive} ${pc.bold(entry.projectName)}`);
499
+ console.log(` Port: ${pc.cyan(String(entry.port))} PID: ${entry.pid} Up: ${age}m Heartbeat: ${heartbeatAge}m ago`);
500
+ console.log(` Dir: ${pc.dim(entry.projectDir)}`);
501
+ console.log();
502
+ }
503
+ });
408
504
  program.parse();
409
505
  //# sourceMappingURL=cli.js.map
@@ -32,6 +32,7 @@ import pc from 'picocolors';
32
32
  import { randomUUID } from 'node:crypto';
33
33
  import { detectTmuxPath, detectClaudePath, ensureStateDir } from '../core/Config.js';
34
34
  import { ensurePrerequisites } from '../core/Prerequisites.js';
35
+ import { allocatePort } from '../core/PortRegistry.js';
35
36
  import { defaultIdentity } from '../scaffold/bootstrap.js';
36
37
  import { generateAgentMd, generateUserMd, generateMemoryMd, generateClaudeMd, } from '../scaffold/templates.js';
37
38
  /**
@@ -93,7 +94,20 @@ async function initFreshProject(projectName, options) {
93
94
  }
94
95
  // Create project directory
95
96
  fs.mkdirSync(projectDir, { recursive: true });
96
- const port = options.port || 4040;
97
+ // Auto-allocate a port if not explicitly specified (multi-instance support)
98
+ let port;
99
+ if (options.port) {
100
+ port = options.port;
101
+ }
102
+ else {
103
+ try {
104
+ port = allocatePort(projectName);
105
+ console.log(` ${pc.green('✓')} Auto-allocated port ${port} (from ~/.instar/port-registry.json)`);
106
+ }
107
+ catch {
108
+ port = 4040; // Fallback to default
109
+ }
110
+ }
97
111
  // Generate identity (non-interactive for init, interactive for setup)
98
112
  const identity = defaultIdentity(projectName);
99
113
  // Create .instar/ state directory
@@ -225,7 +239,19 @@ node_modules/
225
239
  async function initExistingProject(options) {
226
240
  const projectDir = path.resolve(options.dir || process.cwd());
227
241
  const projectName = options.name || path.basename(projectDir);
228
- const port = options.port || 4040;
242
+ // Auto-allocate a port if not explicitly specified (multi-instance support)
243
+ let port;
244
+ if (options.port) {
245
+ port = options.port;
246
+ }
247
+ else {
248
+ try {
249
+ port = allocatePort(projectName);
250
+ }
251
+ catch {
252
+ port = 4040;
253
+ }
254
+ }
229
255
  console.log(pc.bold(`\nInitializing instar in: ${pc.cyan(projectDir)}`));
230
256
  console.log();
231
257
  // Check and install prerequisites
@@ -825,7 +851,11 @@ Types: \`bug\`, \`feature\`, \`improvement\`, \`question\`
825
851
  additions.push(`
826
852
  ## Telegram Relay
827
853
 
828
- When user input starts with \`[telegram:N]\`, the message came from a user via Telegram topic N. After responding, relay the response back:
854
+ When user input starts with \`[telegram:N]\` (e.g., \`[telegram:26] hello\`), the message came from a user via Telegram topic N.
855
+
856
+ **IMMEDIATE ACKNOWLEDGMENT (MANDATORY):** When you receive a Telegram message, your FIRST action — before reading files, searching code, or doing any work — must be sending a brief acknowledgment back. This confirms the message was received and you haven't stalled. Examples: "Got it, looking into this now." / "On it — checking the scheduler." / "Received, working on the sync." Then do the work, then send the full response.
857
+
858
+ **Response relay:** After completing your work, relay your response back:
829
859
 
830
860
  \`\`\`bash
831
861
  cat <<'EOF' | .claude/scripts/telegram-reply.sh N
@@ -833,7 +863,7 @@ Your response text here
833
863
  EOF
834
864
  \`\`\`
835
865
 
836
- Strip the \`[telegram:N]\` prefix before interpreting the message. Only relay conversational text — not tool output.
866
+ Strip the \`[telegram:N]\` prefix before interpreting the message. Respond naturally, then relay. Only relay your conversational text — not tool output or internal reasoning.
837
867
  `);
838
868
  }
839
869
  if (additions.length > 0) {
@@ -21,6 +21,7 @@ import { RelationshipManager } from '../core/RelationshipManager.js';
21
21
  import { FeedbackManager } from '../core/FeedbackManager.js';
22
22
  import { DispatchManager } from '../core/DispatchManager.js';
23
23
  import { UpdateChecker } from '../core/UpdateChecker.js';
24
+ import { registerPort, unregisterPort, startHeartbeat } from '../core/PortRegistry.js';
24
25
  /**
25
26
  * Respawn a session for a topic, including thread history in the bootstrap.
26
27
  * This prevents "thread drift" where respawned sessions lose context.
@@ -177,11 +178,14 @@ function wireTelegramRouting(telegram, sessionManager) {
177
178
  if (sessionManager.isSessionAlive(targetSession)) {
178
179
  console.log(`[telegram→session] Injecting into ${targetSession}: "${text.slice(0, 80)}"`);
179
180
  sessionManager.injectTelegramMessage(targetSession, topicId, text);
181
+ // Delivery confirmation — let the user know the message reached the session
182
+ telegram.sendToTopic(topicId, `✓ Delivered`).catch(() => { });
180
183
  // Track for stall detection
181
184
  telegram.trackMessageInjection(topicId, targetSession, text);
182
185
  }
183
186
  else {
184
187
  // Session died — respawn with thread history
188
+ telegram.sendToTopic(topicId, `🔄 Session restarting — message queued.`).catch(() => { });
185
189
  respawnSessionForTopic(sessionManager, telegram, targetSession, topicId, text).catch(err => {
186
190
  console.error(`[telegram→session] Respawn failed:`, err);
187
191
  });
@@ -285,6 +289,16 @@ export async function startServer(options) {
285
289
  console.log();
286
290
  // Clean up stale Telegram temp files on startup
287
291
  cleanupTelegramTempFiles();
292
+ // Register this instance in the port registry (multi-instance support)
293
+ try {
294
+ registerPort(config.projectName, config.port, config.projectDir);
295
+ console.log(pc.green(` Registered port ${config.port} for "${config.projectName}"`));
296
+ }
297
+ catch (err) {
298
+ console.log(pc.red(` Port conflict: ${err instanceof Error ? err.message : err}`));
299
+ process.exit(1);
300
+ }
301
+ const stopHeartbeat = startHeartbeat(config.projectName);
288
302
  // Warn if no auth token configured — server allows unauthenticated access
289
303
  if (!config.authToken) {
290
304
  console.log(pc.yellow(pc.bold(' ⚠ WARNING: No auth token configured — all API endpoints are unauthenticated!')));
@@ -371,6 +385,8 @@ export async function startServer(options) {
371
385
  // Graceful shutdown
372
386
  const shutdown = async () => {
373
387
  console.log('\nShutting down...');
388
+ stopHeartbeat();
389
+ unregisterPort(config.projectName);
374
390
  scheduler?.stop();
375
391
  if (telegram)
376
392
  await telegram.stop();
@@ -894,7 +894,11 @@ Every user's feedback makes the platform better for everyone. Report issues when
894
894
  section += `
895
895
  ## Telegram Relay
896
896
 
897
- When user input starts with \`[telegram:N]\` (e.g., \`[telegram:26] hello\`), the message came from a user via Telegram topic N. **After responding**, relay your response back:
897
+ When user input starts with \`[telegram:N]\` (e.g., \`[telegram:26] hello\`), the message came from a user via Telegram topic N.
898
+
899
+ **IMMEDIATE ACKNOWLEDGMENT (MANDATORY):** When you receive a Telegram message, your FIRST action — before reading files, searching code, or doing any work — must be sending a brief acknowledgment back. This confirms the message was received and you haven't stalled. Examples: "Got it, looking into this now." / "On it — checking the scheduler." / "Received, working on the sync." Then do the work, then send the full response.
900
+
901
+ **Response relay:** After completing your work, relay your response back:
898
902
 
899
903
  \`\`\`bash
900
904
  cat <<'EOF' | .claude/scripts/telegram-reply.sh N
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Port Registry — shared multi-instance port allocation and discovery.
3
+ *
4
+ * Maintains a machine-wide registry at ~/.instar/port-registry.json so
5
+ * multiple Instar installations can coexist without port conflicts.
6
+ *
7
+ * Features:
8
+ * - Auto-allocate a free port from a configurable range
9
+ * - Detect and reclaim stale entries (process no longer running)
10
+ * - Discover all running Instar instances on this machine
11
+ * - Atomic file writes to prevent corruption from concurrent access
12
+ */
13
+ export interface PortEntry {
14
+ /** Project name (from config.json) */
15
+ projectName: string;
16
+ /** Allocated port */
17
+ port: number;
18
+ /** Process ID of the server */
19
+ pid: number;
20
+ /** Absolute path to the project directory */
21
+ projectDir: string;
22
+ /** When this entry was registered */
23
+ registeredAt: string;
24
+ /** Last heartbeat timestamp (updated periodically) */
25
+ lastHeartbeat: string;
26
+ }
27
+ export interface PortRegistry {
28
+ entries: PortEntry[];
29
+ }
30
+ /**
31
+ * Load the port registry from disk.
32
+ * Returns an empty registry if the file doesn't exist.
33
+ */
34
+ export declare function loadPortRegistry(): PortRegistry;
35
+ /**
36
+ * Remove stale entries where the process is no longer running.
37
+ * Returns the cleaned registry.
38
+ */
39
+ export declare function cleanStaleEntries(registry: PortRegistry): PortRegistry;
40
+ /**
41
+ * Register a port for a project. Overwrites any existing entry for the same project.
42
+ */
43
+ export declare function registerPort(projectName: string, port: number, projectDir: string, pid?: number): void;
44
+ /**
45
+ * Unregister a project's port entry.
46
+ */
47
+ export declare function unregisterPort(projectName: string): void;
48
+ /**
49
+ * Update the heartbeat for a project's entry.
50
+ */
51
+ export declare function heartbeat(projectName: string): void;
52
+ /**
53
+ * Allocate a free port from the range, avoiding conflicts.
54
+ * Returns the first available port not in use by another instance.
55
+ */
56
+ export declare function allocatePort(projectName: string, rangeStart?: number, rangeEnd?: number): number;
57
+ /**
58
+ * List all registered instances (after cleaning stale entries).
59
+ */
60
+ export declare function listInstances(): PortEntry[];
61
+ /**
62
+ * Get a specific project's entry.
63
+ */
64
+ export declare function getEntry(projectName: string): PortEntry | null;
65
+ /**
66
+ * Start a periodic heartbeat that updates the registry entry.
67
+ * Returns a cleanup function to stop the interval.
68
+ */
69
+ export declare function startHeartbeat(projectName: string, intervalMs?: number): () => void;
70
+ //# sourceMappingURL=PortRegistry.d.ts.map
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Port Registry — shared multi-instance port allocation and discovery.
3
+ *
4
+ * Maintains a machine-wide registry at ~/.instar/port-registry.json so
5
+ * multiple Instar installations can coexist without port conflicts.
6
+ *
7
+ * Features:
8
+ * - Auto-allocate a free port from a configurable range
9
+ * - Detect and reclaim stale entries (process no longer running)
10
+ * - Discover all running Instar instances on this machine
11
+ * - Atomic file writes to prevent corruption from concurrent access
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ const REGISTRY_DIR = path.join(os.homedir(), '.instar');
17
+ const REGISTRY_PATH = path.join(REGISTRY_DIR, 'port-registry.json');
18
+ const DEFAULT_PORT_RANGE_START = 4040;
19
+ const DEFAULT_PORT_RANGE_END = 4099;
20
+ const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes without heartbeat = stale
21
+ /**
22
+ * Load the port registry from disk.
23
+ * Returns an empty registry if the file doesn't exist.
24
+ */
25
+ export function loadPortRegistry() {
26
+ try {
27
+ if (!fs.existsSync(REGISTRY_PATH)) {
28
+ return { entries: [] };
29
+ }
30
+ const data = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8'));
31
+ return { entries: Array.isArray(data.entries) ? data.entries : [] };
32
+ }
33
+ catch {
34
+ return { entries: [] };
35
+ }
36
+ }
37
+ /**
38
+ * Save the port registry to disk (atomic write).
39
+ */
40
+ function savePortRegistry(registry) {
41
+ fs.mkdirSync(REGISTRY_DIR, { recursive: true });
42
+ const tmpPath = `${REGISTRY_PATH}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
43
+ try {
44
+ fs.writeFileSync(tmpPath, JSON.stringify(registry, null, 2));
45
+ fs.renameSync(tmpPath, REGISTRY_PATH);
46
+ }
47
+ catch (err) {
48
+ try {
49
+ fs.unlinkSync(tmpPath);
50
+ }
51
+ catch { /* ignore */ }
52
+ throw err;
53
+ }
54
+ }
55
+ /**
56
+ * Check if a process with the given PID is running.
57
+ */
58
+ function isProcessAlive(pid) {
59
+ try {
60
+ process.kill(pid, 0); // Signal 0 = check existence
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ /**
68
+ * Check if a port is actually in use by trying to connect to it.
69
+ */
70
+ async function isPortInUse(port) {
71
+ try {
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => controller.abort(), 2000);
74
+ try {
75
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
76
+ signal: controller.signal,
77
+ });
78
+ return response.ok;
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ /**
89
+ * Remove stale entries where the process is no longer running.
90
+ * Returns the cleaned registry.
91
+ */
92
+ export function cleanStaleEntries(registry) {
93
+ const cleaned = registry.entries.filter(entry => {
94
+ if (!isProcessAlive(entry.pid)) {
95
+ console.log(`[PortRegistry] Removing stale entry: ${entry.projectName} (port ${entry.port}, pid ${entry.pid} dead)`);
96
+ return false;
97
+ }
98
+ return true;
99
+ });
100
+ return { entries: cleaned };
101
+ }
102
+ /**
103
+ * Register a port for a project. Overwrites any existing entry for the same project.
104
+ */
105
+ export function registerPort(projectName, port, projectDir, pid) {
106
+ let registry = loadPortRegistry();
107
+ registry = cleanStaleEntries(registry);
108
+ // Check for port conflicts with other projects
109
+ const conflict = registry.entries.find(e => e.port === port && e.projectName !== projectName);
110
+ if (conflict) {
111
+ throw new Error(`Port ${port} is already in use by "${conflict.projectName}" (pid ${conflict.pid}). ` +
112
+ `Change the port in .instar/config.json or use a different port.`);
113
+ }
114
+ // Remove existing entry for this project (will be replaced)
115
+ registry.entries = registry.entries.filter(e => e.projectName !== projectName);
116
+ const now = new Date().toISOString();
117
+ registry.entries.push({
118
+ projectName,
119
+ port,
120
+ pid: pid ?? process.pid,
121
+ projectDir,
122
+ registeredAt: now,
123
+ lastHeartbeat: now,
124
+ });
125
+ savePortRegistry(registry);
126
+ }
127
+ /**
128
+ * Unregister a project's port entry.
129
+ */
130
+ export function unregisterPort(projectName) {
131
+ const registry = loadPortRegistry();
132
+ registry.entries = registry.entries.filter(e => e.projectName !== projectName);
133
+ savePortRegistry(registry);
134
+ }
135
+ /**
136
+ * Update the heartbeat for a project's entry.
137
+ */
138
+ export function heartbeat(projectName) {
139
+ const registry = loadPortRegistry();
140
+ const entry = registry.entries.find(e => e.projectName === projectName);
141
+ if (entry) {
142
+ entry.lastHeartbeat = new Date().toISOString();
143
+ entry.pid = process.pid; // Update PID in case of restart
144
+ savePortRegistry(registry);
145
+ }
146
+ }
147
+ /**
148
+ * Allocate a free port from the range, avoiding conflicts.
149
+ * Returns the first available port not in use by another instance.
150
+ */
151
+ export function allocatePort(projectName, rangeStart = DEFAULT_PORT_RANGE_START, rangeEnd = DEFAULT_PORT_RANGE_END) {
152
+ let registry = loadPortRegistry();
153
+ registry = cleanStaleEntries(registry);
154
+ // Check if this project already has a port
155
+ const existing = registry.entries.find(e => e.projectName === projectName);
156
+ if (existing) {
157
+ return existing.port;
158
+ }
159
+ // Find the first free port in range
160
+ const usedPorts = new Set(registry.entries.map(e => e.port));
161
+ for (let port = rangeStart; port <= rangeEnd; port++) {
162
+ if (!usedPorts.has(port)) {
163
+ return port;
164
+ }
165
+ }
166
+ throw new Error(`No free ports available in range ${rangeStart}-${rangeEnd}. ` +
167
+ `${registry.entries.length} Instar instances are running.`);
168
+ }
169
+ /**
170
+ * List all registered instances (after cleaning stale entries).
171
+ */
172
+ export function listInstances() {
173
+ let registry = loadPortRegistry();
174
+ registry = cleanStaleEntries(registry);
175
+ savePortRegistry(registry);
176
+ return registry.entries;
177
+ }
178
+ /**
179
+ * Get a specific project's entry.
180
+ */
181
+ export function getEntry(projectName) {
182
+ const registry = loadPortRegistry();
183
+ return registry.entries.find(e => e.projectName === projectName) ?? null;
184
+ }
185
+ /**
186
+ * Start a periodic heartbeat that updates the registry entry.
187
+ * Returns a cleanup function to stop the interval.
188
+ */
189
+ export function startHeartbeat(projectName, intervalMs = 60_000) {
190
+ const interval = setInterval(() => {
191
+ try {
192
+ heartbeat(projectName);
193
+ }
194
+ catch (err) {
195
+ console.error(`[PortRegistry] Heartbeat failed: ${err}`);
196
+ }
197
+ }, intervalMs);
198
+ // Initial heartbeat
199
+ try {
200
+ heartbeat(projectName);
201
+ }
202
+ catch { /* ignore */ }
203
+ return () => clearInterval(interval);
204
+ }
205
+ //# sourceMappingURL=PortRegistry.js.map
@@ -131,7 +131,11 @@ This returns your full capability matrix: scripts, hooks, Telegram status, jobs,
131
131
  const section = `
132
132
  ## Telegram Relay
133
133
 
134
- When user input starts with \`[telegram:N]\` (e.g., \`[telegram:26] hello\`), the message came from a user via Telegram topic N. **After responding**, relay your response back:
134
+ When user input starts with \`[telegram:N]\` (e.g., \`[telegram:26] hello\`), the message came from a user via Telegram topic N.
135
+
136
+ **IMMEDIATE ACKNOWLEDGMENT (MANDATORY):** When you receive a Telegram message, your FIRST action — before reading files, searching code, or doing any work — must be sending a brief acknowledgment back. This confirms the message was received and you haven't stalled. Examples: "Got it, looking into this now." / "On it — checking the scheduler." / "Received, working on the sync." Then do the work, then send the full response.
137
+
138
+ **Response relay:** After completing your work, relay your response back:
135
139
 
136
140
  \`\`\`bash
137
141
  cat <<'EOF' | .claude/scripts/telegram-reply.sh N
@@ -145,6 +149,20 @@ Strip the \`[telegram:N]\` prefix before interpreting the message. Respond natur
145
149
  patched = true;
146
150
  result.upgraded.push('CLAUDE.md: added Telegram Relay section');
147
151
  }
152
+ // Upgrade existing Telegram Relay sections to include mandatory acknowledgment
153
+ if (this.config.hasTelegram && content.includes('Telegram Relay') && !content.includes('IMMEDIATE ACKNOWLEDGMENT')) {
154
+ const ackBlock = `\n**IMMEDIATE ACKNOWLEDGMENT (MANDATORY):** When you receive a Telegram message, your FIRST action — before reading files, searching code, or doing any work — must be sending a brief acknowledgment back. This confirms the message was received and you haven't stalled. Examples: "Got it, looking into this now." / "On it — checking the scheduler." / "Received, working on the sync." Then do the work, then send the full response.\n`;
155
+ // Insert after the first line of the Telegram Relay section
156
+ const relayIdx = content.indexOf('## Telegram Relay');
157
+ if (relayIdx >= 0) {
158
+ const nextNewline = content.indexOf('\n\n', relayIdx + 18);
159
+ if (nextNewline >= 0) {
160
+ content = content.slice(0, nextNewline + 1) + ackBlock + content.slice(nextNewline + 1);
161
+ patched = true;
162
+ result.upgraded.push('CLAUDE.md: added mandatory acknowledgment to Telegram Relay');
163
+ }
164
+ }
165
+ }
148
166
  if (patched) {
149
167
  try {
150
168
  fs.writeFileSync(claudeMdPath, content);
@@ -42,7 +42,8 @@ export declare class SessionManager extends EventEmitter {
42
42
  maxDurationMinutes?: number;
43
43
  }): Promise<Session>;
44
44
  /**
45
- * Check if a session is still running by checking tmux (sync version).
45
+ * Check if a session is still running by checking tmux AND verifying
46
+ * that the Claude process is running inside (not a zombie tmux pane).
46
47
  */
47
48
  isSessionAlive(tmuxSession: string): boolean;
48
49
  /**
@@ -142,10 +142,30 @@ export class SessionManager extends EventEmitter {
142
142
  return session;
143
143
  }
144
144
  /**
145
- * Check if a session is still running by checking tmux (sync version).
145
+ * Check if a session is still running by checking tmux AND verifying
146
+ * that the Claude process is running inside (not a zombie tmux pane).
146
147
  */
147
148
  isSessionAlive(tmuxSession) {
148
- return this.tmuxSessionExists(tmuxSession);
149
+ if (!this.tmuxSessionExists(tmuxSession))
150
+ return false;
151
+ // Verify Claude process is running inside the tmux session
152
+ try {
153
+ const paneCmd = execFileSync(this.config.tmuxPath, ['display-message', '-t', `=${tmuxSession}:`, '-p', '#{pane_current_command}'], { encoding: 'utf-8', timeout: 5000 }).trim();
154
+ // Claude Code runs as 'claude' or 'node' process
155
+ if (paneCmd && (paneCmd.includes('claude') || paneCmd.includes('node'))) {
156
+ return true;
157
+ }
158
+ // If pane command is bash/zsh/sh, Claude may have exited — session is dead
159
+ if (paneCmd === 'bash' || paneCmd === 'zsh' || paneCmd === 'sh') {
160
+ return false;
161
+ }
162
+ // For any other command, assume alive (could be a Claude subprocess)
163
+ return true;
164
+ }
165
+ catch {
166
+ // If we can't check, fall back to tmux session existence
167
+ return true;
168
+ }
149
169
  }
150
170
  /**
151
171
  * Check if a session is still running by checking tmux (async version).
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Message Queue — buffers Telegram messages when the server is down.
3
+ *
4
+ * Messages are persisted to disk so they survive lifeline restarts.
5
+ * When the server comes back, queued messages are replayed in order.
6
+ */
7
+ export interface QueuedMessage {
8
+ id: string;
9
+ topicId: number;
10
+ text: string;
11
+ fromUserId: number;
12
+ fromUsername?: string;
13
+ fromFirstName: string;
14
+ timestamp: string;
15
+ voiceFile?: string;
16
+ photoPath?: string;
17
+ }
18
+ export declare class MessageQueue {
19
+ private queuePath;
20
+ private queue;
21
+ constructor(stateDir: string);
22
+ /**
23
+ * Add a message to the queue.
24
+ */
25
+ enqueue(msg: QueuedMessage): void;
26
+ /**
27
+ * Get all queued messages and clear the queue.
28
+ */
29
+ drain(): QueuedMessage[];
30
+ /**
31
+ * Peek at the queue without draining.
32
+ */
33
+ peek(): QueuedMessage[];
34
+ get length(): number;
35
+ private load;
36
+ private save;
37
+ }
38
+ //# sourceMappingURL=MessageQueue.d.ts.map