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.
- package/.claude/skills/setup-wizard/skill.md +9 -40
- package/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/dist/commands/server.js +141 -1
- package/dist/core/AutoUpdater.js +12 -5
- package/dist/core/CaffeinateManager.d.ts +50 -0
- package/dist/core/CaffeinateManager.js +180 -0
- package/dist/core/SessionManager.d.ts +6 -0
- package/dist/core/SessionManager.js +31 -27
- package/dist/core/UpdateChecker.d.ts +2 -1
- package/dist/core/UpdateChecker.js +37 -10
- package/dist/core/types.d.ts +8 -0
- package/dist/lifeline/ServerSupervisor.d.ts +2 -0
- package/dist/lifeline/ServerSupervisor.js +8 -1
- package/dist/lifeline/TelegramLifeline.d.ts +5 -0
- package/dist/lifeline/TelegramLifeline.js +86 -0
- package/dist/monitoring/HealthChecker.d.ts +4 -1
- package/dist/monitoring/HealthChecker.js +36 -1
- package/dist/monitoring/MemoryPressureMonitor.d.ts +83 -0
- package/dist/monitoring/MemoryPressureMonitor.js +242 -0
- package/dist/monitoring/SessionWatchdog.d.ts +83 -0
- package/dist/monitoring/SessionWatchdog.js +326 -0
- package/dist/scaffold/templates.js +31 -8
- package/dist/server/AgentServer.d.ts +2 -0
- package/dist/server/AgentServer.js +1 -0
- package/dist/server/middleware.js +5 -0
- package/dist/server/routes.d.ts +2 -0
- package/dist/server/routes.js +82 -3
- package/package.json +1 -1
|
@@ -646,50 +646,19 @@ If Telegram was set up in Phase 3, install the relay script that lets Claude ses
|
|
|
646
646
|
mkdir -p .claude/scripts
|
|
647
647
|
```
|
|
648
648
|
|
|
649
|
-
|
|
649
|
+
**IMPORTANT: Do NOT write a custom telegram-reply.sh.** Instead, copy the canonical version from the instar package:
|
|
650
650
|
|
|
651
651
|
```bash
|
|
652
|
-
|
|
653
|
-
# telegram-reply.sh — Send a message back to a Telegram topic via instar server.
|
|
654
|
-
#
|
|
655
|
-
# Usage:
|
|
656
|
-
# .claude/scripts/telegram-reply.sh TOPIC_ID "message text"
|
|
657
|
-
# echo "message text" | .claude/scripts/telegram-reply.sh TOPIC_ID
|
|
658
|
-
# cat <<'EOF' | .claude/scripts/telegram-reply.sh TOPIC_ID
|
|
659
|
-
# Multi-line message here
|
|
660
|
-
# EOF
|
|
661
|
-
|
|
662
|
-
TOPIC_ID=$1
|
|
663
|
-
shift
|
|
664
|
-
|
|
665
|
-
if [ -z "$TOPIC_ID" ]; then
|
|
666
|
-
echo "Usage: telegram-reply.sh TOPIC_ID [message]" >&2
|
|
667
|
-
exit 1
|
|
668
|
-
fi
|
|
669
|
-
|
|
670
|
-
PORT=<PORT>
|
|
671
|
-
|
|
672
|
-
# Get message from args or stdin
|
|
673
|
-
if [ $# -gt 0 ]; then
|
|
674
|
-
MESSAGE="$*"
|
|
675
|
-
else
|
|
676
|
-
MESSAGE=$(cat)
|
|
677
|
-
fi
|
|
678
|
-
|
|
679
|
-
if [ -z "$MESSAGE" ]; then
|
|
680
|
-
echo "No message provided" >&2
|
|
681
|
-
exit 1
|
|
682
|
-
fi
|
|
683
|
-
|
|
684
|
-
# Send via instar server API
|
|
685
|
-
curl -s -X POST "http://localhost:${PORT}/telegram/topic/${TOPIC_ID}/send" \
|
|
686
|
-
-H 'Content-Type: application/json' \
|
|
687
|
-
-d "$(jq -n --arg text "$MESSAGE" '{text: $text}')" > /dev/null 2>&1
|
|
688
|
-
|
|
689
|
-
echo "Sent $(echo "$MESSAGE" | wc -c | tr -d ' ') chars to topic $TOPIC_ID"
|
|
652
|
+
cp "$(dirname "$(which instar 2>/dev/null || echo "$(npm root -g)/instar")")/templates/scripts/telegram-reply.sh" .claude/scripts/telegram-reply.sh 2>/dev/null
|
|
690
653
|
```
|
|
691
654
|
|
|
692
|
-
|
|
655
|
+
If the copy fails (e.g., npx install), write the script using the template at `node_modules/instar/dist/templates/scripts/telegram-reply.sh` as the source. The key details:
|
|
656
|
+
- **Endpoint**: `POST http://localhost:PORT/telegram/reply/TOPIC_ID` (NOT `/telegram/topic/TOPIC_ID/send`)
|
|
657
|
+
- **Auth**: Must read authToken from `.instar/config.json` and include `Authorization: Bearer TOKEN` header
|
|
658
|
+
- **JSON escaping**: Use python3 for proper JSON escaping, not jq (which may not be installed)
|
|
659
|
+
- **Error reporting**: Do NOT pipe curl output to `/dev/null` — check the HTTP status code and report failures
|
|
660
|
+
|
|
661
|
+
Then make it executable:
|
|
693
662
|
|
|
694
663
|
```bash
|
|
695
664
|
chmod +x .claude/scripts/telegram-reply.sh
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
> Why do I have a folder named ".vercel" in my project?
|
|
2
|
+
The ".vercel" folder is created when you link a directory to a Vercel project.
|
|
3
|
+
|
|
4
|
+
> What does the "project.json" file contain?
|
|
5
|
+
The "project.json" file contains:
|
|
6
|
+
- The ID of the Vercel project that you linked ("projectId")
|
|
7
|
+
- The ID of the user or team your Vercel project is owned by ("orgId")
|
|
8
|
+
|
|
9
|
+
> Should I commit the ".vercel" folder?
|
|
10
|
+
No, you should not share the ".vercel" folder with anyone.
|
|
11
|
+
Upon creation, it will be automatically added to your ".gitignore" file.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"projectId":"prj_evM5LcItYL3IAmw8zNvEPGrHeaya","orgId":"team_dHctwIDcV3X9ydapQlCPHFGI","projectName":"claude-agent-kit"}
|
package/dist/commands/server.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from 'node:child_process';
|
|
11
11
|
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
import pc from 'picocolors';
|
|
14
15
|
import { loadConfig, ensureStateDir, detectTmuxPath } from '../core/Config.js';
|
|
@@ -35,6 +36,25 @@ import { QuotaTracker } from '../monitoring/QuotaTracker.js';
|
|
|
35
36
|
import { AccountSwitcher } from '../monitoring/AccountSwitcher.js';
|
|
36
37
|
import { QuotaNotifier } from '../monitoring/QuotaNotifier.js';
|
|
37
38
|
import { classifySessionDeath } from '../monitoring/QuotaExhaustionDetector.js';
|
|
39
|
+
import { SessionWatchdog } from '../monitoring/SessionWatchdog.js';
|
|
40
|
+
import { installAutoStart } from './setup.js';
|
|
41
|
+
/**
|
|
42
|
+
* Check if autostart is installed for this project.
|
|
43
|
+
* Extracted from the CLI `autostart status` handler for programmatic use.
|
|
44
|
+
*/
|
|
45
|
+
function isAutostartInstalled(projectName) {
|
|
46
|
+
if (process.platform === 'darwin') {
|
|
47
|
+
const label = `ai.instar.${projectName}`;
|
|
48
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
49
|
+
return fs.existsSync(plistPath);
|
|
50
|
+
}
|
|
51
|
+
else if (process.platform === 'linux') {
|
|
52
|
+
const serviceName = `instar-${projectName}.service`;
|
|
53
|
+
const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', serviceName);
|
|
54
|
+
return fs.existsSync(servicePath);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
38
58
|
/**
|
|
39
59
|
* Respawn a session for a topic, including thread history in the bootstrap.
|
|
40
60
|
* This prevents "thread drift" where respawned sessions lose context.
|
|
@@ -627,6 +647,31 @@ export async function startServer(options) {
|
|
|
627
647
|
scheduler.notifyJobComplete(session.id, session.tmuxSession);
|
|
628
648
|
});
|
|
629
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
|
+
}
|
|
630
675
|
// Set up feedback and update checking
|
|
631
676
|
let feedback;
|
|
632
677
|
if (config.feedback) {
|
|
@@ -710,7 +755,78 @@ export async function startServer(options) {
|
|
|
710
755
|
...(config.evolution || {}),
|
|
711
756
|
});
|
|
712
757
|
console.log(pc.green(' Evolution system enabled'));
|
|
713
|
-
|
|
758
|
+
// Start MemoryPressureMonitor (platform-aware memory tracking)
|
|
759
|
+
const { MemoryPressureMonitor } = await import('../monitoring/MemoryPressureMonitor.js');
|
|
760
|
+
const memoryMonitor = new MemoryPressureMonitor({});
|
|
761
|
+
memoryMonitor.on('stateChange', ({ from, to, state: memState }) => {
|
|
762
|
+
// Gate scheduler spawning on memory pressure
|
|
763
|
+
if (scheduler && (to === 'elevated' || to === 'critical')) {
|
|
764
|
+
console.log(`[MemoryPressure] ${from} -> ${to} — scheduler should respect canSpawnSession()`);
|
|
765
|
+
}
|
|
766
|
+
// Alert via Telegram attention topic
|
|
767
|
+
if (telegram && to !== 'normal') {
|
|
768
|
+
const attentionTopicId = state.get('agent-attention-topic');
|
|
769
|
+
if (attentionTopicId) {
|
|
770
|
+
telegram.sendToTopic(attentionTopicId, `Memory ${to}: ${memState.pressurePercent.toFixed(1)}% used, ${memState.freeGB.toFixed(1)}GB free (trend: ${memState.trend})`).catch(() => { });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
memoryMonitor.start();
|
|
775
|
+
// Wire memory gate into scheduler
|
|
776
|
+
if (scheduler) {
|
|
777
|
+
const originalCanRun = scheduler.canRunJob;
|
|
778
|
+
scheduler.canRunJob = (priority) => {
|
|
779
|
+
// Check memory first
|
|
780
|
+
const memCheck = memoryMonitor.canSpawnSession();
|
|
781
|
+
if (!memCheck.allowed) {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
// Then check original gate (quota, etc.)
|
|
785
|
+
return originalCanRun(priority);
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
// Start CaffeinateManager (prevents macOS system sleep)
|
|
789
|
+
const { CaffeinateManager } = await import('../core/CaffeinateManager.js');
|
|
790
|
+
const caffeinateManager = new CaffeinateManager({ stateDir: config.stateDir });
|
|
791
|
+
caffeinateManager.start();
|
|
792
|
+
// Start SleepWakeDetector (re-validate sessions on wake)
|
|
793
|
+
const { SleepWakeDetector } = await import('../core/SleepWakeDetector.js');
|
|
794
|
+
const sleepWakeDetector = new SleepWakeDetector();
|
|
795
|
+
sleepWakeDetector.on('wake', async (event) => {
|
|
796
|
+
console.log(`[SleepWake] Wake detected after ~${event.sleepDurationSeconds}s sleep`);
|
|
797
|
+
// Re-validate tmux sessions
|
|
798
|
+
try {
|
|
799
|
+
const tmuxPath = detectTmuxPath();
|
|
800
|
+
if (tmuxPath) {
|
|
801
|
+
const { execFileSync } = await import('child_process');
|
|
802
|
+
const result = execFileSync(tmuxPath, ['list-sessions'], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
803
|
+
console.log(`[SleepWake] tmux sessions after wake: ${result.split('\n').length}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
console.warn('[SleepWake] tmux check failed after wake');
|
|
808
|
+
}
|
|
809
|
+
// Restart tunnel if configured
|
|
810
|
+
if (tunnel) {
|
|
811
|
+
try {
|
|
812
|
+
await tunnel.stop();
|
|
813
|
+
const tunnelUrl = await tunnel.start();
|
|
814
|
+
console.log(`[SleepWake] Tunnel restarted: ${tunnelUrl}`);
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
console.error(`[SleepWake] Tunnel restart failed:`, err);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// Notify via Telegram attention topic
|
|
821
|
+
if (telegram) {
|
|
822
|
+
const attentionTopicId = state.get('agent-attention-topic');
|
|
823
|
+
if (attentionTopicId) {
|
|
824
|
+
telegram.sendToTopic(attentionTopicId, `Wake detected after ~${event.sleepDurationSeconds}s sleep. Sessions re-validated.`).catch(() => { });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
sleepWakeDetector.start();
|
|
829
|
+
const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, autoUpdater, autoDispatcher, quotaTracker, publisher, viewer, tunnel, evolution, watchdog });
|
|
714
830
|
await server.start();
|
|
715
831
|
// Start tunnel AFTER server is listening
|
|
716
832
|
if (tunnel) {
|
|
@@ -723,9 +839,33 @@ export async function startServer(options) {
|
|
|
723
839
|
console.log(pc.yellow(` Server running locally without tunnel. Fix tunnel config and restart.`));
|
|
724
840
|
}
|
|
725
841
|
}
|
|
842
|
+
// Self-healing: ensure autostart is installed so the server always restarts
|
|
843
|
+
// This is a non-negotiable requirement — the user must always be able to reach their agent remotely.
|
|
844
|
+
// If autostart isn't installed, install it silently. The agent should never require human intervention
|
|
845
|
+
// to ensure its own resilience.
|
|
846
|
+
try {
|
|
847
|
+
const hasTelegram = !!telegram;
|
|
848
|
+
const autostartInstalled = isAutostartInstalled(config.projectName);
|
|
849
|
+
if (!autostartInstalled) {
|
|
850
|
+
const installed = installAutoStart(config.projectName, config.projectDir, hasTelegram);
|
|
851
|
+
if (installed) {
|
|
852
|
+
console.log(pc.green(` Auto-start self-healed: installed ${process.platform === 'darwin' ? 'LaunchAgent' : 'systemd service'}`));
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
console.log(pc.yellow(` Auto-start not available on ${process.platform}`));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
catch (err) {
|
|
860
|
+
// Non-critical — don't crash the server over autostart
|
|
861
|
+
console.error(` Auto-start check failed: ${err instanceof Error ? err.message : err}`);
|
|
862
|
+
}
|
|
726
863
|
// Graceful shutdown
|
|
727
864
|
const shutdown = async () => {
|
|
728
865
|
console.log('\nShutting down...');
|
|
866
|
+
memoryMonitor.stop();
|
|
867
|
+
caffeinateManager.stop();
|
|
868
|
+
sleepWakeDetector.stop();
|
|
729
869
|
autoUpdater.stop();
|
|
730
870
|
autoDispatcher?.stop();
|
|
731
871
|
if (tunnel)
|
package/dist/core/AutoUpdater.js
CHANGED
|
@@ -122,7 +122,8 @@ export class AutoUpdater {
|
|
|
122
122
|
if (!this.config.autoApply) {
|
|
123
123
|
// Just notify — don't apply
|
|
124
124
|
await this.notify(`Update available: v${info.currentVersion} → v${info.latestVersion}\n\n` +
|
|
125
|
-
(info.changeSummary ? `
|
|
125
|
+
(info.changeSummary ? `What changed:\n${info.changeSummary}\n\n` : '') +
|
|
126
|
+
`Details: ${info.changelogUrl || 'https://github.com/SageMindAI/instar/releases'}\n\n` +
|
|
126
127
|
`Auto-apply is disabled. Apply manually:\n` +
|
|
127
128
|
`curl -X POST http://localhost:${this.getPort()}/updates/apply`);
|
|
128
129
|
return;
|
|
@@ -147,13 +148,19 @@ export class AutoUpdater {
|
|
|
147
148
|
console.log(`[AutoUpdater] Updated: v${result.previousVersion} → v${result.newVersion}`);
|
|
148
149
|
// Step 5: Notify via Telegram
|
|
149
150
|
const restartNote = result.restartNeeded && this.config.autoRestart
|
|
150
|
-
? '
|
|
151
|
+
? '\nServer is restarting now...'
|
|
151
152
|
: result.restartNeeded
|
|
152
|
-
? '
|
|
153
|
+
? '\nA server restart is needed to use the new version.'
|
|
153
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';
|
|
154
159
|
await this.notify(`Updated: v${result.previousVersion} → v${result.newVersion}\n\n` +
|
|
155
|
-
|
|
156
|
-
|
|
160
|
+
changeSummary +
|
|
161
|
+
`Details: ${detailsUrl}\n` +
|
|
162
|
+
restartNote +
|
|
163
|
+
`\n\nTo disable auto-updates, set "autoApply": false in .instar/config.json under "updates".`);
|
|
157
164
|
// Step 6: Self-restart if needed and configured
|
|
158
165
|
if (result.restartNeeded && this.config.autoRestart) {
|
|
159
166
|
// Brief delay to let the Telegram notification send
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CaffeinateManager - Prevent macOS system sleep for Instar server lifetime.
|
|
3
|
+
*
|
|
4
|
+
* Maintains a `caffeinate -s` child process that prevents system sleep.
|
|
5
|
+
* A watchdog verifies it's alive every 30s and restarts if dead.
|
|
6
|
+
* PID is written to <stateDir>/caffeinate.pid for crash recovery.
|
|
7
|
+
*
|
|
8
|
+
* Only activates on macOS (process.platform === 'darwin').
|
|
9
|
+
* Uses EventEmitter pattern consistent with Instar conventions.
|
|
10
|
+
*/
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
export interface CaffeinateManagerConfig {
|
|
13
|
+
/** State directory for PID file storage */
|
|
14
|
+
stateDir: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CaffeinateStatus {
|
|
17
|
+
running: boolean;
|
|
18
|
+
pid: number | null;
|
|
19
|
+
startedAt: string | null;
|
|
20
|
+
restartCount: number;
|
|
21
|
+
lastWatchdogCheck: string;
|
|
22
|
+
}
|
|
23
|
+
export declare class CaffeinateManager extends EventEmitter {
|
|
24
|
+
private process;
|
|
25
|
+
private watchdogInterval;
|
|
26
|
+
private pid;
|
|
27
|
+
private startedAt;
|
|
28
|
+
private restartCount;
|
|
29
|
+
private lastWatchdogCheck;
|
|
30
|
+
private stopping;
|
|
31
|
+
private pidFile;
|
|
32
|
+
constructor(config: CaffeinateManagerConfig);
|
|
33
|
+
/**
|
|
34
|
+
* Start caffeinate and the watchdog.
|
|
35
|
+
* Only activates on macOS.
|
|
36
|
+
*/
|
|
37
|
+
start(): void;
|
|
38
|
+
/**
|
|
39
|
+
* Stop caffeinate and the watchdog cleanly.
|
|
40
|
+
*/
|
|
41
|
+
stop(): void;
|
|
42
|
+
getStatus(): CaffeinateStatus;
|
|
43
|
+
private spawnCaffeinate;
|
|
44
|
+
private killCaffeinate;
|
|
45
|
+
private watchdog;
|
|
46
|
+
private cleanupStale;
|
|
47
|
+
private writePidFile;
|
|
48
|
+
private removePidFile;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=CaffeinateManager.d.ts.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CaffeinateManager - Prevent macOS system sleep for Instar server lifetime.
|
|
3
|
+
*
|
|
4
|
+
* Maintains a `caffeinate -s` child process that prevents system sleep.
|
|
5
|
+
* A watchdog verifies it's alive every 30s and restarts if dead.
|
|
6
|
+
* PID is written to <stateDir>/caffeinate.pid for crash recovery.
|
|
7
|
+
*
|
|
8
|
+
* Only activates on macOS (process.platform === 'darwin').
|
|
9
|
+
* Uses EventEmitter pattern consistent with Instar conventions.
|
|
10
|
+
*/
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
import { spawn, execSync } from 'child_process';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
const WATCHDOG_INTERVAL_MS = 30_000; // 30 seconds
|
|
16
|
+
export class CaffeinateManager extends EventEmitter {
|
|
17
|
+
process = null;
|
|
18
|
+
watchdogInterval = null;
|
|
19
|
+
pid = null;
|
|
20
|
+
startedAt = null;
|
|
21
|
+
restartCount = 0;
|
|
22
|
+
lastWatchdogCheck = new Date().toISOString();
|
|
23
|
+
stopping = false;
|
|
24
|
+
pidFile;
|
|
25
|
+
constructor(config) {
|
|
26
|
+
super();
|
|
27
|
+
this.pidFile = path.join(config.stateDir, 'caffeinate.pid');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Start caffeinate and the watchdog.
|
|
31
|
+
* Only activates on macOS.
|
|
32
|
+
*/
|
|
33
|
+
start() {
|
|
34
|
+
if (this.watchdogInterval)
|
|
35
|
+
return;
|
|
36
|
+
if (process.platform !== 'darwin') {
|
|
37
|
+
console.log('[CaffeinateManager] Not macOS — skipping sleep prevention');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.stopping = false;
|
|
41
|
+
this.cleanupStale();
|
|
42
|
+
this.spawnCaffeinate();
|
|
43
|
+
this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS);
|
|
44
|
+
this.watchdogInterval.unref(); // Don't prevent process exit
|
|
45
|
+
console.log(`[CaffeinateManager] Started (watchdog: ${WATCHDOG_INTERVAL_MS / 1000}s)`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Stop caffeinate and the watchdog cleanly.
|
|
49
|
+
*/
|
|
50
|
+
stop() {
|
|
51
|
+
this.stopping = true;
|
|
52
|
+
if (this.watchdogInterval) {
|
|
53
|
+
clearInterval(this.watchdogInterval);
|
|
54
|
+
this.watchdogInterval = null;
|
|
55
|
+
}
|
|
56
|
+
this.killCaffeinate();
|
|
57
|
+
this.removePidFile();
|
|
58
|
+
console.log('[CaffeinateManager] Stopped');
|
|
59
|
+
}
|
|
60
|
+
getStatus() {
|
|
61
|
+
return {
|
|
62
|
+
running: this.process !== null && this.pid !== null,
|
|
63
|
+
pid: this.pid,
|
|
64
|
+
startedAt: this.startedAt,
|
|
65
|
+
restartCount: this.restartCount,
|
|
66
|
+
lastWatchdogCheck: this.lastWatchdogCheck,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
spawnCaffeinate() {
|
|
70
|
+
try {
|
|
71
|
+
const proc = spawn('caffeinate', ['-s'], {
|
|
72
|
+
detached: true,
|
|
73
|
+
stdio: 'ignore',
|
|
74
|
+
});
|
|
75
|
+
proc.unref();
|
|
76
|
+
this.process = proc;
|
|
77
|
+
this.pid = proc.pid ?? null;
|
|
78
|
+
this.startedAt = new Date().toISOString();
|
|
79
|
+
this.writePidFile();
|
|
80
|
+
proc.on('exit', (code, signal) => {
|
|
81
|
+
if (!this.stopping) {
|
|
82
|
+
console.warn(`[CaffeinateManager] caffeinate exited (code: ${code}, signal: ${signal})`);
|
|
83
|
+
this.emit('died', { code, signal });
|
|
84
|
+
}
|
|
85
|
+
this.process = null;
|
|
86
|
+
this.pid = null;
|
|
87
|
+
});
|
|
88
|
+
proc.on('error', (err) => {
|
|
89
|
+
console.error('[CaffeinateManager] caffeinate spawn error:', err.message);
|
|
90
|
+
this.process = null;
|
|
91
|
+
this.pid = null;
|
|
92
|
+
});
|
|
93
|
+
console.log(`[CaffeinateManager] caffeinate spawned (PID: ${this.pid})`);
|
|
94
|
+
this.emit('started', { pid: this.pid });
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.error('[CaffeinateManager] Failed to spawn caffeinate:', err);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
killCaffeinate() {
|
|
101
|
+
if (this.pid) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(this.pid, 'SIGTERM');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Already dead
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
this.process = null;
|
|
110
|
+
this.pid = null;
|
|
111
|
+
}
|
|
112
|
+
watchdog() {
|
|
113
|
+
this.lastWatchdogCheck = new Date().toISOString();
|
|
114
|
+
if (this.stopping)
|
|
115
|
+
return;
|
|
116
|
+
if (this.pid) {
|
|
117
|
+
try {
|
|
118
|
+
process.kill(this.pid, 0);
|
|
119
|
+
return; // Still alive
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
console.warn(`[CaffeinateManager] caffeinate PID ${this.pid} is dead`);
|
|
123
|
+
this.process = null;
|
|
124
|
+
this.pid = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
this.restartCount++;
|
|
128
|
+
console.log(`[CaffeinateManager] Restarting caffeinate (restart #${this.restartCount})`);
|
|
129
|
+
this.spawnCaffeinate();
|
|
130
|
+
this.emit('restarted', { restartCount: this.restartCount });
|
|
131
|
+
}
|
|
132
|
+
cleanupStale() {
|
|
133
|
+
try {
|
|
134
|
+
if (fs.existsSync(this.pidFile)) {
|
|
135
|
+
const stalePid = parseInt(fs.readFileSync(this.pidFile, 'utf-8').trim(), 10);
|
|
136
|
+
if (!isNaN(stalePid) && stalePid > 0) {
|
|
137
|
+
try {
|
|
138
|
+
const cmdline = execSync(`ps -p ${stalePid} -o comm= 2>/dev/null`, {
|
|
139
|
+
encoding: 'utf-8',
|
|
140
|
+
timeout: 3000,
|
|
141
|
+
}).trim();
|
|
142
|
+
if (cmdline.includes('caffeinate')) {
|
|
143
|
+
process.kill(stalePid, 'SIGTERM');
|
|
144
|
+
console.log(`[CaffeinateManager] Killed stale caffeinate (PID: ${stalePid})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Process doesn't exist
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.removePidFile();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// PID file doesn't exist or can't be read
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
writePidFile() {
|
|
159
|
+
if (!this.pid)
|
|
160
|
+
return;
|
|
161
|
+
try {
|
|
162
|
+
fs.mkdirSync(path.dirname(this.pidFile), { recursive: true });
|
|
163
|
+
fs.writeFileSync(this.pidFile, String(this.pid));
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
console.error('[CaffeinateManager] Failed to write PID file:', err);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
removePidFile() {
|
|
170
|
+
try {
|
|
171
|
+
if (fs.existsSync(this.pidFile)) {
|
|
172
|
+
fs.unlinkSync(this.pidFile);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Not critical
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=CaffeinateManager.js.map
|
|
@@ -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:
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
/**
|