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,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Messaging Adapter — send/receive messages via Telegram Bot API.
|
|
3
|
+
*
|
|
4
|
+
* Uses long polling to receive messages. Supports forum topics
|
|
5
|
+
* (each user gets a topic thread). Includes topic-session registry
|
|
6
|
+
* and message logging for session respawn with thread history.
|
|
7
|
+
*
|
|
8
|
+
* No external dependencies — uses native fetch for Telegram API calls.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import type { MessagingAdapter, Message, OutgoingMessage, UserChannel } from '../core/types.js';
|
|
14
|
+
|
|
15
|
+
interface TelegramConfig {
|
|
16
|
+
/** Bot token from @BotFather */
|
|
17
|
+
token: string;
|
|
18
|
+
/** Forum chat ID (the supergroup where topics live) */
|
|
19
|
+
chatId: string;
|
|
20
|
+
/** Polling interval in ms */
|
|
21
|
+
pollIntervalMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TelegramUpdate {
|
|
25
|
+
update_id: number;
|
|
26
|
+
message?: {
|
|
27
|
+
message_id: number;
|
|
28
|
+
from: { id: number; first_name: string; username?: string };
|
|
29
|
+
chat: { id: number };
|
|
30
|
+
message_thread_id?: number;
|
|
31
|
+
text?: string;
|
|
32
|
+
date: number;
|
|
33
|
+
reply_to_message?: {
|
|
34
|
+
message_id: number;
|
|
35
|
+
forum_topic_created?: { name: string };
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface LogEntry {
|
|
41
|
+
messageId: number;
|
|
42
|
+
topicId: number | null;
|
|
43
|
+
text: string;
|
|
44
|
+
fromUser: boolean;
|
|
45
|
+
timestamp: string;
|
|
46
|
+
sessionName: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class TelegramAdapter implements MessagingAdapter {
|
|
50
|
+
readonly platform = 'telegram';
|
|
51
|
+
|
|
52
|
+
private config: TelegramConfig;
|
|
53
|
+
private handler: ((message: Message) => Promise<void>) | null = null;
|
|
54
|
+
private polling = false;
|
|
55
|
+
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
56
|
+
private lastUpdateId = 0;
|
|
57
|
+
|
|
58
|
+
// Topic-session registry (persisted to disk)
|
|
59
|
+
private topicToSession: Map<number, string> = new Map();
|
|
60
|
+
private sessionToTopic: Map<string, number> = new Map();
|
|
61
|
+
private topicToName: Map<number, string> = new Map();
|
|
62
|
+
private registryPath: string;
|
|
63
|
+
private messageLogPath: string;
|
|
64
|
+
|
|
65
|
+
// Topic message callback — fires on every incoming topic message
|
|
66
|
+
public onTopicMessage: ((message: Message) => void) | null = null;
|
|
67
|
+
|
|
68
|
+
constructor(config: TelegramConfig, stateDir: string) {
|
|
69
|
+
this.config = config;
|
|
70
|
+
this.registryPath = path.join(stateDir, 'topic-session-registry.json');
|
|
71
|
+
this.messageLogPath = path.join(stateDir, 'telegram-messages.jsonl');
|
|
72
|
+
this.loadRegistry();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async start(): Promise<void> {
|
|
76
|
+
if (this.polling) return;
|
|
77
|
+
this.polling = true;
|
|
78
|
+
console.log(`[telegram] Starting long-polling...`);
|
|
79
|
+
this.poll();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async stop(): Promise<void> {
|
|
83
|
+
this.polling = false;
|
|
84
|
+
if (this.pollTimeout) {
|
|
85
|
+
clearTimeout(this.pollTimeout);
|
|
86
|
+
this.pollTimeout = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async send(message: OutgoingMessage): Promise<void> {
|
|
91
|
+
const topicId = message.channel?.identifier;
|
|
92
|
+
const params: Record<string, unknown> = {
|
|
93
|
+
chat_id: this.config.chatId,
|
|
94
|
+
text: message.content,
|
|
95
|
+
parse_mode: 'Markdown',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (topicId && parseInt(topicId) > 1) {
|
|
99
|
+
params.message_thread_id = parseInt(topicId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await this.apiCall('sendMessage', params);
|
|
104
|
+
} catch {
|
|
105
|
+
// Fallback to plain text on parse errors
|
|
106
|
+
delete params.parse_mode;
|
|
107
|
+
await this.apiCall('sendMessage', params);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Send a message to a specific forum topic.
|
|
113
|
+
*/
|
|
114
|
+
async sendToTopic(topicId: number, text: string): Promise<void> {
|
|
115
|
+
const params: Record<string, unknown> = {
|
|
116
|
+
chat_id: this.config.chatId,
|
|
117
|
+
text,
|
|
118
|
+
};
|
|
119
|
+
// Topic ID 1 = General topic (our fallback) — omit message_thread_id for General
|
|
120
|
+
if (topicId > 1) {
|
|
121
|
+
params.message_thread_id = topicId;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await this.apiCall('sendMessage', { ...params, parse_mode: 'Markdown' });
|
|
126
|
+
} catch {
|
|
127
|
+
await this.apiCall('sendMessage', params);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Log outbound messages too
|
|
131
|
+
this.appendToLog({
|
|
132
|
+
messageId: 0,
|
|
133
|
+
topicId,
|
|
134
|
+
text,
|
|
135
|
+
fromUser: false,
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
sessionName: this.topicToSession.get(topicId) ?? null,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create a forum topic in the supergroup.
|
|
143
|
+
*/
|
|
144
|
+
async createForumTopic(name: string, iconColor?: number): Promise<{ topicId: number; name: string }> {
|
|
145
|
+
const params: Record<string, unknown> = {
|
|
146
|
+
chat_id: this.config.chatId,
|
|
147
|
+
name,
|
|
148
|
+
};
|
|
149
|
+
if (iconColor !== undefined) {
|
|
150
|
+
params.icon_color = iconColor;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = await this.apiCall('createForumTopic', params) as {
|
|
154
|
+
message_thread_id: number;
|
|
155
|
+
name: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
this.topicToName.set(result.message_thread_id, name);
|
|
159
|
+
this.saveRegistry();
|
|
160
|
+
|
|
161
|
+
console.log(`[telegram] Created forum topic: "${name}" (ID: ${result.message_thread_id})`);
|
|
162
|
+
return { topicId: result.message_thread_id, name: result.name };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
onMessage(handler: (message: Message) => Promise<void>): void {
|
|
166
|
+
this.handler = handler;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async resolveUser(channelIdentifier: string): Promise<string | null> {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Topic-Session Registry ─────────────────────────────────
|
|
174
|
+
|
|
175
|
+
registerTopicSession(topicId: number, sessionName: string): void {
|
|
176
|
+
this.topicToSession.set(topicId, sessionName);
|
|
177
|
+
this.sessionToTopic.set(sessionName, topicId);
|
|
178
|
+
this.saveRegistry();
|
|
179
|
+
console.log(`[telegram] Registered topic ${topicId} <-> session "${sessionName}"`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
getSessionForTopic(topicId: number): string | null {
|
|
183
|
+
return this.topicToSession.get(topicId) ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
getTopicForSession(sessionName: string): number | null {
|
|
187
|
+
return this.sessionToTopic.get(sessionName) ?? null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
getTopicName(topicId: number): string | null {
|
|
191
|
+
return this.topicToName.get(topicId) ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Message Log ────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get recent messages for a topic (for thread history on respawn).
|
|
198
|
+
*/
|
|
199
|
+
getTopicHistory(topicId: number, limit: number = 20): LogEntry[] {
|
|
200
|
+
if (!fs.existsSync(this.messageLogPath)) return [];
|
|
201
|
+
|
|
202
|
+
const lines = fs.readFileSync(this.messageLogPath, 'utf-8')
|
|
203
|
+
.split('\n')
|
|
204
|
+
.filter(Boolean);
|
|
205
|
+
|
|
206
|
+
const matching: LogEntry[] = [];
|
|
207
|
+
for (const line of lines) {
|
|
208
|
+
try {
|
|
209
|
+
const entry: LogEntry = JSON.parse(line);
|
|
210
|
+
if (entry.topicId === topicId) {
|
|
211
|
+
matching.push(entry);
|
|
212
|
+
}
|
|
213
|
+
} catch { /* skip malformed */ }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return matching.slice(-limit);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private appendToLog(entry: LogEntry): void {
|
|
220
|
+
try {
|
|
221
|
+
fs.appendFileSync(this.messageLogPath, JSON.stringify(entry) + '\n');
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.error(`[telegram] Failed to append to message log: ${err}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Registry Persistence ───────────────────────────────────
|
|
228
|
+
|
|
229
|
+
private loadRegistry(): void {
|
|
230
|
+
try {
|
|
231
|
+
const data = JSON.parse(fs.readFileSync(this.registryPath, 'utf-8'));
|
|
232
|
+
if (data.topicToSession) {
|
|
233
|
+
for (const [k, v] of Object.entries(data.topicToSession)) {
|
|
234
|
+
this.topicToSession.set(Number(k), v as string);
|
|
235
|
+
this.sessionToTopic.set(v as string, Number(k));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (data.topicToName) {
|
|
239
|
+
for (const [k, v] of Object.entries(data.topicToName)) {
|
|
240
|
+
this.topicToName.set(Number(k), v as string);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
console.log(`[telegram] Loaded ${this.topicToSession.size} topic-session mappings from disk`);
|
|
244
|
+
} catch {
|
|
245
|
+
// File doesn't exist yet — start fresh
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private saveRegistry(): void {
|
|
250
|
+
try {
|
|
251
|
+
const data = {
|
|
252
|
+
topicToSession: Object.fromEntries(this.topicToSession),
|
|
253
|
+
topicToName: Object.fromEntries(this.topicToName),
|
|
254
|
+
};
|
|
255
|
+
fs.writeFileSync(this.registryPath, JSON.stringify(data, null, 2));
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error(`[telegram] Failed to save registry: ${err}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Polling ────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
private async poll(): Promise<void> {
|
|
264
|
+
if (!this.polling) return;
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const updates = await this.getUpdates();
|
|
268
|
+
for (const update of updates) {
|
|
269
|
+
if (update.message?.text) {
|
|
270
|
+
const msg = update.message;
|
|
271
|
+
const text = msg.text!;
|
|
272
|
+
// Use message_thread_id if present; fall back to 1 (General topic) for forum groups
|
|
273
|
+
const numericTopicId = msg.message_thread_id ?? 1;
|
|
274
|
+
const topicId = numericTopicId.toString();
|
|
275
|
+
|
|
276
|
+
// Auto-capture topic name from reply_to_message
|
|
277
|
+
if (msg.reply_to_message?.forum_topic_created?.name) {
|
|
278
|
+
if (!this.topicToName.has(numericTopicId)) {
|
|
279
|
+
this.topicToName.set(numericTopicId, msg.reply_to_message.forum_topic_created.name);
|
|
280
|
+
this.saveRegistry();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const message: Message = {
|
|
285
|
+
id: `tg-${msg.message_id}`,
|
|
286
|
+
userId: msg.from.id.toString(),
|
|
287
|
+
content: text,
|
|
288
|
+
channel: { type: 'telegram', identifier: topicId },
|
|
289
|
+
receivedAt: new Date(msg.date * 1000).toISOString(),
|
|
290
|
+
metadata: {
|
|
291
|
+
telegramUserId: msg.from.id,
|
|
292
|
+
username: msg.from.username,
|
|
293
|
+
firstName: msg.from.first_name,
|
|
294
|
+
messageThreadId: numericTopicId,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// Log the message
|
|
299
|
+
this.appendToLog({
|
|
300
|
+
messageId: msg.message_id,
|
|
301
|
+
topicId: numericTopicId,
|
|
302
|
+
text,
|
|
303
|
+
fromUser: true,
|
|
304
|
+
timestamp: new Date(msg.date * 1000).toISOString(),
|
|
305
|
+
sessionName: this.topicToSession.get(numericTopicId) ?? null,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Fire topic message callback (always fires — General topic falls back to ID 1)
|
|
309
|
+
if (this.onTopicMessage) {
|
|
310
|
+
this.onTopicMessage(message);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Fire general handler
|
|
314
|
+
if (this.handler) {
|
|
315
|
+
try {
|
|
316
|
+
await this.handler(message);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
console.error(`[telegram] Handler error: ${err}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.lastUpdateId = Math.max(this.lastUpdateId, update.update_id);
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
console.error(`[telegram] Poll error: ${err}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Schedule next poll
|
|
330
|
+
const interval = this.config.pollIntervalMs ?? 2000;
|
|
331
|
+
this.pollTimeout = setTimeout(() => this.poll(), interval);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async getUpdates(): Promise<TelegramUpdate[]> {
|
|
335
|
+
const result = await this.apiCall('getUpdates', {
|
|
336
|
+
offset: this.lastUpdateId + 1,
|
|
337
|
+
timeout: 30,
|
|
338
|
+
allowed_updates: ['message'],
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return (result as TelegramUpdate[]) ?? [];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async apiCall(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
345
|
+
const url = `https://api.telegram.org/bot${this.config.token}/${method}`;
|
|
346
|
+
|
|
347
|
+
const response = await fetch(url, {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
headers: { 'Content-Type': 'application/json' },
|
|
350
|
+
body: JSON.stringify(params),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (!response.ok) {
|
|
354
|
+
const text = await response.text();
|
|
355
|
+
throw new Error(`Telegram API error (${response.status}): ${text}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const data = await response.json() as { ok: boolean; result: unknown };
|
|
359
|
+
if (!data.ok) {
|
|
360
|
+
throw new Error(`Telegram API returned not ok: ${JSON.stringify(data)}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return data.result;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Checker — aggregates component health into a single status.
|
|
3
|
+
*
|
|
4
|
+
* Checks tmux availability, session state, scheduler health,
|
|
5
|
+
* and disk space. Returns a HealthStatus object.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import type { SessionManager } from '../core/SessionManager.js';
|
|
11
|
+
import type { JobScheduler } from '../scheduler/JobScheduler.js';
|
|
12
|
+
import type { HealthStatus, ComponentHealth, AgentKitConfig } from '../core/types.js';
|
|
13
|
+
|
|
14
|
+
export class HealthChecker {
|
|
15
|
+
private config: AgentKitConfig;
|
|
16
|
+
private sessionManager: SessionManager;
|
|
17
|
+
private scheduler: JobScheduler | null;
|
|
18
|
+
private checkInterval: ReturnType<typeof setInterval> | null = null;
|
|
19
|
+
private lastStatus: HealthStatus | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
config: AgentKitConfig,
|
|
23
|
+
sessionManager: SessionManager,
|
|
24
|
+
scheduler: JobScheduler | null = null,
|
|
25
|
+
) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.sessionManager = sessionManager;
|
|
28
|
+
this.scheduler = scheduler;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run all health checks and return aggregated status.
|
|
33
|
+
*/
|
|
34
|
+
check(): HealthStatus {
|
|
35
|
+
const components: Record<string, ComponentHealth> = {};
|
|
36
|
+
|
|
37
|
+
components.tmux = this.checkTmux();
|
|
38
|
+
components.sessions = this.checkSessions();
|
|
39
|
+
components.stateDir = this.checkStateDir();
|
|
40
|
+
|
|
41
|
+
if (this.scheduler) {
|
|
42
|
+
components.scheduler = this.checkScheduler();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Aggregate: worst component status becomes overall status
|
|
46
|
+
const statuses = Object.values(components).map(c => c.status);
|
|
47
|
+
let overall: HealthStatus['status'] = 'healthy';
|
|
48
|
+
if (statuses.includes('unhealthy')) overall = 'unhealthy';
|
|
49
|
+
else if (statuses.includes('degraded')) overall = 'degraded';
|
|
50
|
+
|
|
51
|
+
this.lastStatus = {
|
|
52
|
+
status: overall,
|
|
53
|
+
components,
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return this.lastStatus;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the last computed health status without re-checking.
|
|
62
|
+
*/
|
|
63
|
+
getLastStatus(): HealthStatus | null {
|
|
64
|
+
return this.lastStatus;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Start periodic health checks.
|
|
69
|
+
*/
|
|
70
|
+
startPeriodicChecks(intervalMs?: number): void {
|
|
71
|
+
if (this.checkInterval) return;
|
|
72
|
+
|
|
73
|
+
const interval = intervalMs ?? this.config.monitoring.healthCheckIntervalMs;
|
|
74
|
+
this.check(); // Run immediately
|
|
75
|
+
this.checkInterval = setInterval(() => this.check(), interval);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Stop periodic health checks.
|
|
80
|
+
*/
|
|
81
|
+
stopPeriodicChecks(): void {
|
|
82
|
+
if (this.checkInterval) {
|
|
83
|
+
clearInterval(this.checkInterval);
|
|
84
|
+
this.checkInterval = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private checkTmux(): ComponentHealth {
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
try {
|
|
91
|
+
execSync(`${this.config.sessions.tmuxPath} list-sessions 2>/dev/null`, {
|
|
92
|
+
encoding: 'utf-8',
|
|
93
|
+
timeout: 3000,
|
|
94
|
+
});
|
|
95
|
+
return { status: 'healthy', message: 'tmux server responding', lastCheck: now };
|
|
96
|
+
} catch {
|
|
97
|
+
// tmux server not running is ok if no sessions needed
|
|
98
|
+
try {
|
|
99
|
+
execSync(`${this.config.sessions.tmuxPath} -V`, { encoding: 'utf-8', timeout: 3000 });
|
|
100
|
+
return { status: 'healthy', message: 'tmux available (no server running)', lastCheck: now };
|
|
101
|
+
} catch {
|
|
102
|
+
return { status: 'unhealthy', message: 'tmux binary not found', lastCheck: now };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private checkSessions(): ComponentHealth {
|
|
108
|
+
const now = new Date().toISOString();
|
|
109
|
+
try {
|
|
110
|
+
const running = this.sessionManager.listRunningSessions();
|
|
111
|
+
const max = this.config.sessions.maxSessions;
|
|
112
|
+
|
|
113
|
+
if (running.length >= max) {
|
|
114
|
+
return {
|
|
115
|
+
status: 'degraded',
|
|
116
|
+
message: `At capacity: ${running.length}/${max} sessions`,
|
|
117
|
+
lastCheck: now,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
status: 'healthy',
|
|
123
|
+
message: `${running.length}/${max} sessions active`,
|
|
124
|
+
lastCheck: now,
|
|
125
|
+
};
|
|
126
|
+
} catch (err: any) {
|
|
127
|
+
return { status: 'unhealthy', message: `Session check failed: ${err.message}`, lastCheck: now };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private checkScheduler(): ComponentHealth {
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
if (!this.scheduler) {
|
|
134
|
+
return { status: 'healthy', message: 'Scheduler not configured', lastCheck: now };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const status = this.scheduler.getStatus();
|
|
138
|
+
|
|
139
|
+
if (!status.running) {
|
|
140
|
+
return { status: 'degraded', message: 'Scheduler not running', lastCheck: now };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (status.paused) {
|
|
144
|
+
return { status: 'degraded', message: 'Scheduler paused', lastCheck: now };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
status: 'healthy',
|
|
149
|
+
message: `Running: ${status.enabledJobs} jobs, ${status.queueLength} queued`,
|
|
150
|
+
lastCheck: now,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private checkStateDir(): ComponentHealth {
|
|
155
|
+
const now = new Date().toISOString();
|
|
156
|
+
try {
|
|
157
|
+
const exists = fs.existsSync(this.config.stateDir);
|
|
158
|
+
if (!exists) {
|
|
159
|
+
return { status: 'unhealthy', message: 'State directory missing', lastCheck: now };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check we can write
|
|
163
|
+
const testFile = `${this.config.stateDir}/.health-check-${Date.now()}`;
|
|
164
|
+
fs.writeFileSync(testFile, 'ok');
|
|
165
|
+
fs.unlinkSync(testFile);
|
|
166
|
+
|
|
167
|
+
return { status: 'healthy', message: 'State directory writable', lastCheck: now };
|
|
168
|
+
} catch (err: any) {
|
|
169
|
+
return { status: 'unhealthy', message: `State dir error: ${err.message}`, lastCheck: now };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity bootstrap — interactive flow for creating the agent's identity.
|
|
3
|
+
*
|
|
4
|
+
* On first run, walks the user through defining who their agent is.
|
|
5
|
+
* Writes AGENT.md, USER.md, and MEMORY.md based on their answers.
|
|
6
|
+
*
|
|
7
|
+
* Inspired by OpenClaw's SOUL.md co-creation — but adapted for
|
|
8
|
+
* persistent infrastructure rather than conversational personality.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { input, select } from '@inquirer/prompts';
|
|
12
|
+
import pc from 'picocolors';
|
|
13
|
+
import type { AgentIdentity } from './templates.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run the interactive identity bootstrap.
|
|
17
|
+
* Returns the agent identity for template generation.
|
|
18
|
+
*/
|
|
19
|
+
export async function bootstrapIdentity(projectName: string): Promise<AgentIdentity> {
|
|
20
|
+
console.log();
|
|
21
|
+
console.log(pc.bold(' Identity Bootstrap'));
|
|
22
|
+
console.log(pc.dim(' Let\'s define who your agent is. This takes about 30 seconds.'));
|
|
23
|
+
console.log(pc.dim(' You can always change these later by editing .instar/AGENT.md'));
|
|
24
|
+
console.log();
|
|
25
|
+
|
|
26
|
+
// Agent name
|
|
27
|
+
const name = await input({
|
|
28
|
+
message: 'What should your agent be called?',
|
|
29
|
+
default: capitalize(projectName),
|
|
30
|
+
validate: (v) => v.trim().length > 0 ? true : 'Name is required',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Agent role
|
|
34
|
+
const roleChoice = await select({
|
|
35
|
+
message: 'What\'s their primary role?',
|
|
36
|
+
choices: [
|
|
37
|
+
{
|
|
38
|
+
name: 'General-purpose autonomous agent',
|
|
39
|
+
value: 'I am a general-purpose autonomous agent. I build, maintain, and evolve this project.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'Development assistant (code-focused)',
|
|
43
|
+
value: 'I am a development agent. I write code, run tests, review PRs, and maintain code quality for this project.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'Operations agent (monitoring, automation)',
|
|
47
|
+
value: 'I am an operations agent. I monitor systems, automate workflows, handle alerts, and keep infrastructure running.',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'Research agent (analysis, exploration)',
|
|
51
|
+
value: 'I am a research agent. I explore topics, analyze data, synthesize findings, and surface insights.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'Custom (I\'ll describe it)',
|
|
55
|
+
value: '__custom__',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const role = roleChoice === '__custom__'
|
|
61
|
+
? await input({ message: 'Describe their role in one sentence' })
|
|
62
|
+
: roleChoice;
|
|
63
|
+
|
|
64
|
+
// Personality
|
|
65
|
+
const personalityChoice = await select({
|
|
66
|
+
message: 'What\'s their personality like?',
|
|
67
|
+
choices: [
|
|
68
|
+
{
|
|
69
|
+
name: 'Direct and efficient — gets things done with minimal fuss',
|
|
70
|
+
value: 'I am direct and efficient. I focus on outcomes, communicate concisely, and value action over discussion. When something needs doing, I do it.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'Thoughtful and thorough — considers carefully before acting',
|
|
74
|
+
value: 'I am thoughtful and thorough. I consider implications before acting, document my reasoning, and prefer getting things right over getting them fast.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'Curious and exploratory — learns and experiments actively',
|
|
78
|
+
value: 'I am curious and exploratory. I investigate deeply, try new approaches, and treat every problem as an opportunity to learn something. I document what I discover.',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'Warm and collaborative — works as a true partner',
|
|
82
|
+
value: 'I am warm and collaborative. I communicate openly, celebrate progress, and treat my work as a genuine partnership with the people I work with.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'Custom (I\'ll describe it)',
|
|
86
|
+
value: '__custom__',
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const personality = personalityChoice === '__custom__'
|
|
92
|
+
? await input({ message: 'Describe their personality in a sentence or two' })
|
|
93
|
+
: personalityChoice;
|
|
94
|
+
|
|
95
|
+
// User name
|
|
96
|
+
const userName = await input({
|
|
97
|
+
message: 'What\'s your name? (the person who\'ll work with this agent)',
|
|
98
|
+
validate: (v) => v.trim().length > 0 ? true : 'Name is required',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
console.log();
|
|
102
|
+
console.log(` ${pc.green('✓')} ${pc.bold(name)} is ready.`);
|
|
103
|
+
|
|
104
|
+
return { name, role, personality, userName };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate a default identity without interaction.
|
|
109
|
+
* Used for non-interactive init (flags-only mode).
|
|
110
|
+
*/
|
|
111
|
+
export function defaultIdentity(projectName: string): AgentIdentity {
|
|
112
|
+
return {
|
|
113
|
+
name: capitalize(projectName),
|
|
114
|
+
role: 'I am a general-purpose autonomous agent. I build, maintain, and evolve this project.',
|
|
115
|
+
personality: 'I am direct and efficient. I focus on outcomes, communicate concisely, and value action over discussion. When something needs doing, I do it.',
|
|
116
|
+
userName: 'User',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function capitalize(str: string): string {
|
|
121
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
122
|
+
}
|