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.
Files changed (115) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.claude/skills/setup-wizard/skill.md +343 -0
  3. package/.github/workflows/ci.yml +78 -0
  4. package/CLAUDE.md +82 -0
  5. package/README.md +194 -0
  6. package/dist/cli.d.ts +18 -0
  7. package/dist/cli.js +141 -0
  8. package/dist/commands/init.d.ts +40 -0
  9. package/dist/commands/init.js +568 -0
  10. package/dist/commands/job.d.ts +20 -0
  11. package/dist/commands/job.js +84 -0
  12. package/dist/commands/server.d.ts +19 -0
  13. package/dist/commands/server.js +273 -0
  14. package/dist/commands/setup.d.ts +24 -0
  15. package/dist/commands/setup.js +865 -0
  16. package/dist/commands/status.d.ts +11 -0
  17. package/dist/commands/status.js +114 -0
  18. package/dist/commands/user.d.ts +17 -0
  19. package/dist/commands/user.js +53 -0
  20. package/dist/core/Config.d.ts +16 -0
  21. package/dist/core/Config.js +144 -0
  22. package/dist/core/Prerequisites.d.ts +28 -0
  23. package/dist/core/Prerequisites.js +159 -0
  24. package/dist/core/RelationshipManager.d.ts +73 -0
  25. package/dist/core/RelationshipManager.js +318 -0
  26. package/dist/core/SessionManager.d.ts +89 -0
  27. package/dist/core/SessionManager.js +326 -0
  28. package/dist/core/StateManager.d.ts +28 -0
  29. package/dist/core/StateManager.js +96 -0
  30. package/dist/core/types.d.ts +279 -0
  31. package/dist/core/types.js +8 -0
  32. package/dist/index.d.ts +18 -0
  33. package/dist/index.js +23 -0
  34. package/dist/messaging/TelegramAdapter.d.ts +73 -0
  35. package/dist/messaging/TelegramAdapter.js +288 -0
  36. package/dist/monitoring/HealthChecker.d.ts +38 -0
  37. package/dist/monitoring/HealthChecker.js +148 -0
  38. package/dist/scaffold/bootstrap.d.ts +21 -0
  39. package/dist/scaffold/bootstrap.js +110 -0
  40. package/dist/scaffold/templates.d.ts +34 -0
  41. package/dist/scaffold/templates.js +187 -0
  42. package/dist/scheduler/JobLoader.d.ts +18 -0
  43. package/dist/scheduler/JobLoader.js +70 -0
  44. package/dist/scheduler/JobScheduler.d.ts +111 -0
  45. package/dist/scheduler/JobScheduler.js +402 -0
  46. package/dist/server/AgentServer.d.ts +40 -0
  47. package/dist/server/AgentServer.js +73 -0
  48. package/dist/server/middleware.d.ts +12 -0
  49. package/dist/server/middleware.js +50 -0
  50. package/dist/server/routes.d.ts +25 -0
  51. package/dist/server/routes.js +224 -0
  52. package/dist/users/UserManager.d.ts +45 -0
  53. package/dist/users/UserManager.js +113 -0
  54. package/docs/dawn-audit-report.md +412 -0
  55. package/docs/positioning-vs-openclaw.md +246 -0
  56. package/package.json +52 -0
  57. package/src/cli.ts +169 -0
  58. package/src/commands/init.ts +654 -0
  59. package/src/commands/job.ts +110 -0
  60. package/src/commands/server.ts +325 -0
  61. package/src/commands/setup.ts +958 -0
  62. package/src/commands/status.ts +125 -0
  63. package/src/commands/user.ts +71 -0
  64. package/src/core/Config.ts +161 -0
  65. package/src/core/Prerequisites.ts +187 -0
  66. package/src/core/RelationshipManager.ts +366 -0
  67. package/src/core/SessionManager.ts +385 -0
  68. package/src/core/StateManager.ts +121 -0
  69. package/src/core/types.ts +320 -0
  70. package/src/index.ts +58 -0
  71. package/src/messaging/TelegramAdapter.ts +365 -0
  72. package/src/monitoring/HealthChecker.ts +172 -0
  73. package/src/scaffold/bootstrap.ts +122 -0
  74. package/src/scaffold/templates.ts +204 -0
  75. package/src/scheduler/JobLoader.ts +85 -0
  76. package/src/scheduler/JobScheduler.ts +476 -0
  77. package/src/server/AgentServer.ts +93 -0
  78. package/src/server/middleware.ts +58 -0
  79. package/src/server/routes.ts +278 -0
  80. package/src/templates/default-jobs.json +47 -0
  81. package/src/templates/hooks/compaction-recovery.sh +23 -0
  82. package/src/templates/hooks/dangerous-command-guard.sh +35 -0
  83. package/src/templates/hooks/grounding-before-messaging.sh +22 -0
  84. package/src/templates/hooks/session-start.sh +37 -0
  85. package/src/templates/hooks/settings-template.json +45 -0
  86. package/src/templates/scripts/health-watchdog.sh +63 -0
  87. package/src/templates/scripts/telegram-reply.sh +54 -0
  88. package/src/users/UserManager.ts +129 -0
  89. package/tests/e2e/lifecycle.test.ts +376 -0
  90. package/tests/fixtures/test-repo/CLAUDE.md +3 -0
  91. package/tests/fixtures/test-repo/README.md +1 -0
  92. package/tests/helpers/setup.ts +209 -0
  93. package/tests/integration/fresh-install.test.ts +218 -0
  94. package/tests/integration/scheduler-basic.test.ts +109 -0
  95. package/tests/integration/server-full.test.ts +284 -0
  96. package/tests/integration/session-lifecycle.test.ts +181 -0
  97. package/tests/unit/Config.test.ts +22 -0
  98. package/tests/unit/HealthChecker.test.ts +168 -0
  99. package/tests/unit/JobLoader.test.ts +151 -0
  100. package/tests/unit/JobScheduler.test.ts +267 -0
  101. package/tests/unit/Prerequisites.test.ts +59 -0
  102. package/tests/unit/RelationshipManager.test.ts +345 -0
  103. package/tests/unit/StateManager.test.ts +143 -0
  104. package/tests/unit/TelegramAdapter.test.ts +165 -0
  105. package/tests/unit/UserManager.test.ts +131 -0
  106. package/tests/unit/bootstrap.test.ts +28 -0
  107. package/tests/unit/commands.test.ts +138 -0
  108. package/tests/unit/middleware.test.ts +92 -0
  109. package/tests/unit/relationship-routes.test.ts +131 -0
  110. package/tests/unit/scaffold-templates.test.ts +132 -0
  111. package/tests/unit/server.test.ts +163 -0
  112. package/tsconfig.json +20 -0
  113. package/vitest.config.ts +9 -0
  114. package/vitest.e2e.config.ts +9 -0
  115. 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
+ }