instar 0.7.47 → 0.7.49
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 +40 -9
- package/dist/commands/server.js +0 -114
- package/dist/lifeline/ServerSupervisor.d.ts +0 -2
- package/dist/lifeline/ServerSupervisor.js +1 -8
- package/dist/lifeline/TelegramLifeline.d.ts +0 -5
- package/dist/lifeline/TelegramLifeline.js +16 -87
- package/dist/monitoring/HealthChecker.d.ts +0 -1
- package/dist/monitoring/HealthChecker.js +0 -22
- package/dist/scaffold/templates.js +8 -31
- package/dist/server/middleware.js +0 -5
- package/dist/server/routes.js +0 -9
- package/package.json +1 -1
- package/.vercel/README.txt +0 -11
- package/.vercel/project.json +0 -1
- package/dist/core/CaffeinateManager.d.ts +0 -50
- package/dist/core/CaffeinateManager.js +0 -180
- package/dist/monitoring/MemoryPressureMonitor.d.ts +0 -83
- package/dist/monitoring/MemoryPressureMonitor.js +0 -242
|
@@ -646,19 +646,50 @@ 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
|
+
Write `.claude/scripts/telegram-reply.sh`:
|
|
650
650
|
|
|
651
651
|
```bash
|
|
652
|
-
|
|
653
|
-
|
|
652
|
+
#!/bin/bash
|
|
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
|
|
654
688
|
|
|
655
|
-
|
|
656
|
-
|
|
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
|
|
689
|
+
echo "Sent $(echo "$MESSAGE" | wc -c | tr -d ' ') chars to topic $TOPIC_ID"
|
|
690
|
+
```
|
|
660
691
|
|
|
661
|
-
Then make it executable:
|
|
692
|
+
Replace `<PORT>` with the actual server port. Then make it executable:
|
|
662
693
|
|
|
663
694
|
```bash
|
|
664
695
|
chmod +x .claude/scripts/telegram-reply.sh
|
package/dist/commands/server.js
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from 'node:child_process';
|
|
11
11
|
import fs from 'node:fs';
|
|
12
|
-
import os from 'node:os';
|
|
13
12
|
import path from 'node:path';
|
|
14
13
|
import pc from 'picocolors';
|
|
15
14
|
import { loadConfig, ensureStateDir, detectTmuxPath } from '../core/Config.js';
|
|
@@ -36,24 +35,6 @@ import { QuotaTracker } from '../monitoring/QuotaTracker.js';
|
|
|
36
35
|
import { AccountSwitcher } from '../monitoring/AccountSwitcher.js';
|
|
37
36
|
import { QuotaNotifier } from '../monitoring/QuotaNotifier.js';
|
|
38
37
|
import { classifySessionDeath } from '../monitoring/QuotaExhaustionDetector.js';
|
|
39
|
-
import { installAutoStart } from './setup.js';
|
|
40
|
-
/**
|
|
41
|
-
* Check if autostart is installed for this project.
|
|
42
|
-
* Extracted from the CLI `autostart status` handler for programmatic use.
|
|
43
|
-
*/
|
|
44
|
-
function isAutostartInstalled(projectName) {
|
|
45
|
-
if (process.platform === 'darwin') {
|
|
46
|
-
const label = `ai.instar.${projectName}`;
|
|
47
|
-
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
48
|
-
return fs.existsSync(plistPath);
|
|
49
|
-
}
|
|
50
|
-
else if (process.platform === 'linux') {
|
|
51
|
-
const serviceName = `instar-${projectName}.service`;
|
|
52
|
-
const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', serviceName);
|
|
53
|
-
return fs.existsSync(servicePath);
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
38
|
/**
|
|
58
39
|
* Respawn a session for a topic, including thread history in the bootstrap.
|
|
59
40
|
* This prevents "thread drift" where respawned sessions lose context.
|
|
@@ -729,77 +710,6 @@ export async function startServer(options) {
|
|
|
729
710
|
...(config.evolution || {}),
|
|
730
711
|
});
|
|
731
712
|
console.log(pc.green(' Evolution system enabled'));
|
|
732
|
-
// Start MemoryPressureMonitor (platform-aware memory tracking)
|
|
733
|
-
const { MemoryPressureMonitor } = await import('../monitoring/MemoryPressureMonitor.js');
|
|
734
|
-
const memoryMonitor = new MemoryPressureMonitor({});
|
|
735
|
-
memoryMonitor.on('stateChange', ({ from, to, state: memState }) => {
|
|
736
|
-
// Gate scheduler spawning on memory pressure
|
|
737
|
-
if (scheduler && (to === 'elevated' || to === 'critical')) {
|
|
738
|
-
console.log(`[MemoryPressure] ${from} -> ${to} — scheduler should respect canSpawnSession()`);
|
|
739
|
-
}
|
|
740
|
-
// Alert via Telegram attention topic
|
|
741
|
-
if (telegram && to !== 'normal') {
|
|
742
|
-
const attentionTopicId = state.get('agent-attention-topic');
|
|
743
|
-
if (attentionTopicId) {
|
|
744
|
-
telegram.sendToTopic(attentionTopicId, `Memory ${to}: ${memState.pressurePercent.toFixed(1)}% used, ${memState.freeGB.toFixed(1)}GB free (trend: ${memState.trend})`).catch(() => { });
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
});
|
|
748
|
-
memoryMonitor.start();
|
|
749
|
-
// Wire memory gate into scheduler
|
|
750
|
-
if (scheduler) {
|
|
751
|
-
const originalCanRun = scheduler.canRunJob;
|
|
752
|
-
scheduler.canRunJob = (priority) => {
|
|
753
|
-
// Check memory first
|
|
754
|
-
const memCheck = memoryMonitor.canSpawnSession();
|
|
755
|
-
if (!memCheck.allowed) {
|
|
756
|
-
return false;
|
|
757
|
-
}
|
|
758
|
-
// Then check original gate (quota, etc.)
|
|
759
|
-
return originalCanRun(priority);
|
|
760
|
-
};
|
|
761
|
-
}
|
|
762
|
-
// Start CaffeinateManager (prevents macOS system sleep)
|
|
763
|
-
const { CaffeinateManager } = await import('../core/CaffeinateManager.js');
|
|
764
|
-
const caffeinateManager = new CaffeinateManager({ stateDir: config.stateDir });
|
|
765
|
-
caffeinateManager.start();
|
|
766
|
-
// Start SleepWakeDetector (re-validate sessions on wake)
|
|
767
|
-
const { SleepWakeDetector } = await import('../core/SleepWakeDetector.js');
|
|
768
|
-
const sleepWakeDetector = new SleepWakeDetector();
|
|
769
|
-
sleepWakeDetector.on('wake', async (event) => {
|
|
770
|
-
console.log(`[SleepWake] Wake detected after ~${event.sleepDurationSeconds}s sleep`);
|
|
771
|
-
// Re-validate tmux sessions
|
|
772
|
-
try {
|
|
773
|
-
const tmuxPath = detectTmuxPath();
|
|
774
|
-
if (tmuxPath) {
|
|
775
|
-
const { execFileSync } = await import('child_process');
|
|
776
|
-
const result = execFileSync(tmuxPath, ['list-sessions'], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
777
|
-
console.log(`[SleepWake] tmux sessions after wake: ${result.split('\n').length}`);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
catch {
|
|
781
|
-
console.warn('[SleepWake] tmux check failed after wake');
|
|
782
|
-
}
|
|
783
|
-
// Restart tunnel if configured
|
|
784
|
-
if (tunnel) {
|
|
785
|
-
try {
|
|
786
|
-
await tunnel.stop();
|
|
787
|
-
const tunnelUrl = await tunnel.start();
|
|
788
|
-
console.log(`[SleepWake] Tunnel restarted: ${tunnelUrl}`);
|
|
789
|
-
}
|
|
790
|
-
catch (err) {
|
|
791
|
-
console.error(`[SleepWake] Tunnel restart failed:`, err);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
// Notify via Telegram attention topic
|
|
795
|
-
if (telegram) {
|
|
796
|
-
const attentionTopicId = state.get('agent-attention-topic');
|
|
797
|
-
if (attentionTopicId) {
|
|
798
|
-
telegram.sendToTopic(attentionTopicId, `Wake detected after ~${event.sleepDurationSeconds}s sleep. Sessions re-validated.`).catch(() => { });
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
});
|
|
802
|
-
sleepWakeDetector.start();
|
|
803
713
|
const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, dispatches, updateChecker, autoUpdater, autoDispatcher, quotaTracker, publisher, viewer, tunnel, evolution });
|
|
804
714
|
await server.start();
|
|
805
715
|
// Start tunnel AFTER server is listening
|
|
@@ -813,33 +723,9 @@ export async function startServer(options) {
|
|
|
813
723
|
console.log(pc.yellow(` Server running locally without tunnel. Fix tunnel config and restart.`));
|
|
814
724
|
}
|
|
815
725
|
}
|
|
816
|
-
// Self-healing: ensure autostart is installed so the server always restarts
|
|
817
|
-
// This is a non-negotiable requirement — the user must always be able to reach their agent remotely.
|
|
818
|
-
// If autostart isn't installed, install it silently. The agent should never require human intervention
|
|
819
|
-
// to ensure its own resilience.
|
|
820
|
-
try {
|
|
821
|
-
const hasTelegram = !!telegram;
|
|
822
|
-
const autostartInstalled = isAutostartInstalled(config.projectName);
|
|
823
|
-
if (!autostartInstalled) {
|
|
824
|
-
const installed = installAutoStart(config.projectName, config.projectDir, hasTelegram);
|
|
825
|
-
if (installed) {
|
|
826
|
-
console.log(pc.green(` Auto-start self-healed: installed ${process.platform === 'darwin' ? 'LaunchAgent' : 'systemd service'}`));
|
|
827
|
-
}
|
|
828
|
-
else {
|
|
829
|
-
console.log(pc.yellow(` Auto-start not available on ${process.platform}`));
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
catch (err) {
|
|
834
|
-
// Non-critical — don't crash the server over autostart
|
|
835
|
-
console.error(` Auto-start check failed: ${err instanceof Error ? err.message : err}`);
|
|
836
|
-
}
|
|
837
726
|
// Graceful shutdown
|
|
838
727
|
const shutdown = async () => {
|
|
839
728
|
console.log('\nShutting down...');
|
|
840
|
-
memoryMonitor.stop();
|
|
841
|
-
caffeinateManager.stop();
|
|
842
|
-
sleepWakeDetector.stop();
|
|
843
729
|
autoUpdater.stop();
|
|
844
730
|
autoDispatcher?.stop();
|
|
845
731
|
if (tunnel)
|
|
@@ -22,8 +22,6 @@ 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;
|
|
27
25
|
constructor(options) {
|
|
28
26
|
super();
|
|
29
27
|
this.projectDir = options.projectDir;
|
|
@@ -97,7 +95,7 @@ export class ServerSupervisor extends EventEmitter {
|
|
|
97
95
|
return false;
|
|
98
96
|
try {
|
|
99
97
|
// Get the instar CLI path
|
|
100
|
-
const cliPath = new URL('
|
|
98
|
+
const cliPath = new URL('../../cli.js', import.meta.url).pathname;
|
|
101
99
|
// --no-telegram: lifeline owns the Telegram connection, server should not poll
|
|
102
100
|
const nodeCmd = ['node', cliPath, 'server', 'start', '--foreground', '--no-telegram']
|
|
103
101
|
.map(arg => `'${arg.replace(/'/g, "'\\''")}'`)
|
|
@@ -110,7 +108,6 @@ export class ServerSupervisor extends EventEmitter {
|
|
|
110
108
|
], { stdio: 'ignore' });
|
|
111
109
|
console.log(`[Supervisor] Server started in tmux session: ${this.serverSessionName}`);
|
|
112
110
|
this.isRunning = true;
|
|
113
|
-
this.spawnedAt = Date.now();
|
|
114
111
|
this.startHealthChecks();
|
|
115
112
|
return true;
|
|
116
113
|
}
|
|
@@ -136,10 +133,6 @@ export class ServerSupervisor extends EventEmitter {
|
|
|
136
133
|
if (this.healthCheckInterval)
|
|
137
134
|
return;
|
|
138
135
|
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
|
-
}
|
|
143
136
|
try {
|
|
144
137
|
const healthy = await this.checkHealth();
|
|
145
138
|
if (healthy) {
|
|
@@ -30,7 +30,6 @@ export declare class TelegramLifeline {
|
|
|
30
30
|
private stopHeartbeat;
|
|
31
31
|
private replayInterval;
|
|
32
32
|
private lifelineTopicId;
|
|
33
|
-
private lockPath;
|
|
34
33
|
constructor(projectDir?: string);
|
|
35
34
|
/**
|
|
36
35
|
* Start the lifeline — begins Telegram polling and server supervision.
|
|
@@ -45,10 +44,6 @@ export declare class TelegramLifeline {
|
|
|
45
44
|
private handleLifelineCommand;
|
|
46
45
|
private replayQueue;
|
|
47
46
|
private notifyServerDown;
|
|
48
|
-
/**
|
|
49
|
-
* Check if OS-level autostart is installed for this project.
|
|
50
|
-
*/
|
|
51
|
-
private isAutostartInstalled;
|
|
52
47
|
/**
|
|
53
48
|
* Ensure the Lifeline topic exists. Recreates if deleted.
|
|
54
49
|
*/
|
|
@@ -19,61 +19,12 @@
|
|
|
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';
|
|
23
22
|
import path from 'node:path';
|
|
24
23
|
import pc from 'picocolors';
|
|
25
24
|
import { loadConfig, ensureStateDir } from '../core/Config.js';
|
|
26
25
|
import { registerPort, unregisterPort, startHeartbeat } from '../core/PortRegistry.js';
|
|
27
|
-
import { installAutoStart } from '../commands/setup.js';
|
|
28
26
|
import { MessageQueue } from './MessageQueue.js';
|
|
29
27
|
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
|
-
}
|
|
77
28
|
export class TelegramLifeline {
|
|
78
29
|
config;
|
|
79
30
|
projectConfig;
|
|
@@ -86,7 +37,6 @@ export class TelegramLifeline {
|
|
|
86
37
|
stopHeartbeat = null;
|
|
87
38
|
replayInterval = null;
|
|
88
39
|
lifelineTopicId = null;
|
|
89
|
-
lockPath;
|
|
90
40
|
constructor(projectDir) {
|
|
91
41
|
this.projectConfig = loadConfig(projectDir);
|
|
92
42
|
ensureStateDir(this.projectConfig.stateDir);
|
|
@@ -98,7 +48,6 @@ export class TelegramLifeline {
|
|
|
98
48
|
this.config = telegramConfig.config;
|
|
99
49
|
this.queue = new MessageQueue(this.projectConfig.stateDir);
|
|
100
50
|
this.offsetPath = path.join(this.projectConfig.stateDir, 'lifeline-poll-offset.json');
|
|
101
|
-
this.lockPath = path.join(this.projectConfig.stateDir, 'lifeline.lock');
|
|
102
51
|
this.supervisor = new ServerSupervisor({
|
|
103
52
|
projectDir: this.projectConfig.projectDir,
|
|
104
53
|
projectName: this.projectConfig.projectName,
|
|
@@ -126,11 +75,6 @@ export class TelegramLifeline {
|
|
|
126
75
|
console.log(` Port: ${this.projectConfig.port}`);
|
|
127
76
|
console.log(` State: ${this.projectConfig.stateDir}`);
|
|
128
77
|
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
|
-
}
|
|
134
78
|
// Register in port registry (lifeline owns the port claim)
|
|
135
79
|
try {
|
|
136
80
|
registerPort(`${this.projectConfig.projectName}-lifeline`, this.projectConfig.port + 1000, // Lifeline uses port + 1000 to avoid conflict
|
|
@@ -168,19 +112,6 @@ export class TelegramLifeline {
|
|
|
168
112
|
setTimeout(() => this.replayQueue(), 5000); // Wait for server to fully start
|
|
169
113
|
}
|
|
170
114
|
}
|
|
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
|
-
}
|
|
184
115
|
// Graceful shutdown
|
|
185
116
|
const shutdown = async () => {
|
|
186
117
|
console.log('\nLifeline shutting down...');
|
|
@@ -192,7 +123,6 @@ export class TelegramLifeline {
|
|
|
192
123
|
if (this.stopHeartbeat)
|
|
193
124
|
this.stopHeartbeat();
|
|
194
125
|
unregisterPort(`${this.projectConfig.projectName}-lifeline`);
|
|
195
|
-
releaseLockFile(this.lockPath);
|
|
196
126
|
await this.supervisor.stop();
|
|
197
127
|
process.exit(0);
|
|
198
128
|
};
|
|
@@ -241,8 +171,23 @@ export class TelegramLifeline {
|
|
|
241
171
|
// Forward to server if healthy
|
|
242
172
|
if (this.supervisor.healthy) {
|
|
243
173
|
const forwarded = await this.forwardToServer(topicId, text, msg);
|
|
244
|
-
if (forwarded)
|
|
174
|
+
if (forwarded) {
|
|
175
|
+
// Delivery confirmation — user knows message reached the server
|
|
176
|
+
await this.sendToTopic(topicId, '✓ Delivered');
|
|
245
177
|
return;
|
|
178
|
+
}
|
|
179
|
+
// Server appears healthy but forward failed — queue with accurate message
|
|
180
|
+
this.queue.enqueue({
|
|
181
|
+
id: `tg-${msg.message_id}`,
|
|
182
|
+
topicId,
|
|
183
|
+
text,
|
|
184
|
+
fromUserId: msg.from.id,
|
|
185
|
+
fromUsername: msg.from.username,
|
|
186
|
+
fromFirstName: msg.from.first_name,
|
|
187
|
+
timestamp: new Date(msg.date * 1000).toISOString(),
|
|
188
|
+
});
|
|
189
|
+
await this.sendToTopic(topicId, `Server is restarting. Your message has been queued (${this.queue.length} in queue). It will be delivered when the server recovers.`);
|
|
190
|
+
return;
|
|
246
191
|
}
|
|
247
192
|
// Server is down — queue the message
|
|
248
193
|
this.queue.enqueue({
|
|
@@ -381,22 +326,6 @@ export class TelegramLifeline {
|
|
|
381
326
|
await this.sendToTopic(topicId, `Server went down: ${reason}\n\nYour messages will be queued until recovery. Use /lifeline status to check.`).catch(() => { });
|
|
382
327
|
}
|
|
383
328
|
// ── Lifeline Topic ──────────────────────────────────────────
|
|
384
|
-
/**
|
|
385
|
-
* Check if OS-level autostart is installed for this project.
|
|
386
|
-
*/
|
|
387
|
-
isAutostartInstalled() {
|
|
388
|
-
if (process.platform === 'darwin') {
|
|
389
|
-
const label = `ai.instar.${this.projectConfig.projectName}`;
|
|
390
|
-
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
391
|
-
return fs.existsSync(plistPath);
|
|
392
|
-
}
|
|
393
|
-
else if (process.platform === 'linux') {
|
|
394
|
-
const serviceName = `instar-${this.projectConfig.projectName}.service`;
|
|
395
|
-
const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', serviceName);
|
|
396
|
-
return fs.existsSync(servicePath);
|
|
397
|
-
}
|
|
398
|
-
return false;
|
|
399
|
-
}
|
|
400
329
|
/**
|
|
401
330
|
* Ensure the Lifeline topic exists. Recreates if deleted.
|
|
402
331
|
*/
|
|
@@ -26,7 +26,6 @@ export class HealthChecker {
|
|
|
26
26
|
components.tmux = this.checkTmux();
|
|
27
27
|
components.sessions = this.checkSessions();
|
|
28
28
|
components.stateDir = this.checkStateDir();
|
|
29
|
-
components.memory = this.checkMemory();
|
|
30
29
|
if (this.scheduler) {
|
|
31
30
|
components.scheduler = this.checkScheduler();
|
|
32
31
|
}
|
|
@@ -136,27 +135,6 @@ export class HealthChecker {
|
|
|
136
135
|
lastCheck: now,
|
|
137
136
|
};
|
|
138
137
|
}
|
|
139
|
-
checkMemory() {
|
|
140
|
-
const now = new Date().toISOString();
|
|
141
|
-
try {
|
|
142
|
-
const os = require('node:os');
|
|
143
|
-
const totalBytes = os.totalmem();
|
|
144
|
-
const freeBytes = os.freemem();
|
|
145
|
-
const totalGB = totalBytes / (1024 ** 3);
|
|
146
|
-
const freeGB = freeBytes / (1024 ** 3);
|
|
147
|
-
const usedPercent = ((totalBytes - freeBytes) / totalBytes) * 100;
|
|
148
|
-
if (usedPercent >= 90) {
|
|
149
|
-
return { status: 'unhealthy', message: `Memory critical: ${usedPercent.toFixed(0)}% used (${freeGB.toFixed(1)}GB free)`, lastCheck: now };
|
|
150
|
-
}
|
|
151
|
-
if (usedPercent >= 75) {
|
|
152
|
-
return { status: 'degraded', message: `Memory elevated: ${usedPercent.toFixed(0)}% used (${freeGB.toFixed(1)}GB free)`, lastCheck: now };
|
|
153
|
-
}
|
|
154
|
-
return { status: 'healthy', message: `${usedPercent.toFixed(0)}% used (${freeGB.toFixed(1)}GB free / ${totalGB.toFixed(0)}GB total)`, lastCheck: now };
|
|
155
|
-
}
|
|
156
|
-
catch (err) {
|
|
157
|
-
return { status: 'degraded', message: `Memory check failed: ${err instanceof Error ? err.message : String(err)}`, lastCheck: now };
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
138
|
checkStateDir() {
|
|
161
139
|
const now = new Date().toISOString();
|
|
162
140
|
try {
|
|
@@ -27,13 +27,10 @@ ${identity.personality}
|
|
|
27
27
|
1. **Build, don't describe.** When asked to do something, do it. Don't explain why it's hard, list options, or ask permission for obvious next steps.
|
|
28
28
|
2. **Remember and grow.** Write to MEMORY.md when I learn something. The next session should benefit from what this one learned.
|
|
29
29
|
3. **Own the outcome.** Implementation isn't done when code compiles. It's done when the feature is running, tested, and verified.
|
|
30
|
-
4. **Be honest about
|
|
30
|
+
4. **Be honest about limits.** If I can't do something, I say so clearly. I don't fabricate experience or claim capabilities I don't have.
|
|
31
31
|
5. **Infrastructure over improvisation.** If I solve a problem twice, I make it a script, a job, or a documented pattern.
|
|
32
|
-
6. **Use my own tools first.** I have a built-in feedback system, update checker, dispatch system,
|
|
32
|
+
6. **Use my own tools first.** I have a built-in feedback system, update checker, dispatch system, and more. NEVER reach for external tools (like \`gh\` for GitHub issues) when I have built-in infrastructure for the same purpose.
|
|
33
33
|
7. **Registry first, explore second.** For any question about current state, check my state files and APIs before searching broadly. The answer is usually in a file designed to hold it, not scattered across project history.
|
|
34
|
-
8. **Be proactive, not reactive.** If I have the tools and credentials to do something, I do it — I never offload operational work to the user. Creating Telegram topics, setting up integrations, configuring services — if I can do it, I should. The user should never have to do something I'm capable of doing.
|
|
35
|
-
9. **Share artifacts, not just summaries.** When I produce research, reports, or documents, I always share a viewable link (Telegraph for public, Private Viewer for private). Research without an accessible artifact link is incomplete delivery.
|
|
36
|
-
10. **Handle browser obstacles gracefully.** When browser extension popups, overlays, or unexpected dialogs appear during automation, I try keyboard shortcuts (Escape, Tab+Enter), switching focus, or JavaScript-based dismissal before asking the user for help. Browser obstacles are my problem to solve.
|
|
37
34
|
|
|
38
35
|
## Who I Work With
|
|
39
36
|
|
|
@@ -250,14 +247,10 @@ This routes feedback to the Instar maintainers automatically. Valid types: \`bug
|
|
|
250
247
|
- Check: \`curl -H "Authorization: Bearer $AUTH" http://localhost:${port}/ci\`
|
|
251
248
|
- **When to use**: Before deploying, after pushing, or during health checks — verify CI is green.
|
|
252
249
|
|
|
253
|
-
**Telegram** —
|
|
254
|
-
- Search
|
|
250
|
+
**Telegram Search** — Search across message history when Telegram is configured.
|
|
251
|
+
- Search: \`curl -H "Authorization: Bearer $AUTH" "http://localhost:${port}/telegram/search?q=QUERY"\`
|
|
255
252
|
- Topic messages: \`curl -H "Authorization: Bearer $AUTH" http://localhost:${port}/telegram/topics/TOPIC_ID/messages\`
|
|
256
|
-
- List topics: \`curl -H "Authorization: Bearer $AUTH" http://localhost:${port}/telegram/topics\`
|
|
257
|
-
- **Create topic**: \`curl -X POST -H "Authorization: Bearer $AUTH" http://localhost:${port}/telegram/topics -H 'Content-Type: application/json' -d '{"name":"Project Name"}'\`
|
|
258
|
-
- Reply to topic: \`curl -X POST -H "Authorization: Bearer $AUTH" http://localhost:${port}/telegram/reply/TOPIC_ID -H 'Content-Type: application/json' -d '{"text":"message"}'\`
|
|
259
253
|
- Log stats: \`curl -H "Authorization: Bearer $AUTH" http://localhost:${port}/telegram/log-stats\`
|
|
260
|
-
- **Proactive topic creation**: When a new project or workstream is discussed, proactively create a dedicated Telegram topic for it rather than continuing in the general topic. Organization keeps conversations findable.
|
|
261
254
|
|
|
262
255
|
**Quota Tracking** — Monitor Claude API usage when configured.
|
|
263
256
|
- Check: \`curl -H "Authorization: Bearer $AUTH" http://localhost:${port}/quota\`
|
|
@@ -323,18 +316,6 @@ When fetching content from ANY URL, always try the most efficient method first:
|
|
|
323
316
|
|
|
324
317
|
**The key rule**: Before using WebFetch on any URL, try \`python3 .claude/scripts/smart-fetch.py URL --auto --raw\` first. Many documentation sites now serve llms.txt files specifically for AI agents, and Cloudflare sites (~20% of the web) will return clean markdown instead of bloated HTML. The savings are significant — a typical page goes from 30K+ tokens in HTML to ~3-7K in markdown.
|
|
325
318
|
|
|
326
|
-
### Browser Automation — Handling Obstacles
|
|
327
|
-
|
|
328
|
-
When using browser automation (Playwright MCP or Claude-in-Chrome), browser extension popups (password managers, ad blockers, cookie consent) can capture focus and block your actions. Strategies for handling these:
|
|
329
|
-
|
|
330
|
-
1. **Escape key** — Press Escape to dismiss most popups and overlays
|
|
331
|
-
2. **Tab + Enter** — Tab to a dismiss/close button and press Enter
|
|
332
|
-
3. **JavaScript dismissal** — Run \`document.querySelector('[class*="close"], [class*="dismiss"], [aria-label="Close"]')?.click()\` to find and click close buttons
|
|
333
|
-
4. **Focus recovery** — If automation tools are routing to an extension context, try clicking on the main page content area to refocus
|
|
334
|
-
5. **Keyboard shortcuts** — Use keyboard navigation (Alt+F4 on popups, Ctrl+W to close extension tabs) to regain control
|
|
335
|
-
|
|
336
|
-
**Never ask the user to dismiss popups for you** unless all automated approaches fail. Browser obstacles are your problem to solve.
|
|
337
|
-
|
|
338
319
|
### Self-Discovery (Know Before You Claim)
|
|
339
320
|
|
|
340
321
|
Before EVER saying "I don't have", "I can't", or "this isn't available" — check what actually exists:
|
|
@@ -432,20 +413,16 @@ I run with \`--dangerously-skip-permissions\` — meaning I have full access to
|
|
|
432
413
|
|
|
433
414
|
**"Search Blind"** — Don't grep the entire project to answer a question that a state file or API endpoint could answer. Check \`.instar/\` state files, \`MEMORY.md\`, and \`/capabilities\` first. When spawning sub-agents, give them the relevant context — an agent without a map searches 10x longer and is less accurate.
|
|
434
415
|
|
|
435
|
-
**"Unfortunately I Can't"** — This phrase should NEVER appear in my responses
|
|
436
|
-
|
|
437
|
-
**"Offload to User"** — If I have the tools and credentials to do something, I do it myself. Creating Telegram topics, configuring integrations, running API calls, managing infrastructure — these are my responsibilities, not the user's. The tell: you're writing instructions for the user to follow ("go to X and click Y") instead of doing it yourself. If you can do it, do it.
|
|
416
|
+
**"Unfortunately I Can't"** — This phrase should NEVER appear in my responses. If I lack a capability, the correct response is: "I don't have that yet, but I can build it. Want me to set it up?" My job is to grow to meet the user's needs, not to list my limitations. Every "I can't" is an opportunity to evolve. Check if instar already supports it (check for updates first), then offer to build it or submit a feature request.
|
|
438
417
|
|
|
439
418
|
### Feature Proactivity — Guide, Don't Wait
|
|
440
419
|
|
|
441
420
|
**I am the user's guide to this system.** Most users will never run a command, read API docs, or explore endpoints. They talk to me. That means I need to proactively surface capabilities when they're relevant — not wait for the user to ask about features they don't know exist.
|
|
442
421
|
|
|
443
|
-
**Context-triggered
|
|
444
|
-
- User mentions a **document, file, or report** → Use the private viewer to render it as a beautiful HTML page they can view on any device. If a tunnel is running, they can access it from their phone.
|
|
445
|
-
- User asks to **share something publicly** → Use Telegraph publishing. Warn them it's public.
|
|
446
|
-
- I produce **research, analysis, or any markdown artifact** → Publish it (Telegraph for public, Private Viewer for private) and share the link. Research without an accessible link is incomplete delivery.
|
|
422
|
+
**Context-triggered suggestions:**
|
|
423
|
+
- User mentions a **document, file, or report** → Use the private viewer to render it as a beautiful HTML page they can view on any device. If a tunnel is running, they can access it from their phone.
|
|
424
|
+
- User asks to **share something publicly** → Use Telegraph publishing. Warn them it's public.
|
|
447
425
|
- User mentions **someone by name** → Check relationships. If they're tracked, use context to personalize. If not, offer to start tracking.
|
|
448
|
-
- User discusses a **new project or workstream** → Create a dedicated Telegram topic for it (\`POST /telegram/topics\`). Project conversations deserve their own space.
|
|
449
426
|
- User has a **recurring task** → Suggest creating a job for it. "I can run this automatically every day/hour/week."
|
|
450
427
|
- User describes a **workflow they repeat** → Suggest creating a skill. "I can turn this into a slash command."
|
|
451
428
|
- User is **debugging CI or deployment** → Use the CI health endpoint to check GitHub Actions status.
|
|
@@ -32,11 +32,6 @@ export function authMiddleware(authToken) {
|
|
|
32
32
|
next();
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
-
// Internal endpoints are localhost-only (server binds 127.0.0.1) — skip auth
|
|
36
|
-
if (req.path.startsWith('/internal/')) {
|
|
37
|
-
next();
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
35
|
// View routes support signed URLs for browser access (see ?sig= below)
|
|
41
36
|
if (req.path.startsWith('/view/') && req.method === 'GET') {
|
|
42
37
|
const sig = typeof req.query.sig === 'string' ? req.query.sig : null;
|
package/dist/server/routes.js
CHANGED
|
@@ -63,15 +63,6 @@ export function createRoutes(ctx) {
|
|
|
63
63
|
heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
|
|
64
64
|
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
|
|
65
65
|
};
|
|
66
|
-
// System-wide memory state
|
|
67
|
-
const os = require('node:os');
|
|
68
|
-
const totalMem = os.totalmem();
|
|
69
|
-
const freeMem = os.freemem();
|
|
70
|
-
base.systemMemory = {
|
|
71
|
-
totalGB: Math.round(totalMem / (1024 ** 3) * 10) / 10,
|
|
72
|
-
freeGB: Math.round(freeMem / (1024 ** 3) * 10) / 10,
|
|
73
|
-
usedPercent: Math.round(((totalMem - freeMem) / totalMem) * 1000) / 10,
|
|
74
|
-
};
|
|
75
66
|
// Job health summary
|
|
76
67
|
if (ctx.scheduler) {
|
|
77
68
|
const jobs = ctx.scheduler.getJobs();
|
package/package.json
CHANGED
package/.vercel/README.txt
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
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.
|
package/.vercel/project.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"projectId":"prj_evM5LcItYL3IAmw8zNvEPGrHeaya","orgId":"team_dHctwIDcV3X9ydapQlCPHFGI","projectName":"claude-agent-kit"}
|
|
@@ -1,50 +0,0 @@
|
|
|
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
|
|
@@ -1,180 +0,0 @@
|
|
|
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
|
|
@@ -1,83 +0,0 @@
|
|
|
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
|
|
@@ -1,242 +0,0 @@
|
|
|
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
|
-
import { execSync } from 'node:child_process';
|
|
17
|
-
import * as fs from 'node:fs';
|
|
18
|
-
const DEFAULT_THRESHOLDS = {
|
|
19
|
-
warning: 60,
|
|
20
|
-
elevated: 75,
|
|
21
|
-
critical: 90,
|
|
22
|
-
};
|
|
23
|
-
const RING_BUFFER_SIZE = 20;
|
|
24
|
-
const TREND_WINDOW = 6;
|
|
25
|
-
const PAGE_SIZE_BYTES = 16384; // macOS Apple Silicon
|
|
26
|
-
// Adaptive intervals
|
|
27
|
-
const INTERVALS = {
|
|
28
|
-
normal: 30_000,
|
|
29
|
-
warning: 15_000,
|
|
30
|
-
elevated: 10_000,
|
|
31
|
-
critical: 5_000,
|
|
32
|
-
};
|
|
33
|
-
export class MemoryPressureMonitor extends EventEmitter {
|
|
34
|
-
timeout = null;
|
|
35
|
-
currentState = 'normal';
|
|
36
|
-
stateChangedAt = new Date().toISOString();
|
|
37
|
-
lastChecked = new Date().toISOString();
|
|
38
|
-
lastPressurePercent = 0;
|
|
39
|
-
lastFreeGB = 0;
|
|
40
|
-
lastTotalGB = 0;
|
|
41
|
-
ringBuffer = [];
|
|
42
|
-
currentTrend = 'stable';
|
|
43
|
-
currentRatePerMin = 0;
|
|
44
|
-
thresholds;
|
|
45
|
-
baseIntervalMs;
|
|
46
|
-
constructor(config = {}) {
|
|
47
|
-
super();
|
|
48
|
-
this.thresholds = {
|
|
49
|
-
...DEFAULT_THRESHOLDS,
|
|
50
|
-
...config.thresholds,
|
|
51
|
-
};
|
|
52
|
-
this.baseIntervalMs = config.checkIntervalMs ?? 30_000;
|
|
53
|
-
}
|
|
54
|
-
start() {
|
|
55
|
-
if (this.timeout)
|
|
56
|
-
return;
|
|
57
|
-
this.check();
|
|
58
|
-
this.scheduleNext();
|
|
59
|
-
console.log(`[MemoryPressureMonitor] Started (platform: ${process.platform}, thresholds: ${JSON.stringify(this.thresholds)})`);
|
|
60
|
-
}
|
|
61
|
-
stop() {
|
|
62
|
-
if (this.timeout) {
|
|
63
|
-
clearTimeout(this.timeout);
|
|
64
|
-
this.timeout = null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
getState() {
|
|
68
|
-
return {
|
|
69
|
-
pressurePercent: this.lastPressurePercent,
|
|
70
|
-
freeGB: this.lastFreeGB,
|
|
71
|
-
totalGB: this.lastTotalGB,
|
|
72
|
-
state: this.currentState,
|
|
73
|
-
trend: this.currentTrend,
|
|
74
|
-
ratePerMin: this.currentRatePerMin,
|
|
75
|
-
lastChecked: this.lastChecked,
|
|
76
|
-
stateChangedAt: this.stateChangedAt,
|
|
77
|
-
platform: process.platform,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Can a new session be spawned?
|
|
82
|
-
*/
|
|
83
|
-
canSpawnSession() {
|
|
84
|
-
switch (this.currentState) {
|
|
85
|
-
case 'normal':
|
|
86
|
-
case 'warning':
|
|
87
|
-
return { allowed: true };
|
|
88
|
-
case 'elevated':
|
|
89
|
-
return {
|
|
90
|
-
allowed: false,
|
|
91
|
-
reason: `Memory pressure elevated (${this.lastPressurePercent.toFixed(1)}%) — session spawn blocked`,
|
|
92
|
-
};
|
|
93
|
-
case 'critical':
|
|
94
|
-
return {
|
|
95
|
-
allowed: false,
|
|
96
|
-
reason: `Memory pressure critical (${this.lastPressurePercent.toFixed(1)}%) — all spawns blocked`,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
scheduleNext() {
|
|
101
|
-
const intervalMs = INTERVALS[this.currentState] || this.baseIntervalMs;
|
|
102
|
-
this.timeout = setTimeout(() => {
|
|
103
|
-
this.check();
|
|
104
|
-
this.scheduleNext();
|
|
105
|
-
}, intervalMs);
|
|
106
|
-
this.timeout.unref(); // Don't prevent process exit
|
|
107
|
-
}
|
|
108
|
-
check() {
|
|
109
|
-
try {
|
|
110
|
-
const { pressurePercent, freeGB, totalGB } = this.readSystemMemory();
|
|
111
|
-
this.lastPressurePercent = pressurePercent;
|
|
112
|
-
this.lastFreeGB = freeGB;
|
|
113
|
-
this.lastTotalGB = totalGB;
|
|
114
|
-
this.lastChecked = new Date().toISOString();
|
|
115
|
-
// Ring buffer
|
|
116
|
-
this.ringBuffer.push({ timestamp: Date.now(), pressurePercent });
|
|
117
|
-
if (this.ringBuffer.length > RING_BUFFER_SIZE) {
|
|
118
|
-
this.ringBuffer.shift();
|
|
119
|
-
}
|
|
120
|
-
// Trend
|
|
121
|
-
const { trend, ratePerMin } = this.detectTrend();
|
|
122
|
-
this.currentTrend = trend;
|
|
123
|
-
this.currentRatePerMin = ratePerMin;
|
|
124
|
-
const newState = this.classifyState(pressurePercent);
|
|
125
|
-
if (newState !== this.currentState) {
|
|
126
|
-
const from = this.currentState;
|
|
127
|
-
this.currentState = newState;
|
|
128
|
-
this.stateChangedAt = new Date().toISOString();
|
|
129
|
-
console.log(`[MemoryPressureMonitor] ${from} -> ${newState} (${pressurePercent.toFixed(1)}%, ${freeGB.toFixed(1)}GB free, trend: ${trend})`);
|
|
130
|
-
this.emit('stateChange', { from, to: newState, state: this.getState() });
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
console.error('[MemoryPressureMonitor] Check failed:', error);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
classifyState(pressurePercent) {
|
|
138
|
-
if (pressurePercent >= this.thresholds.critical)
|
|
139
|
-
return 'critical';
|
|
140
|
-
if (pressurePercent >= this.thresholds.elevated)
|
|
141
|
-
return 'elevated';
|
|
142
|
-
if (pressurePercent >= this.thresholds.warning)
|
|
143
|
-
return 'warning';
|
|
144
|
-
return 'normal';
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Read system memory — platform-aware.
|
|
148
|
-
*/
|
|
149
|
-
readSystemMemory() {
|
|
150
|
-
if (process.platform === 'darwin') {
|
|
151
|
-
return this.parseVmStat();
|
|
152
|
-
}
|
|
153
|
-
else if (process.platform === 'linux') {
|
|
154
|
-
return this.parseProcMeminfo();
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
// Fallback: use Node's process.memoryUsage (very rough)
|
|
158
|
-
const mem = process.memoryUsage();
|
|
159
|
-
const totalGB = require('os').totalmem() / (1024 ** 3);
|
|
160
|
-
const usedGB = mem.rss / (1024 ** 3);
|
|
161
|
-
return {
|
|
162
|
-
pressurePercent: (usedGB / totalGB) * 100,
|
|
163
|
-
freeGB: totalGB - usedGB,
|
|
164
|
-
totalGB,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* macOS: parse vm_stat
|
|
170
|
-
*/
|
|
171
|
-
parseVmStat() {
|
|
172
|
-
const output = execSync('vm_stat', { encoding: 'utf-8', timeout: 5000 });
|
|
173
|
-
const pageSizeMatch = output.match(/page size of (\d+) bytes/);
|
|
174
|
-
const pageSize = pageSizeMatch ? parseInt(pageSizeMatch[1], 10) : PAGE_SIZE_BYTES;
|
|
175
|
-
const parsePages = (label) => {
|
|
176
|
-
const match = output.match(new RegExp(`${label}:\\s+(\\d+)`));
|
|
177
|
-
return match ? parseInt(match[1], 10) : 0;
|
|
178
|
-
};
|
|
179
|
-
const freePages = parsePages('Pages free');
|
|
180
|
-
const activePages = parsePages('Pages active');
|
|
181
|
-
const inactivePages = parsePages('Pages inactive');
|
|
182
|
-
const wiredPages = parsePages('Pages wired down');
|
|
183
|
-
const compressorPages = parsePages('Pages occupied by compressor');
|
|
184
|
-
const purgeablePages = parsePages('Pages purgeable');
|
|
185
|
-
const totalPages = freePages + activePages + inactivePages + wiredPages + compressorPages;
|
|
186
|
-
const totalBytes = totalPages * pageSize;
|
|
187
|
-
const totalGB = totalBytes / (1024 ** 3);
|
|
188
|
-
const availablePages = freePages + inactivePages + purgeablePages;
|
|
189
|
-
const availableBytes = availablePages * pageSize;
|
|
190
|
-
const freeGB = availableBytes / (1024 ** 3);
|
|
191
|
-
const usedPages = totalPages - availablePages;
|
|
192
|
-
const pressurePercent = totalPages > 0 ? (usedPages / totalPages) * 100 : 0;
|
|
193
|
-
return { pressurePercent, freeGB, totalGB };
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* Linux: parse /proc/meminfo
|
|
197
|
-
*/
|
|
198
|
-
parseProcMeminfo() {
|
|
199
|
-
const content = fs.readFileSync('/proc/meminfo', 'utf-8');
|
|
200
|
-
const parseKB = (key) => {
|
|
201
|
-
const match = content.match(new RegExp(`${key}:\\s+(\\d+)`));
|
|
202
|
-
return match ? parseInt(match[1], 10) : 0;
|
|
203
|
-
};
|
|
204
|
-
const totalKB = parseKB('MemTotal');
|
|
205
|
-
const availableKB = parseKB('MemAvailable') || (parseKB('MemFree') + parseKB('Buffers') + parseKB('Cached'));
|
|
206
|
-
const totalGB = totalKB / (1024 * 1024);
|
|
207
|
-
const freeGB = availableKB / (1024 * 1024);
|
|
208
|
-
const pressurePercent = totalKB > 0 ? ((totalKB - availableKB) / totalKB) * 100 : 0;
|
|
209
|
-
return { pressurePercent, freeGB, totalGB };
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* Linear regression over recent readings.
|
|
213
|
-
*/
|
|
214
|
-
detectTrend() {
|
|
215
|
-
if (this.ringBuffer.length < 3) {
|
|
216
|
-
return { trend: 'stable', ratePerMin: 0 };
|
|
217
|
-
}
|
|
218
|
-
const readings = this.ringBuffer.slice(-TREND_WINDOW);
|
|
219
|
-
const n = readings.length;
|
|
220
|
-
const firstTs = readings[0].timestamp;
|
|
221
|
-
const xs = readings.map(r => (r.timestamp - firstTs) / 1000);
|
|
222
|
-
const ys = readings.map(r => r.pressurePercent);
|
|
223
|
-
const sumX = xs.reduce((a, b) => a + b, 0);
|
|
224
|
-
const sumY = ys.reduce((a, b) => a + b, 0);
|
|
225
|
-
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0);
|
|
226
|
-
const sumX2 = xs.reduce((a, x) => a + x * x, 0);
|
|
227
|
-
const denom = n * sumX2 - sumX * sumX;
|
|
228
|
-
if (denom === 0)
|
|
229
|
-
return { trend: 'stable', ratePerMin: 0 };
|
|
230
|
-
const slope = (n * sumXY - sumX * sumY) / denom;
|
|
231
|
-
const ratePerMin = slope * 60;
|
|
232
|
-
let trend;
|
|
233
|
-
if (ratePerMin > 0.5)
|
|
234
|
-
trend = 'rising';
|
|
235
|
-
else if (ratePerMin < -0.5)
|
|
236
|
-
trend = 'falling';
|
|
237
|
-
else
|
|
238
|
-
trend = 'stable';
|
|
239
|
-
return { trend, ratePerMin };
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
//# sourceMappingURL=MemoryPressureMonitor.js.map
|