instar 0.7.52 → 0.7.53

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.
@@ -36,6 +36,7 @@ import { QuotaTracker } from '../monitoring/QuotaTracker.js';
36
36
  import { AccountSwitcher } from '../monitoring/AccountSwitcher.js';
37
37
  import { QuotaNotifier } from '../monitoring/QuotaNotifier.js';
38
38
  import { classifySessionDeath } from '../monitoring/QuotaExhaustionDetector.js';
39
+ import { SessionWatchdog } from '../monitoring/SessionWatchdog.js';
39
40
  import { installAutoStart } from './setup.js';
40
41
  /**
41
42
  * Check if autostart is installed for this project.
@@ -646,6 +647,31 @@ export async function startServer(options) {
646
647
  scheduler.notifyJobComplete(session.id, session.tmuxSession);
647
648
  });
648
649
  }
650
+ // Session Watchdog — auto-remediation for stuck commands
651
+ let watchdog;
652
+ if (config.monitoring.watchdog?.enabled) {
653
+ watchdog = new SessionWatchdog(config, sessionManager, state);
654
+ watchdog.on('intervention', (event) => {
655
+ if (telegram) {
656
+ const topicId = telegram.getTopicForSession(event.sessionName);
657
+ if (topicId) {
658
+ const levelNames = ['Monitoring', 'Ctrl+C', 'SIGTERM', 'SIGKILL', 'Kill Session'];
659
+ const levelName = levelNames[event.level] || `Level ${event.level}`;
660
+ telegram.sendToTopic(topicId, `🔧 Watchdog [${levelName}]: ${event.action}\nStuck: \`${event.stuckCommand.slice(0, 60)}\``).catch(() => { });
661
+ }
662
+ }
663
+ });
664
+ watchdog.on('recovery', (sessionName, fromLevel) => {
665
+ if (telegram) {
666
+ const topicId = telegram.getTopicForSession(sessionName);
667
+ if (topicId) {
668
+ telegram.sendToTopic(topicId, `✅ Watchdog: session recovered (was at escalation level ${fromLevel})`).catch(() => { });
669
+ }
670
+ }
671
+ });
672
+ watchdog.start();
673
+ console.log(pc.green(' Session Watchdog enabled'));
674
+ }
649
675
  // Set up feedback and update checking
650
676
  let feedback;
651
677
  if (config.feedback) {
@@ -800,7 +826,7 @@ export async function startServer(options) {
800
826
  }
801
827
  });
802
828
  sleepWakeDetector.start();
803
- const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, autoUpdater, autoDispatcher, quotaTracker, publisher, viewer, tunnel, evolution });
829
+ const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, autoUpdater, autoDispatcher, quotaTracker, publisher, viewer, tunnel, evolution, watchdog });
804
830
  await server.start();
805
831
  // Start tunnel AFTER server is listening
806
832
  if (tunnel) {
@@ -54,17 +54,11 @@ export class AutoUpdater {
54
54
  if (this.interval)
55
55
  return;
56
56
  const intervalMs = this.config.checkIntervalMinutes * 60 * 1000;
57
- // Detect npx cache auto-apply and restart cause infinite loops when
58
- // running from npx because the cache still resolves to the old version
59
- // after npm installs the update. The restart finds the update again,
60
- // applies it again, restarts again — forever, killing all sessions each time.
57
+ // Warn if running from npx cache (auto-updates won't work properly)
61
58
  const scriptPath = process.argv[1] || '';
62
- const runningFromNpx = scriptPath.includes('.npm/_npx') || scriptPath.includes('/_npx/');
63
- if (runningFromNpx) {
64
- this.config.autoApply = false;
65
- this.config.autoRestart = false;
66
- console.warn('[AutoUpdater] Running from npx cache. Auto-apply and auto-restart disabled to prevent restart loops.\n' +
67
- '[AutoUpdater] Run: npm install -g instar (then restart with: instar server start)');
59
+ if (scriptPath.includes('.npm/_npx') || scriptPath.includes('/_npx/')) {
60
+ console.warn('[AutoUpdater] WARNING: Running from npx cache. Auto-updates require a global install.\n' +
61
+ '[AutoUpdater] Run: npm install -g instar');
68
62
  }
69
63
  console.log(`[AutoUpdater] Started (every ${this.config.checkIntervalMinutes}m, ` +
70
64
  `autoApply: ${this.config.autoApply}, autoRestart: ${this.config.autoRestart})`);
@@ -128,7 +122,8 @@ export class AutoUpdater {
128
122
  if (!this.config.autoApply) {
129
123
  // Just notify — don't apply
130
124
  await this.notify(`Update available: v${info.currentVersion} → v${info.latestVersion}\n\n` +
131
- (info.changeSummary ? `Changes: ${info.changeSummary}\n\n` : '') +
125
+ (info.changeSummary ? `What changed:\n${info.changeSummary}\n\n` : '') +
126
+ `Details: ${info.changelogUrl || 'https://github.com/SageMindAI/instar/releases'}\n\n` +
132
127
  `Auto-apply is disabled. Apply manually:\n` +
133
128
  `curl -X POST http://localhost:${this.getPort()}/updates/apply`);
134
129
  return;
@@ -153,13 +148,19 @@ export class AutoUpdater {
153
148
  console.log(`[AutoUpdater] Updated: v${result.previousVersion} → v${result.newVersion}`);
154
149
  // Step 5: Notify via Telegram
155
150
  const restartNote = result.restartNeeded && this.config.autoRestart
156
- ? 'Server is restarting now...'
151
+ ? '\nServer is restarting now...'
157
152
  : result.restartNeeded
158
- ? 'A server restart is needed to use the new version.'
153
+ ? '\nA server restart is needed to use the new version.'
159
154
  : '';
155
+ const changeSummary = info.changeSummary
156
+ ? `What changed:\n${info.changeSummary}\n`
157
+ : '';
158
+ const detailsUrl = info.changelogUrl || 'https://github.com/SageMindAI/instar/releases';
160
159
  await this.notify(`Updated: v${result.previousVersion} → v${result.newVersion}\n\n` +
161
- (info.changeSummary ? `What changed:\n${info.changeSummary}\n\n` : '') +
162
- restartNote);
160
+ changeSummary +
161
+ `Details: ${detailsUrl}\n` +
162
+ restartNote +
163
+ `\n\nTo disable auto-updates, set "autoApply": false in .instar/config.json under "updates".`);
163
164
  // Step 6: Self-restart if needed and configured
164
165
  if (result.restartNeeded && this.config.autoRestart) {
165
166
  // Brief delay to let the Telegram notification send
@@ -206,6 +207,23 @@ export class AutoUpdater {
206
207
  }
207
208
  }
208
209
  catch { /* not found globally */ }
210
+ // If `which instar` didn't find a global binary, try npm's prefix path directly.
211
+ // This handles the common case where npm's global bin directory is not in PATH
212
+ // (automation contexts, fresh shell sessions, custom npm prefixes).
213
+ if (!instarBin) {
214
+ try {
215
+ const npmPrefix = execFileSync('npm', ['prefix', '-g'], {
216
+ encoding: 'utf-8',
217
+ stdio: ['pipe', 'pipe', 'pipe'],
218
+ }).trim();
219
+ const candidate = `${npmPrefix}/bin/instar`;
220
+ if (fs.existsSync(candidate)) {
221
+ instarBin = candidate;
222
+ console.log(`[AutoUpdater] Found global binary via npm prefix: ${instarBin}`);
223
+ }
224
+ }
225
+ catch { /* npm not available or prefix lookup failed */ }
226
+ }
209
227
  let cmd;
210
228
  if (instarBin) {
211
229
  // Use the global binary — guaranteed to be the updated version
@@ -214,7 +232,21 @@ export class AutoUpdater {
214
232
  console.log(`[AutoUpdater] Will restart from global binary: ${instarBin}`);
215
233
  }
216
234
  else {
217
- // Fallback: use the original process.argv (global not available)
235
+ // No global binary found. If we were running from npx cache, restarting
236
+ // from process.argv would loop (npx cache is the old version, which would
237
+ // detect the update again and restart again indefinitely).
238
+ const scriptPath = process.argv[1] || '';
239
+ const isNpxCache = scriptPath.includes('.npm/_npx') || scriptPath.includes('/_npx/');
240
+ if (isNpxCache) {
241
+ console.error('[AutoUpdater] Update applied but cannot restart — global binary not found in PATH or npm prefix.');
242
+ console.error('[AutoUpdater] Restarting from npx cache would cause a restart loop.');
243
+ console.error('[AutoUpdater] Manual restart required: npm install -g instar && instar server start');
244
+ void this.notify('Update applied but auto-restart skipped — global binary not in PATH.\n\n' +
245
+ 'Run manually to activate the update:\n' +
246
+ '```\nnpm install -g instar\ninstar server start --foreground\n```');
247
+ return;
248
+ }
249
+ // Not from npx cache — safe to restart from current path
218
250
  const args = process.argv.slice(1)
219
251
  .map(a => `'${a.replace(/'/g, "'\\''")}'`)
220
252
  .join(' ');
@@ -68,6 +68,12 @@ export declare class SessionManager extends EventEmitter {
68
68
  * Send input to a running tmux session.
69
69
  */
70
70
  sendInput(tmuxSession: string, input: string): boolean;
71
+ /**
72
+ * Send a tmux key sequence (without -l literal flag).
73
+ * Use for special keys like 'C-c' (Ctrl+C), 'Enter', 'Escape'.
74
+ * Unlike sendInput() which uses -l (literal), this sends key names directly.
75
+ */
76
+ sendKey(tmuxSession: string, key: string): boolean;
71
77
  /**
72
78
  * List all sessions that are currently running.
73
79
  * Pure filter — does not mutate state. The monitor tick handles lifecycle transitions.
@@ -297,6 +297,20 @@ export class SessionManager extends EventEmitter {
297
297
  return false;
298
298
  }
299
299
  }
300
+ /**
301
+ * Send a tmux key sequence (without -l literal flag).
302
+ * Use for special keys like 'C-c' (Ctrl+C), 'Enter', 'Escape'.
303
+ * Unlike sendInput() which uses -l (literal), this sends key names directly.
304
+ */
305
+ sendKey(tmuxSession, key) {
306
+ try {
307
+ execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, key], { encoding: 'utf-8', timeout: 5000 });
308
+ return true;
309
+ }
310
+ catch {
311
+ return false;
312
+ }
313
+ }
300
314
  /**
301
315
  * List all sessions that are currently running.
302
316
  * Pure filter — does not mutate state. The monitor tick handles lifecycle transitions.
@@ -464,35 +478,25 @@ export class SessionManager extends EventEmitter {
464
478
  const exactTarget = `=${tmuxSession}:`;
465
479
  try {
466
480
  if (text.includes('\n')) {
467
- // Multi-line: write to temp file, load into tmux buffer, paste into pane.
481
+ // Multi-line: pipe into tmux load-buffer via stdin, then paste into pane.
468
482
  // This avoids newlines being treated as Enter keypresses which would
469
483
  // fragment the message into multiple Claude prompts.
470
- const tmpDir = path.join('/tmp', 'instar-inject');
471
- fs.mkdirSync(tmpDir, { recursive: true });
472
- const tmpPath = path.join(tmpDir, `msg-${Date.now()}-${process.pid}.txt`);
473
- fs.writeFileSync(tmpPath, text);
474
- try {
475
- execFileSync(this.config.tmuxPath, ['load-buffer', tmpPath], {
476
- encoding: 'utf-8', timeout: 5000,
477
- });
478
- execFileSync(this.config.tmuxPath, ['paste-buffer', '-t', exactTarget, '-p'], {
479
- encoding: 'utf-8', timeout: 5000,
480
- });
481
- // Brief delay to let the terminal process the paste before sending Enter.
482
- // Without this, the Enter arrives before paste processing completes and
483
- // the message sits in the input buffer without being submitted.
484
- execFileSync('/bin/sleep', ['0.3'], { timeout: 2000 });
485
- // Send Enter to submit
486
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, 'Enter'], {
487
- encoding: 'utf-8', timeout: 5000,
488
- });
489
- }
490
- finally {
491
- try {
492
- fs.unlinkSync(tmpPath);
493
- }
494
- catch { /* ignore */ }
495
- }
484
+ // Uses stdin pipe (load-buffer -) instead of temp files to avoid
485
+ // macOS TCC "access data from other apps" permission prompts.
486
+ execFileSync(this.config.tmuxPath, ['load-buffer', '-'], {
487
+ encoding: 'utf-8', timeout: 5000, input: text,
488
+ });
489
+ execFileSync(this.config.tmuxPath, ['paste-buffer', '-t', exactTarget, '-p'], {
490
+ encoding: 'utf-8', timeout: 5000,
491
+ });
492
+ // Brief delay to let the terminal process the paste before sending Enter.
493
+ // Without this, the Enter arrives before paste processing completes and
494
+ // the message sits in the input buffer without being submitted.
495
+ execFileSync('/bin/sleep', ['0.3'], { timeout: 2000 });
496
+ // Send Enter to submit
497
+ execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, 'Enter'], {
498
+ encoding: 'utf-8', timeout: 5000,
499
+ });
496
500
  }
497
501
  else {
498
502
  // Single-line: simple send-keys
@@ -60,7 +60,8 @@ export declare class UpdateChecker {
60
60
  updatedAt: string;
61
61
  } | null;
62
62
  /**
63
- * Fetch human-readable changelog from GitHub releases.
63
+ * Fetch human-readable changelog from GitHub releases, falling back to
64
+ * recent commit messages if no release exists for this version.
64
65
  */
65
66
  fetchChangelog(version: string): Promise<string | undefined>;
66
67
  /**
@@ -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;
@@ -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
  */
@@ -6,17 +6,20 @@
6
6
  */
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import fs from 'node:fs';
9
+ import os from 'node:os';
9
10
  import path from 'node:path';
10
11
  export class HealthChecker {
11
12
  config;
12
13
  sessionManager;
13
14
  scheduler;
15
+ watchdog;
14
16
  checkInterval = null;
15
17
  lastStatus = null;
16
- constructor(config, sessionManager, scheduler = null) {
18
+ constructor(config, sessionManager, scheduler = null, watchdog = null) {
17
19
  this.config = config;
18
20
  this.sessionManager = sessionManager;
19
21
  this.scheduler = scheduler;
22
+ this.watchdog = watchdog;
20
23
  }
21
24
  /**
22
25
  * Run all health checks and return aggregated status.
@@ -30,6 +33,17 @@ export class HealthChecker {
30
33
  if (this.scheduler) {
31
34
  components.scheduler = this.checkScheduler();
32
35
  }
36
+ if (this.watchdog) {
37
+ const wdStatus = this.watchdog.getStatus();
38
+ const intervening = wdStatus.sessions.filter(s => s.escalation && s.escalation.level > 0);
39
+ components.watchdog = {
40
+ status: intervening.length > 0 ? 'degraded' : 'healthy',
41
+ message: intervening.length > 0
42
+ ? `Intervening on ${intervening.length} session(s)`
43
+ : `Monitoring${wdStatus.enabled ? '' : ' (disabled)'}`,
44
+ lastCheck: new Date().toISOString(),
45
+ };
46
+ }
33
47
  // Aggregate: worst component status becomes overall status
34
48
  const statuses = Object.values(components).map(c => c.status);
35
49
  let overall = 'healthy';
@@ -139,7 +153,6 @@ export class HealthChecker {
139
153
  checkMemory() {
140
154
  const now = new Date().toISOString();
141
155
  try {
142
- const os = require('node:os');
143
156
  const totalBytes = os.totalmem();
144
157
  const freeBytes = os.freemem();
145
158
  const totalGB = totalBytes / (1024 ** 3);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * SessionWatchdog — Auto-remediation for stuck Claude sessions (Instar port).
3
+ *
4
+ * Detects when a Claude session has a long-running bash command and escalates
5
+ * from gentle (Ctrl+C) to forceful (SIGKILL + session kill). Adapted from
6
+ * Dawn Server's SessionWatchdog for Instar's self-contained architecture.
7
+ *
8
+ * Escalation pipeline:
9
+ * Level 0: Monitoring (default)
10
+ * Level 1: Ctrl+C via tmux send-keys
11
+ * Level 2: SIGTERM the stuck child PID
12
+ * Level 3: SIGKILL the stuck child PID
13
+ * Level 4: Kill tmux session
14
+ */
15
+ import { EventEmitter } from 'node:events';
16
+ import type { SessionManager } from '../core/SessionManager.js';
17
+ import type { StateManager } from '../core/StateManager.js';
18
+ import type { InstarConfig } from '../core/types.js';
19
+ export declare enum EscalationLevel {
20
+ Monitoring = 0,
21
+ CtrlC = 1,
22
+ SigTerm = 2,
23
+ SigKill = 3,
24
+ KillSession = 4
25
+ }
26
+ interface EscalationState {
27
+ level: EscalationLevel;
28
+ levelEnteredAt: number;
29
+ stuckChildPid: number;
30
+ stuckCommand: string;
31
+ retryCount: number;
32
+ }
33
+ export interface InterventionEvent {
34
+ sessionName: string;
35
+ level: EscalationLevel;
36
+ action: string;
37
+ stuckCommand: string;
38
+ stuckPid: number;
39
+ timestamp: number;
40
+ }
41
+ export interface WatchdogEvents {
42
+ intervention: [event: InterventionEvent];
43
+ recovery: [sessionName: string, fromLevel: EscalationLevel];
44
+ }
45
+ export declare class SessionWatchdog extends EventEmitter {
46
+ private config;
47
+ private sessionManager;
48
+ private state;
49
+ private interval;
50
+ private escalationState;
51
+ private interventionHistory;
52
+ private enabled;
53
+ private running;
54
+ private stuckThresholdMs;
55
+ private pollIntervalMs;
56
+ constructor(config: InstarConfig, sessionManager: SessionManager, state: StateManager);
57
+ start(): void;
58
+ stop(): void;
59
+ setEnabled(enabled: boolean): void;
60
+ isEnabled(): boolean;
61
+ isManaging(sessionName: string): boolean;
62
+ getStatus(): {
63
+ enabled: boolean;
64
+ sessions: Array<{
65
+ name: string;
66
+ escalation: EscalationState | null;
67
+ }>;
68
+ interventionHistory: InterventionEvent[];
69
+ };
70
+ private poll;
71
+ private checkSession;
72
+ private handleEscalation;
73
+ private getClaudePid;
74
+ private getChildProcesses;
75
+ private isExcluded;
76
+ private parseElapsed;
77
+ private sendSignal;
78
+ private isProcessAlive;
79
+ private killTmuxSession;
80
+ private recordIntervention;
81
+ }
82
+ export {};
83
+ //# sourceMappingURL=SessionWatchdog.d.ts.map