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 +96 -0
- package/dist/commands/init.js +34 -4
- package/dist/commands/server.js +16 -0
- package/dist/commands/setup.js +5 -1
- package/dist/core/PortRegistry.d.ts +70 -0
- package/dist/core/PortRegistry.js +205 -0
- package/dist/core/PostUpdateMigrator.js +19 -1
- package/dist/core/SessionManager.d.ts +2 -1
- package/dist/core/SessionManager.js +22 -2
- package/dist/lifeline/MessageQueue.d.ts +38 -0
- package/dist/lifeline/MessageQueue.js +63 -0
- package/dist/lifeline/ServerSupervisor.d.ts +62 -0
- package/dist/lifeline/ServerSupervisor.js +207 -0
- package/dist/lifeline/TelegramLifeline.d.ts +52 -0
- package/dist/lifeline/TelegramLifeline.js +384 -0
- package/dist/messaging/TelegramAdapter.d.ts +64 -0
- package/dist/messaging/TelegramAdapter.js +244 -0
- package/dist/scaffold/templates.js +28 -0
- package/dist/server/routes.js +150 -0
- package/package.json +1 -1
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
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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]
|
|
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) {
|
package/dist/commands/server.js
CHANGED
|
@@ -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();
|
package/dist/commands/setup.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|