instar 0.1.0
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/settings.local.json +7 -0
- package/.claude/skills/setup-wizard/skill.md +343 -0
- package/.github/workflows/ci.yml +78 -0
- package/CLAUDE.md +82 -0
- package/README.md +194 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +141 -0
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +568 -0
- package/dist/commands/job.d.ts +20 -0
- package/dist/commands/job.js +84 -0
- package/dist/commands/server.d.ts +19 -0
- package/dist/commands/server.js +273 -0
- package/dist/commands/setup.d.ts +24 -0
- package/dist/commands/setup.js +865 -0
- package/dist/commands/status.d.ts +11 -0
- package/dist/commands/status.js +114 -0
- package/dist/commands/user.d.ts +17 -0
- package/dist/commands/user.js +53 -0
- package/dist/core/Config.d.ts +16 -0
- package/dist/core/Config.js +144 -0
- package/dist/core/Prerequisites.d.ts +28 -0
- package/dist/core/Prerequisites.js +159 -0
- package/dist/core/RelationshipManager.d.ts +73 -0
- package/dist/core/RelationshipManager.js +318 -0
- package/dist/core/SessionManager.d.ts +89 -0
- package/dist/core/SessionManager.js +326 -0
- package/dist/core/StateManager.d.ts +28 -0
- package/dist/core/StateManager.js +96 -0
- package/dist/core/types.d.ts +279 -0
- package/dist/core/types.js +8 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +23 -0
- package/dist/messaging/TelegramAdapter.d.ts +73 -0
- package/dist/messaging/TelegramAdapter.js +288 -0
- package/dist/monitoring/HealthChecker.d.ts +38 -0
- package/dist/monitoring/HealthChecker.js +148 -0
- package/dist/scaffold/bootstrap.d.ts +21 -0
- package/dist/scaffold/bootstrap.js +110 -0
- package/dist/scaffold/templates.d.ts +34 -0
- package/dist/scaffold/templates.js +187 -0
- package/dist/scheduler/JobLoader.d.ts +18 -0
- package/dist/scheduler/JobLoader.js +70 -0
- package/dist/scheduler/JobScheduler.d.ts +111 -0
- package/dist/scheduler/JobScheduler.js +402 -0
- package/dist/server/AgentServer.d.ts +40 -0
- package/dist/server/AgentServer.js +73 -0
- package/dist/server/middleware.d.ts +12 -0
- package/dist/server/middleware.js +50 -0
- package/dist/server/routes.d.ts +25 -0
- package/dist/server/routes.js +224 -0
- package/dist/users/UserManager.d.ts +45 -0
- package/dist/users/UserManager.js +113 -0
- package/docs/dawn-audit-report.md +412 -0
- package/docs/positioning-vs-openclaw.md +246 -0
- package/package.json +52 -0
- package/src/cli.ts +169 -0
- package/src/commands/init.ts +654 -0
- package/src/commands/job.ts +110 -0
- package/src/commands/server.ts +325 -0
- package/src/commands/setup.ts +958 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/user.ts +71 -0
- package/src/core/Config.ts +161 -0
- package/src/core/Prerequisites.ts +187 -0
- package/src/core/RelationshipManager.ts +366 -0
- package/src/core/SessionManager.ts +385 -0
- package/src/core/StateManager.ts +121 -0
- package/src/core/types.ts +320 -0
- package/src/index.ts +58 -0
- package/src/messaging/TelegramAdapter.ts +365 -0
- package/src/monitoring/HealthChecker.ts +172 -0
- package/src/scaffold/bootstrap.ts +122 -0
- package/src/scaffold/templates.ts +204 -0
- package/src/scheduler/JobLoader.ts +85 -0
- package/src/scheduler/JobScheduler.ts +476 -0
- package/src/server/AgentServer.ts +93 -0
- package/src/server/middleware.ts +58 -0
- package/src/server/routes.ts +278 -0
- package/src/templates/default-jobs.json +47 -0
- package/src/templates/hooks/compaction-recovery.sh +23 -0
- package/src/templates/hooks/dangerous-command-guard.sh +35 -0
- package/src/templates/hooks/grounding-before-messaging.sh +22 -0
- package/src/templates/hooks/session-start.sh +37 -0
- package/src/templates/hooks/settings-template.json +45 -0
- package/src/templates/scripts/health-watchdog.sh +63 -0
- package/src/templates/scripts/telegram-reply.sh +54 -0
- package/src/users/UserManager.ts +129 -0
- package/tests/e2e/lifecycle.test.ts +376 -0
- package/tests/fixtures/test-repo/CLAUDE.md +3 -0
- package/tests/fixtures/test-repo/README.md +1 -0
- package/tests/helpers/setup.ts +209 -0
- package/tests/integration/fresh-install.test.ts +218 -0
- package/tests/integration/scheduler-basic.test.ts +109 -0
- package/tests/integration/server-full.test.ts +284 -0
- package/tests/integration/session-lifecycle.test.ts +181 -0
- package/tests/unit/Config.test.ts +22 -0
- package/tests/unit/HealthChecker.test.ts +168 -0
- package/tests/unit/JobLoader.test.ts +151 -0
- package/tests/unit/JobScheduler.test.ts +267 -0
- package/tests/unit/Prerequisites.test.ts +59 -0
- package/tests/unit/RelationshipManager.test.ts +345 -0
- package/tests/unit/StateManager.test.ts +143 -0
- package/tests/unit/TelegramAdapter.test.ts +165 -0
- package/tests/unit/UserManager.test.ts +131 -0
- package/tests/unit/bootstrap.test.ts +28 -0
- package/tests/unit/commands.test.ts +138 -0
- package/tests/unit/middleware.test.ts +92 -0
- package/tests/unit/relationship-routes.test.ts +131 -0
- package/tests/unit/scaffold-templates.test.ts +132 -0
- package/tests/unit/server.test.ts +163 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
- package/vitest.e2e.config.ts +9 -0
- package/vitest.integration.config.ts +9 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Manager — spawn and monitor Claude Code sessions via tmux.
|
|
3
|
+
*
|
|
4
|
+
* This is the core capability that transforms Claude Code from a CLI tool
|
|
5
|
+
* into a persistent agent. Sessions run in tmux, survive terminal disconnects,
|
|
6
|
+
* and can be monitored/reaped by the server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync, spawn } from 'node:child_process';
|
|
10
|
+
import { EventEmitter } from 'node:events';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import type { Session, SessionManagerConfig, SessionStatus, ModelTier } from './types.js';
|
|
14
|
+
import { StateManager } from './StateManager.js';
|
|
15
|
+
|
|
16
|
+
export interface SessionManagerEvents {
|
|
17
|
+
sessionComplete: [session: Session];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SessionManager extends EventEmitter {
|
|
21
|
+
private config: SessionManagerConfig;
|
|
22
|
+
private state: StateManager;
|
|
23
|
+
private monitorInterval: ReturnType<typeof setInterval> | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(config: SessionManagerConfig, state: StateManager) {
|
|
26
|
+
super();
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.state = state;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Start polling for completed sessions. Emits 'sessionComplete' when
|
|
33
|
+
* a running session's tmux process disappears.
|
|
34
|
+
*/
|
|
35
|
+
startMonitoring(intervalMs: number = 5000): void {
|
|
36
|
+
if (this.monitorInterval) return;
|
|
37
|
+
|
|
38
|
+
this.monitorInterval = setInterval(() => {
|
|
39
|
+
const running = this.state.listSessions({ status: 'running' });
|
|
40
|
+
for (const session of running) {
|
|
41
|
+
if (!this.isSessionAlive(session.tmuxSession)) {
|
|
42
|
+
session.status = 'completed';
|
|
43
|
+
session.endedAt = new Date().toISOString();
|
|
44
|
+
this.state.saveSession(session);
|
|
45
|
+
this.emit('sessionComplete', session);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, intervalMs);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Stop the monitoring poll.
|
|
53
|
+
*/
|
|
54
|
+
stopMonitoring(): void {
|
|
55
|
+
if (this.monitorInterval) {
|
|
56
|
+
clearInterval(this.monitorInterval);
|
|
57
|
+
this.monitorInterval = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Spawn a new Claude Code session in tmux.
|
|
63
|
+
*/
|
|
64
|
+
async spawnSession(options: {
|
|
65
|
+
name: string;
|
|
66
|
+
prompt: string;
|
|
67
|
+
model?: ModelTier;
|
|
68
|
+
jobSlug?: string;
|
|
69
|
+
triggeredBy?: string;
|
|
70
|
+
}): Promise<Session> {
|
|
71
|
+
const runningSessions = this.listRunningSessions();
|
|
72
|
+
if (runningSessions.length >= this.config.maxSessions) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Max sessions (${this.config.maxSessions}) reached. ` +
|
|
75
|
+
`Running: ${runningSessions.map(s => s.name).join(', ')}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sessionId = this.generateId();
|
|
80
|
+
const tmuxSession = `${path.basename(this.config.projectDir)}-${options.name}`;
|
|
81
|
+
|
|
82
|
+
// Check if tmux session already exists
|
|
83
|
+
if (this.tmuxSessionExists(tmuxSession)) {
|
|
84
|
+
throw new Error(`tmux session "${tmuxSession}" already exists`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Build the claude command
|
|
88
|
+
const claudeArgs = ['--dangerously-skip-permissions'];
|
|
89
|
+
if (options.model) {
|
|
90
|
+
claudeArgs.push('--model', options.model);
|
|
91
|
+
}
|
|
92
|
+
claudeArgs.push('-p', options.prompt);
|
|
93
|
+
|
|
94
|
+
// Create tmux session and run claude
|
|
95
|
+
// Unset ANTHROPIC_* env vars so Claude uses OAuth (subscription) not API key
|
|
96
|
+
const cleanEnv = 'unset ANTHROPIC_API_KEY ANTHROPIC_ADMIN_KEY CLAUDECODE;';
|
|
97
|
+
const claudeCmd = `${cleanEnv} ${this.config.claudePath} ${claudeArgs.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`;
|
|
98
|
+
const tmuxCmd = [
|
|
99
|
+
this.config.tmuxPath,
|
|
100
|
+
'new-session',
|
|
101
|
+
'-d',
|
|
102
|
+
'-s', tmuxSession,
|
|
103
|
+
'-c', this.config.projectDir,
|
|
104
|
+
`bash -c "${claudeCmd.replace(/"/g, '\\"')}"`,
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
execSync(tmuxCmd.join(' '), { encoding: 'utf-8' });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new Error(`Failed to create tmux session: ${err}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const session: Session = {
|
|
114
|
+
id: sessionId,
|
|
115
|
+
name: options.name,
|
|
116
|
+
status: 'running',
|
|
117
|
+
jobSlug: options.jobSlug,
|
|
118
|
+
tmuxSession,
|
|
119
|
+
startedAt: new Date().toISOString(),
|
|
120
|
+
triggeredBy: options.triggeredBy,
|
|
121
|
+
model: options.model,
|
|
122
|
+
prompt: options.prompt,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
this.state.saveSession(session);
|
|
126
|
+
return session;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if a session is still running by checking tmux.
|
|
131
|
+
*/
|
|
132
|
+
isSessionAlive(tmuxSession: string): boolean {
|
|
133
|
+
return this.tmuxSessionExists(tmuxSession);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Kill a session by terminating its tmux session.
|
|
138
|
+
*/
|
|
139
|
+
killSession(sessionId: string): boolean {
|
|
140
|
+
const session = this.state.getSession(sessionId);
|
|
141
|
+
if (!session) return false;
|
|
142
|
+
|
|
143
|
+
// Don't kill protected sessions
|
|
144
|
+
if (this.config.protectedSessions.includes(session.tmuxSession)) {
|
|
145
|
+
throw new Error(`Cannot kill protected session: ${session.tmuxSession}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
execSync(`${this.config.tmuxPath} kill-session -t '=${session.tmuxSession}'`, {
|
|
150
|
+
encoding: 'utf-8',
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
// Session might already be dead
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
session.status = 'killed';
|
|
157
|
+
session.endedAt = new Date().toISOString();
|
|
158
|
+
this.state.saveSession(session);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Capture the current output of a tmux session.
|
|
164
|
+
*/
|
|
165
|
+
captureOutput(tmuxSession: string, lines: number = 100): string | null {
|
|
166
|
+
try {
|
|
167
|
+
// Note: use `=session:` (trailing colon) for pane-level tmux commands
|
|
168
|
+
return execSync(
|
|
169
|
+
`${this.config.tmuxPath} capture-pane -t '=${tmuxSession}:' -p -S -${lines}`,
|
|
170
|
+
{ encoding: 'utf-8' }
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Send input to a running tmux session.
|
|
179
|
+
*/
|
|
180
|
+
sendInput(tmuxSession: string, input: string): boolean {
|
|
181
|
+
try {
|
|
182
|
+
// Note: use `=session:` (trailing colon) for pane-level tmux commands
|
|
183
|
+
execSync(
|
|
184
|
+
`${this.config.tmuxPath} send-keys -t '=${tmuxSession}:' ${JSON.stringify(input)} Enter`,
|
|
185
|
+
{ encoding: 'utf-8' }
|
|
186
|
+
);
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* List all sessions that are currently running.
|
|
195
|
+
*/
|
|
196
|
+
listRunningSessions(): Session[] {
|
|
197
|
+
const sessions = this.state.listSessions({ status: 'running' });
|
|
198
|
+
|
|
199
|
+
// Verify each is actually still alive in tmux
|
|
200
|
+
return sessions.filter(s => {
|
|
201
|
+
const alive = this.isSessionAlive(s.tmuxSession);
|
|
202
|
+
if (!alive) {
|
|
203
|
+
// Mark as completed if tmux session is gone
|
|
204
|
+
s.status = 'completed';
|
|
205
|
+
s.endedAt = new Date().toISOString();
|
|
206
|
+
this.state.saveSession(s);
|
|
207
|
+
}
|
|
208
|
+
return alive;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Detect if a session has completed by checking output patterns.
|
|
214
|
+
*/
|
|
215
|
+
detectCompletion(tmuxSession: string): boolean {
|
|
216
|
+
const output = this.captureOutput(tmuxSession, 30);
|
|
217
|
+
if (!output) return false;
|
|
218
|
+
|
|
219
|
+
return this.config.completionPatterns.some(pattern =>
|
|
220
|
+
output.includes(pattern)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Reap completed/zombie sessions.
|
|
226
|
+
*/
|
|
227
|
+
reapCompletedSessions(): string[] {
|
|
228
|
+
const running = this.state.listSessions({ status: 'running' });
|
|
229
|
+
const reaped: string[] = [];
|
|
230
|
+
|
|
231
|
+
for (const session of running) {
|
|
232
|
+
if (this.config.protectedSessions.includes(session.tmuxSession)) continue;
|
|
233
|
+
|
|
234
|
+
if (!this.isSessionAlive(session.tmuxSession) || this.detectCompletion(session.tmuxSession)) {
|
|
235
|
+
session.status = 'completed';
|
|
236
|
+
session.endedAt = new Date().toISOString();
|
|
237
|
+
this.state.saveSession(session);
|
|
238
|
+
reaped.push(session.id);
|
|
239
|
+
|
|
240
|
+
// Kill the tmux session if it's still hanging around
|
|
241
|
+
if (this.isSessionAlive(session.tmuxSession)) {
|
|
242
|
+
try {
|
|
243
|
+
execSync(`${this.config.tmuxPath} kill-session -t '=${session.tmuxSession}'`);
|
|
244
|
+
} catch { /* ignore */ }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return reaped;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Spawn an interactive Claude Code session (no -p prompt — opens at the REPL).
|
|
254
|
+
* Used for Telegram-driven conversational sessions.
|
|
255
|
+
* Optionally sends an initial message after Claude is ready.
|
|
256
|
+
*/
|
|
257
|
+
async spawnInteractiveSession(initialMessage?: string, name?: string): Promise<string> {
|
|
258
|
+
const sanitized = name
|
|
259
|
+
? name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
|
|
260
|
+
: null;
|
|
261
|
+
const projectBase = path.basename(this.config.projectDir);
|
|
262
|
+
const tmuxSession = sanitized ? `${projectBase}-${sanitized}` : `${projectBase}-interactive-${Date.now()}`;
|
|
263
|
+
|
|
264
|
+
if (this.tmuxSessionExists(tmuxSession)) {
|
|
265
|
+
// Session already exists — just reuse it
|
|
266
|
+
if (initialMessage) {
|
|
267
|
+
this.injectMessage(tmuxSession, initialMessage);
|
|
268
|
+
}
|
|
269
|
+
return tmuxSession;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Unset ANTHROPIC_* env vars so Claude uses OAuth (subscription) not API key
|
|
273
|
+
const claudeCmd = `${this.config.claudePath} --dangerously-skip-permissions`;
|
|
274
|
+
const shellCmd = `cd '${this.config.projectDir}' && unset ANTHROPIC_API_KEY ANTHROPIC_ADMIN_KEY CLAUDECODE && ${claudeCmd}`;
|
|
275
|
+
const tmuxCmd = `${this.config.tmuxPath} new-session -d -s '${tmuxSession}' -x 200 -y 50 'bash -c "${shellCmd.replace(/"/g, '\\"')}"'`;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
execSync(tmuxCmd, { encoding: 'utf-8' });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
throw new Error(`Failed to create interactive tmux session: ${err}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Track it in state
|
|
284
|
+
const session: Session = {
|
|
285
|
+
id: this.generateId(),
|
|
286
|
+
name: name || tmuxSession,
|
|
287
|
+
status: 'running',
|
|
288
|
+
tmuxSession,
|
|
289
|
+
startedAt: new Date().toISOString(),
|
|
290
|
+
prompt: initialMessage,
|
|
291
|
+
};
|
|
292
|
+
this.state.saveSession(session);
|
|
293
|
+
|
|
294
|
+
// Wait for Claude to be ready, then send the initial message
|
|
295
|
+
if (initialMessage) {
|
|
296
|
+
this.waitForClaudeReady(tmuxSession).then((ready) => {
|
|
297
|
+
if (ready) {
|
|
298
|
+
this.injectMessage(tmuxSession, initialMessage);
|
|
299
|
+
} else {
|
|
300
|
+
console.error(`[SessionManager] Claude not ready in session "${tmuxSession}" after timeout`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return tmuxSession;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Inject a Telegram message into a tmux session.
|
|
310
|
+
* Short messages go via send-keys; long messages are written to a temp file.
|
|
311
|
+
*/
|
|
312
|
+
injectTelegramMessage(tmuxSession: string, topicId: number, text: string): void {
|
|
313
|
+
const FILE_THRESHOLD = 500;
|
|
314
|
+
const taggedText = `[telegram:${topicId}] ${text}`;
|
|
315
|
+
|
|
316
|
+
if (taggedText.length <= FILE_THRESHOLD) {
|
|
317
|
+
this.injectMessage(tmuxSession, taggedText);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Write full message to temp file
|
|
322
|
+
const tmpDir = path.join('/tmp', 'instar-telegram');
|
|
323
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
324
|
+
const filename = `msg-${topicId}-${Date.now()}.txt`;
|
|
325
|
+
const filepath = path.join(tmpDir, filename);
|
|
326
|
+
fs.writeFileSync(filepath, taggedText);
|
|
327
|
+
|
|
328
|
+
const ref = `[telegram:${topicId}] [Long message saved to ${filepath} — read it to see the full message]`;
|
|
329
|
+
this.injectMessage(tmuxSession, ref);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Send text to a tmux session via send-keys.
|
|
334
|
+
* Uses -l (literal) flag for text, then sends Enter separately.
|
|
335
|
+
*/
|
|
336
|
+
private injectMessage(tmuxSession: string, text: string): void {
|
|
337
|
+
const exactTarget = `=${tmuxSession}:`;
|
|
338
|
+
try {
|
|
339
|
+
// Send the text literally
|
|
340
|
+
execSync(
|
|
341
|
+
`${this.config.tmuxPath} send-keys -t '${exactTarget}' -l ${JSON.stringify(text)}`,
|
|
342
|
+
{ encoding: 'utf-8' }
|
|
343
|
+
);
|
|
344
|
+
// Send Enter separately
|
|
345
|
+
execSync(
|
|
346
|
+
`${this.config.tmuxPath} send-keys -t '${exactTarget}' Enter`,
|
|
347
|
+
{ encoding: 'utf-8' }
|
|
348
|
+
);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
console.error(`[SessionManager] Failed to inject message into ${tmuxSession}: ${err}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Wait for Claude to be ready in a tmux session by polling output.
|
|
356
|
+
*/
|
|
357
|
+
private async waitForClaudeReady(tmuxSession: string, timeoutMs: number = 15000): Promise<boolean> {
|
|
358
|
+
const start = Date.now();
|
|
359
|
+
while (Date.now() - start < timeoutMs) {
|
|
360
|
+
const output = this.captureOutput(tmuxSession, 10);
|
|
361
|
+
if (output && (output.includes('❯') || output.includes('>') || output.includes('$'))) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
await new Promise(r => setTimeout(r, 500));
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private tmuxSessionExists(name: string): boolean {
|
|
370
|
+
try {
|
|
371
|
+
execSync(`${this.config.tmuxPath} has-session -t '=${name}' 2>/dev/null`, {
|
|
372
|
+
encoding: 'utf-8',
|
|
373
|
+
});
|
|
374
|
+
return true;
|
|
375
|
+
} catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private generateId(): string {
|
|
381
|
+
const timestamp = Date.now().toString(36);
|
|
382
|
+
const random = Math.random().toString(36).substring(2, 6);
|
|
383
|
+
return `${timestamp}-${random}`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based state management.
|
|
3
|
+
*
|
|
4
|
+
* All state is stored as JSON files — no database dependency.
|
|
5
|
+
* This is intentional: agent infrastructure should be portable
|
|
6
|
+
* and not require running a DB server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import type { Session, JobState, ActivityEvent } from './types.js';
|
|
12
|
+
|
|
13
|
+
export class StateManager {
|
|
14
|
+
private stateDir: string;
|
|
15
|
+
|
|
16
|
+
constructor(stateDir: string) {
|
|
17
|
+
this.stateDir = stateDir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Session State ───────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
getSession(sessionId: string): Session | null {
|
|
23
|
+
const filePath = path.join(this.stateDir, 'state', 'sessions', `${sessionId}.json`);
|
|
24
|
+
if (!fs.existsSync(filePath)) return null;
|
|
25
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
saveSession(session: Session): void {
|
|
29
|
+
const filePath = path.join(this.stateDir, 'state', 'sessions', `${session.id}.json`);
|
|
30
|
+
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
listSessions(filter?: { status?: Session['status'] }): Session[] {
|
|
34
|
+
const dir = path.join(this.stateDir, 'state', 'sessions');
|
|
35
|
+
if (!fs.existsSync(dir)) return [];
|
|
36
|
+
|
|
37
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
38
|
+
const sessions: Session[] = files.map(f =>
|
|
39
|
+
JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (filter?.status) {
|
|
43
|
+
return sessions.filter(s => s.status === filter.status);
|
|
44
|
+
}
|
|
45
|
+
return sessions;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Job State ─────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
getJobState(slug: string): JobState | null {
|
|
51
|
+
const filePath = path.join(this.stateDir, 'state', 'jobs', `${slug}.json`);
|
|
52
|
+
if (!fs.existsSync(filePath)) return null;
|
|
53
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
saveJobState(state: JobState): void {
|
|
57
|
+
const filePath = path.join(this.stateDir, 'state', 'jobs', `${state.slug}.json`);
|
|
58
|
+
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Activity Events ───────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
appendEvent(event: ActivityEvent): void {
|
|
64
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
65
|
+
const filePath = path.join(this.stateDir, 'logs', `activity-${date}.jsonl`);
|
|
66
|
+
fs.appendFileSync(filePath, JSON.stringify(event) + '\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
queryEvents(options: {
|
|
70
|
+
since?: Date;
|
|
71
|
+
type?: string;
|
|
72
|
+
limit?: number;
|
|
73
|
+
}): ActivityEvent[] {
|
|
74
|
+
const logDir = path.join(this.stateDir, 'logs');
|
|
75
|
+
if (!fs.existsSync(logDir)) return [];
|
|
76
|
+
|
|
77
|
+
const files = fs.readdirSync(logDir)
|
|
78
|
+
.filter(f => f.startsWith('activity-') && f.endsWith('.jsonl'))
|
|
79
|
+
.sort()
|
|
80
|
+
.reverse();
|
|
81
|
+
|
|
82
|
+
const events: ActivityEvent[] = [];
|
|
83
|
+
const limit = options.limit || 100;
|
|
84
|
+
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
const lines = fs.readFileSync(path.join(logDir, file), 'utf-8')
|
|
87
|
+
.split('\n')
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
|
|
90
|
+
for (const line of lines.reverse()) {
|
|
91
|
+
const event: ActivityEvent = JSON.parse(line);
|
|
92
|
+
|
|
93
|
+
if (options.since && new Date(event.timestamp) < options.since) {
|
|
94
|
+
return events; // Past the time window
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (options.type && event.type !== options.type) continue;
|
|
98
|
+
|
|
99
|
+
events.push(event);
|
|
100
|
+
if (events.length >= limit) return events;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return events;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Generic Key-Value Store ───────────────────────────────────
|
|
108
|
+
|
|
109
|
+
get<T>(key: string): T | null {
|
|
110
|
+
const filePath = path.join(this.stateDir, 'state', `${key}.json`);
|
|
111
|
+
if (!fs.existsSync(filePath)) return null;
|
|
112
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
set<T>(key: string, value: T): void {
|
|
116
|
+
const filePath = path.join(this.stateDir, 'state', `${key}.json`);
|
|
117
|
+
const dir = path.dirname(filePath);
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
|
120
|
+
}
|
|
121
|
+
}
|