instar 0.7.49 → 0.7.51

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.
@@ -226,9 +226,11 @@ export class UpdateChecker {
226
226
  }
227
227
  }
228
228
  /**
229
- * Fetch human-readable changelog from GitHub releases.
229
+ * Fetch human-readable changelog from GitHub releases, falling back to
230
+ * recent commit messages if no release exists for this version.
230
231
  */
231
232
  async fetchChangelog(version) {
233
+ // Try GitHub release first
232
234
  try {
233
235
  const tag = version.startsWith('v') ? version : `v${version}`;
234
236
  const response = await fetch(`${GITHUB_RELEASES_URL}/tags/${tag}`, {
@@ -238,16 +240,41 @@ export class UpdateChecker {
238
240
  },
239
241
  signal: AbortSignal.timeout(10000),
240
242
  });
241
- if (!response.ok)
242
- return undefined;
243
- const release = await response.json();
244
- if (release.body) {
245
- // Truncate to first 500 chars for concise summary
246
- const summary = release.body.slice(0, 500);
247
- return summary.length < release.body.length ? summary + '...' : summary;
243
+ if (response.ok) {
244
+ const release = await response.json();
245
+ if (release.body) {
246
+ const summary = release.body.slice(0, 500);
247
+ return summary.length < release.body.length ? summary + '...' : summary;
248
+ }
249
+ if (release.name)
250
+ return release.name;
251
+ }
252
+ }
253
+ catch {
254
+ // Non-critical — try commit fallback
255
+ }
256
+ // Fallback: fetch recent commits from GitHub
257
+ try {
258
+ const response = await fetch('https://api.github.com/repos/SageMindAI/instar/commits?per_page=5', {
259
+ headers: {
260
+ 'Accept': 'application/vnd.github.v3+json',
261
+ 'User-Agent': 'instar-update-checker',
262
+ },
263
+ signal: AbortSignal.timeout(10000),
264
+ });
265
+ if (response.ok) {
266
+ const commits = await response.json();
267
+ if (commits.length > 0) {
268
+ const lines = commits
269
+ .map(c => {
270
+ // Take first line of commit message only
271
+ const firstLine = c.commit.message.split('\n')[0];
272
+ return `• ${firstLine}`;
273
+ })
274
+ .join('\n');
275
+ return `Recent changes:\n${lines}`;
276
+ }
248
277
  }
249
- if (release.name)
250
- return release.name;
251
278
  }
252
279
  catch {
253
280
  // Non-critical
@@ -712,6 +712,14 @@ export interface MonitoringConfig {
712
712
  memoryMonitoring: boolean;
713
713
  /** Health check interval in ms */
714
714
  healthCheckIntervalMs: number;
715
+ /** Session watchdog — auto-remediation for stuck commands */
716
+ watchdog?: {
717
+ enabled: boolean;
718
+ /** Seconds before a command is considered stuck (default: 180) */
719
+ stuckCommandSec?: number;
720
+ /** Poll interval in ms (default: 30000) */
721
+ pollIntervalMs?: number;
722
+ };
715
723
  }
716
724
  /** @deprecated Use InstarConfig instead */
717
725
  export type AgentKitConfig = InstarConfig;
@@ -25,6 +25,8 @@ export declare class ServerSupervisor extends EventEmitter {
25
25
  private restartBackoffMs;
26
26
  private isRunning;
27
27
  private lastHealthy;
28
+ private startupGraceMs;
29
+ private spawnedAt;
28
30
  constructor(options: {
29
31
  projectDir: string;
30
32
  projectName: string;
@@ -22,6 +22,8 @@ export class ServerSupervisor extends EventEmitter {
22
22
  restartBackoffMs = 5000;
23
23
  isRunning = false;
24
24
  lastHealthy = 0;
25
+ startupGraceMs = 20_000; // 20 seconds grace period after spawn before health checks
26
+ spawnedAt = 0;
25
27
  constructor(options) {
26
28
  super();
27
29
  this.projectDir = options.projectDir;
@@ -95,7 +97,7 @@ export class ServerSupervisor extends EventEmitter {
95
97
  return false;
96
98
  try {
97
99
  // Get the instar CLI path
98
- const cliPath = new URL('../../cli.js', import.meta.url).pathname;
100
+ const cliPath = new URL('../cli.js', import.meta.url).pathname;
99
101
  // --no-telegram: lifeline owns the Telegram connection, server should not poll
100
102
  const nodeCmd = ['node', cliPath, 'server', 'start', '--foreground', '--no-telegram']
101
103
  .map(arg => `'${arg.replace(/'/g, "'\\''")}'`)
@@ -108,6 +110,7 @@ export class ServerSupervisor extends EventEmitter {
108
110
  ], { stdio: 'ignore' });
109
111
  console.log(`[Supervisor] Server started in tmux session: ${this.serverSessionName}`);
110
112
  this.isRunning = true;
113
+ this.spawnedAt = Date.now();
111
114
  this.startHealthChecks();
112
115
  return true;
113
116
  }
@@ -133,6 +136,10 @@ export class ServerSupervisor extends EventEmitter {
133
136
  if (this.healthCheckInterval)
134
137
  return;
135
138
  this.healthCheckInterval = setInterval(async () => {
139
+ // Skip health checks during startup grace period — server needs time to boot
140
+ if (this.spawnedAt > 0 && (Date.now() - this.spawnedAt) < this.startupGraceMs) {
141
+ return;
142
+ }
136
143
  try {
137
144
  const healthy = await this.checkHealth();
138
145
  if (healthy) {
@@ -30,6 +30,7 @@ export declare class TelegramLifeline {
30
30
  private stopHeartbeat;
31
31
  private replayInterval;
32
32
  private lifelineTopicId;
33
+ private lockPath;
33
34
  constructor(projectDir?: string);
34
35
  /**
35
36
  * Start the lifeline — begins Telegram polling and server supervision.
@@ -44,6 +45,10 @@ export declare class TelegramLifeline {
44
45
  private handleLifelineCommand;
45
46
  private replayQueue;
46
47
  private notifyServerDown;
48
+ /**
49
+ * Check if OS-level autostart is installed for this project.
50
+ */
51
+ private isAutostartInstalled;
47
52
  /**
48
53
  * Ensure the Lifeline topic exists. Recreates if deleted.
49
54
  */
@@ -19,12 +19,61 @@
19
19
  * the full server crashes, runs out of memory, or gets stuck.
20
20
  */
21
21
  import fs from 'node:fs';
22
+ import os from 'node:os';
22
23
  import path from 'node:path';
23
24
  import pc from 'picocolors';
24
25
  import { loadConfig, ensureStateDir } from '../core/Config.js';
25
26
  import { registerPort, unregisterPort, startHeartbeat } from '../core/PortRegistry.js';
27
+ import { installAutoStart } from '../commands/setup.js';
26
28
  import { MessageQueue } from './MessageQueue.js';
27
29
  import { ServerSupervisor } from './ServerSupervisor.js';
30
+ /**
31
+ * Acquire an exclusive lock file to prevent multiple lifeline instances.
32
+ * Returns true if lock acquired, false if another instance holds it.
33
+ */
34
+ function acquireLockFile(lockPath) {
35
+ try {
36
+ // Check if lock file exists and if the PID is still alive
37
+ if (fs.existsSync(lockPath)) {
38
+ const raw = fs.readFileSync(lockPath, 'utf-8');
39
+ const data = JSON.parse(raw);
40
+ if (data.pid && typeof data.pid === 'number') {
41
+ try {
42
+ // Signal 0 checks if process exists without killing it
43
+ process.kill(data.pid, 0);
44
+ // Process still alive — another lifeline is running
45
+ return false;
46
+ }
47
+ catch {
48
+ // Process is dead — stale lock, we can take over
49
+ console.log(`[Lifeline] Removing stale lock (PID ${data.pid} is dead)`);
50
+ }
51
+ }
52
+ }
53
+ // Write our PID
54
+ const tmpPath = `${lockPath}.${process.pid}.tmp`;
55
+ fs.writeFileSync(tmpPath, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
56
+ fs.renameSync(tmpPath, lockPath);
57
+ return true;
58
+ }
59
+ catch (err) {
60
+ console.error(`[Lifeline] Lock acquisition failed: ${err}`);
61
+ return false;
62
+ }
63
+ }
64
+ function releaseLockFile(lockPath) {
65
+ try {
66
+ if (fs.existsSync(lockPath)) {
67
+ const raw = fs.readFileSync(lockPath, 'utf-8');
68
+ const data = JSON.parse(raw);
69
+ // Only remove if we own it
70
+ if (data.pid === process.pid) {
71
+ fs.unlinkSync(lockPath);
72
+ }
73
+ }
74
+ }
75
+ catch { /* best effort */ }
76
+ }
28
77
  export class TelegramLifeline {
29
78
  config;
30
79
  projectConfig;
@@ -37,6 +86,7 @@ export class TelegramLifeline {
37
86
  stopHeartbeat = null;
38
87
  replayInterval = null;
39
88
  lifelineTopicId = null;
89
+ lockPath;
40
90
  constructor(projectDir) {
41
91
  this.projectConfig = loadConfig(projectDir);
42
92
  ensureStateDir(this.projectConfig.stateDir);
@@ -48,6 +98,7 @@ export class TelegramLifeline {
48
98
  this.config = telegramConfig.config;
49
99
  this.queue = new MessageQueue(this.projectConfig.stateDir);
50
100
  this.offsetPath = path.join(this.projectConfig.stateDir, 'lifeline-poll-offset.json');
101
+ this.lockPath = path.join(this.projectConfig.stateDir, 'lifeline.lock');
51
102
  this.supervisor = new ServerSupervisor({
52
103
  projectDir: this.projectConfig.projectDir,
53
104
  projectName: this.projectConfig.projectName,
@@ -75,6 +126,11 @@ export class TelegramLifeline {
75
126
  console.log(` Port: ${this.projectConfig.port}`);
76
127
  console.log(` State: ${this.projectConfig.stateDir}`);
77
128
  console.log();
129
+ // Acquire exclusive lock — prevent multiple lifeline instances
130
+ if (!acquireLockFile(this.lockPath)) {
131
+ console.error(pc.red('[Lifeline] Another lifeline instance is already running. Exiting.'));
132
+ process.exit(0); // Clean exit — launchd will restart after ThrottleInterval, acting as a watchdog
133
+ }
78
134
  // Register in port registry (lifeline owns the port claim)
79
135
  try {
80
136
  registerPort(`${this.projectConfig.projectName}-lifeline`, this.projectConfig.port + 1000, // Lifeline uses port + 1000 to avoid conflict
@@ -112,6 +168,19 @@ export class TelegramLifeline {
112
168
  setTimeout(() => this.replayQueue(), 5000); // Wait for server to fully start
113
169
  }
114
170
  }
171
+ // Self-healing: ensure autostart is installed so the lifeline persists across reboots.
172
+ // The user must always be able to reach their agent remotely — this is non-negotiable.
173
+ try {
174
+ if (!this.isAutostartInstalled()) {
175
+ const installed = installAutoStart(this.projectConfig.projectName, this.projectConfig.projectDir, true);
176
+ if (installed) {
177
+ console.log(pc.green(` Auto-start self-healed: installed ${process.platform === 'darwin' ? 'LaunchAgent' : 'systemd service'}`));
178
+ }
179
+ }
180
+ }
181
+ catch {
182
+ // Non-critical — don't crash the lifeline over autostart
183
+ }
115
184
  // Graceful shutdown
116
185
  const shutdown = async () => {
117
186
  console.log('\nLifeline shutting down...');
@@ -123,6 +192,7 @@ export class TelegramLifeline {
123
192
  if (this.stopHeartbeat)
124
193
  this.stopHeartbeat();
125
194
  unregisterPort(`${this.projectConfig.projectName}-lifeline`);
195
+ releaseLockFile(this.lockPath);
126
196
  await this.supervisor.stop();
127
197
  process.exit(0);
128
198
  };
@@ -326,6 +396,22 @@ export class TelegramLifeline {
326
396
  await this.sendToTopic(topicId, `Server went down: ${reason}\n\nYour messages will be queued until recovery. Use /lifeline status to check.`).catch(() => { });
327
397
  }
328
398
  // ── Lifeline Topic ──────────────────────────────────────────
399
+ /**
400
+ * Check if OS-level autostart is installed for this project.
401
+ */
402
+ isAutostartInstalled() {
403
+ if (process.platform === 'darwin') {
404
+ const label = `ai.instar.${this.projectConfig.projectName}`;
405
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
406
+ return fs.existsSync(plistPath);
407
+ }
408
+ else if (process.platform === 'linux') {
409
+ const serviceName = `instar-${this.projectConfig.projectName}.service`;
410
+ const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', serviceName);
411
+ return fs.existsSync(servicePath);
412
+ }
413
+ return false;
414
+ }
329
415
  /**
330
416
  * Ensure the Lifeline topic exists. Recreates if deleted.
331
417
  */
@@ -7,13 +7,15 @@
7
7
  import type { SessionManager } from '../core/SessionManager.js';
8
8
  import type { JobScheduler } from '../scheduler/JobScheduler.js';
9
9
  import type { HealthStatus, InstarConfig } from '../core/types.js';
10
+ import type { SessionWatchdog } from './SessionWatchdog.js';
10
11
  export declare class HealthChecker {
11
12
  private config;
12
13
  private sessionManager;
13
14
  private scheduler;
15
+ private watchdog;
14
16
  private checkInterval;
15
17
  private lastStatus;
16
- constructor(config: InstarConfig, sessionManager: SessionManager, scheduler?: JobScheduler | null);
18
+ constructor(config: InstarConfig, sessionManager: SessionManager, scheduler?: JobScheduler | null, watchdog?: SessionWatchdog | null);
17
19
  /**
18
20
  * Run all health checks and return aggregated status.
19
21
  */
@@ -33,6 +35,7 @@ export declare class HealthChecker {
33
35
  private checkTmux;
34
36
  private checkSessions;
35
37
  private checkScheduler;
38
+ private checkMemory;
36
39
  private checkStateDir;
37
40
  }
38
41
  //# sourceMappingURL=HealthChecker.d.ts.map
@@ -11,12 +11,14 @@ export class HealthChecker {
11
11
  config;
12
12
  sessionManager;
13
13
  scheduler;
14
+ watchdog;
14
15
  checkInterval = null;
15
16
  lastStatus = null;
16
- constructor(config, sessionManager, scheduler = null) {
17
+ constructor(config, sessionManager, scheduler = null, watchdog = null) {
17
18
  this.config = config;
18
19
  this.sessionManager = sessionManager;
19
20
  this.scheduler = scheduler;
21
+ this.watchdog = watchdog;
20
22
  }
21
23
  /**
22
24
  * Run all health checks and return aggregated status.
@@ -26,9 +28,21 @@ export class HealthChecker {
26
28
  components.tmux = this.checkTmux();
27
29
  components.sessions = this.checkSessions();
28
30
  components.stateDir = this.checkStateDir();
31
+ components.memory = this.checkMemory();
29
32
  if (this.scheduler) {
30
33
  components.scheduler = this.checkScheduler();
31
34
  }
35
+ if (this.watchdog) {
36
+ const wdStatus = this.watchdog.getStatus();
37
+ const intervening = wdStatus.sessions.filter(s => s.escalation && s.escalation.level > 0);
38
+ components.watchdog = {
39
+ status: intervening.length > 0 ? 'degraded' : 'healthy',
40
+ message: intervening.length > 0
41
+ ? `Intervening on ${intervening.length} session(s)`
42
+ : `Monitoring${wdStatus.enabled ? '' : ' (disabled)'}`,
43
+ lastCheck: new Date().toISOString(),
44
+ };
45
+ }
32
46
  // Aggregate: worst component status becomes overall status
33
47
  const statuses = Object.values(components).map(c => c.status);
34
48
  let overall = 'healthy';
@@ -135,6 +149,27 @@ export class HealthChecker {
135
149
  lastCheck: now,
136
150
  };
137
151
  }
152
+ checkMemory() {
153
+ const now = new Date().toISOString();
154
+ try {
155
+ const os = require('node:os');
156
+ const totalBytes = os.totalmem();
157
+ const freeBytes = os.freemem();
158
+ const totalGB = totalBytes / (1024 ** 3);
159
+ const freeGB = freeBytes / (1024 ** 3);
160
+ const usedPercent = ((totalBytes - freeBytes) / totalBytes) * 100;
161
+ if (usedPercent >= 90) {
162
+ return { status: 'unhealthy', message: `Memory critical: ${usedPercent.toFixed(0)}% used (${freeGB.toFixed(1)}GB free)`, lastCheck: now };
163
+ }
164
+ if (usedPercent >= 75) {
165
+ return { status: 'degraded', message: `Memory elevated: ${usedPercent.toFixed(0)}% used (${freeGB.toFixed(1)}GB free)`, lastCheck: now };
166
+ }
167
+ return { status: 'healthy', message: `${usedPercent.toFixed(0)}% used (${freeGB.toFixed(1)}GB free / ${totalGB.toFixed(0)}GB total)`, lastCheck: now };
168
+ }
169
+ catch (err) {
170
+ return { status: 'degraded', message: `Memory check failed: ${err instanceof Error ? err.message : String(err)}`, lastCheck: now };
171
+ }
172
+ }
138
173
  checkStateDir() {
139
174
  const now = new Date().toISOString();
140
175
  try {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * MemoryPressureMonitor - Detect and respond to system memory pressure.
3
+ *
4
+ * Platform-aware: uses macOS `vm_stat` or Linux `/proc/meminfo`.
5
+ * EventEmitter pattern consistent with Instar conventions.
6
+ *
7
+ * Thresholds:
8
+ * - normal (< 60%): all operations allowed
9
+ * - warning (60-75%): log trend, notify
10
+ * - elevated (75-90%): restrict session spawning
11
+ * - critical (90%+): block all spawns, alert
12
+ *
13
+ * Includes trend tracking via ring buffer + linear regression.
14
+ */
15
+ import { EventEmitter } from 'node:events';
16
+ export type MemoryPressureState = 'normal' | 'warning' | 'elevated' | 'critical';
17
+ export type MemoryTrend = 'rising' | 'stable' | 'falling';
18
+ export interface MemoryState {
19
+ pressurePercent: number;
20
+ freeGB: number;
21
+ totalGB: number;
22
+ state: MemoryPressureState;
23
+ trend: MemoryTrend;
24
+ ratePerMin: number;
25
+ lastChecked: string;
26
+ stateChangedAt: string;
27
+ platform: string;
28
+ }
29
+ export interface MemoryPressureMonitorConfig {
30
+ /** Thresholds (percent). Defaults: warning=60, elevated=75, critical=90 */
31
+ thresholds?: {
32
+ warning?: number;
33
+ elevated?: number;
34
+ critical?: number;
35
+ };
36
+ /** Base check interval in ms. Default: 30000 */
37
+ checkIntervalMs?: number;
38
+ }
39
+ export declare class MemoryPressureMonitor extends EventEmitter {
40
+ private timeout;
41
+ private currentState;
42
+ private stateChangedAt;
43
+ private lastChecked;
44
+ private lastPressurePercent;
45
+ private lastFreeGB;
46
+ private lastTotalGB;
47
+ private ringBuffer;
48
+ private currentTrend;
49
+ private currentRatePerMin;
50
+ private thresholds;
51
+ private baseIntervalMs;
52
+ constructor(config?: MemoryPressureMonitorConfig);
53
+ start(): void;
54
+ stop(): void;
55
+ getState(): MemoryState;
56
+ /**
57
+ * Can a new session be spawned?
58
+ */
59
+ canSpawnSession(): {
60
+ allowed: boolean;
61
+ reason?: string;
62
+ };
63
+ private scheduleNext;
64
+ private check;
65
+ private classifyState;
66
+ /**
67
+ * Read system memory — platform-aware.
68
+ */
69
+ private readSystemMemory;
70
+ /**
71
+ * macOS: parse vm_stat
72
+ */
73
+ private parseVmStat;
74
+ /**
75
+ * Linux: parse /proc/meminfo
76
+ */
77
+ private parseProcMeminfo;
78
+ /**
79
+ * Linear regression over recent readings.
80
+ */
81
+ private detectTrend;
82
+ }
83
+ //# sourceMappingURL=MemoryPressureMonitor.d.ts.map