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,110 @@
1
+ /**
2
+ * `instar job add|list` — Manage scheduled jobs.
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import pc from 'picocolors';
7
+ import { loadConfig, ensureStateDir } from '../core/Config.js';
8
+ import { loadJobs, validateJob } from '../scheduler/JobLoader.js';
9
+ import { StateManager } from '../core/StateManager.js';
10
+ import type { JobDefinition, JobPriority, ModelTier } from '../core/types.js';
11
+
12
+ interface JobAddOptions {
13
+ slug: string;
14
+ name: string;
15
+ schedule: string;
16
+ description?: string;
17
+ priority?: string;
18
+ model?: string;
19
+ type?: string;
20
+ execute?: string;
21
+ enabled?: boolean;
22
+ }
23
+
24
+ export async function addJob(options: JobAddOptions): Promise<void> {
25
+ const config = loadConfig();
26
+ ensureStateDir(config.stateDir);
27
+
28
+ const jobsFile = config.scheduler.jobsFile;
29
+ let jobs: JobDefinition[] = [];
30
+
31
+ if (fs.existsSync(jobsFile)) {
32
+ jobs = JSON.parse(fs.readFileSync(jobsFile, 'utf-8'));
33
+ }
34
+
35
+ // Check for duplicate slug
36
+ if (jobs.some(j => j.slug === options.slug)) {
37
+ console.log(pc.red(`Job with slug "${options.slug}" already exists.`));
38
+ process.exit(1);
39
+ }
40
+
41
+ const newJob: JobDefinition = {
42
+ slug: options.slug,
43
+ name: options.name,
44
+ description: options.description || options.name,
45
+ schedule: options.schedule,
46
+ priority: (options.priority || 'medium') as JobPriority,
47
+ expectedDurationMinutes: 5,
48
+ model: (options.model || 'sonnet') as ModelTier,
49
+ enabled: options.enabled !== false,
50
+ execute: {
51
+ type: (options.type || 'prompt') as 'skill' | 'prompt' | 'script',
52
+ value: options.execute || `Run the ${options.name} job`,
53
+ },
54
+ };
55
+
56
+ // Validate before saving
57
+ try {
58
+ validateJob(newJob);
59
+ } catch (err: any) {
60
+ console.log(pc.red(`Invalid job: ${err.message}`));
61
+ process.exit(1);
62
+ }
63
+
64
+ jobs.push(newJob);
65
+ fs.writeFileSync(jobsFile, JSON.stringify(jobs, null, 2));
66
+
67
+ console.log(pc.green(`Job "${options.name}" (${options.slug}) added.`));
68
+ console.log(` Schedule: ${options.schedule}`);
69
+ console.log(` Priority: ${newJob.priority}`);
70
+ console.log(` Model: ${newJob.model}`);
71
+ }
72
+
73
+ export async function listJobs(_options: { dir?: string }): Promise<void> {
74
+ const config = loadConfig();
75
+
76
+ let jobs: JobDefinition[];
77
+ try {
78
+ jobs = loadJobs(config.scheduler.jobsFile);
79
+ } catch {
80
+ console.log(pc.dim('No jobs configured.'));
81
+ console.log(`Add one: ${pc.cyan("instar job add --slug health-check --name 'Health Check' --schedule '0 */4 * * *'")}`);
82
+ return;
83
+ }
84
+
85
+ if (jobs.length === 0) {
86
+ console.log(pc.dim('No jobs configured.'));
87
+ return;
88
+ }
89
+
90
+ const state = new StateManager(config.stateDir);
91
+ const enabled = jobs.filter(j => j.enabled);
92
+
93
+ console.log(pc.bold(`Jobs (${enabled.length} enabled / ${jobs.length} total):\n`));
94
+
95
+ for (const job of jobs) {
96
+ const icon = job.enabled ? pc.green('●') : pc.dim('○');
97
+ const jobState = state.getJobState(job.slug);
98
+ const lastRun = jobState?.lastRun
99
+ ? new Date(jobState.lastRun).toLocaleString()
100
+ : pc.dim('never');
101
+ const failures = jobState?.consecutiveFailures
102
+ ? pc.red(` (${jobState.consecutiveFailures} failures)`)
103
+ : '';
104
+
105
+ console.log(` ${icon} ${pc.bold(job.slug)} — ${job.name}`);
106
+ console.log(` Schedule: ${job.schedule} | Priority: ${job.priority} | Model: ${job.model}`);
107
+ console.log(` Last run: ${lastRun}${failures}`);
108
+ console.log(` Execute: ${job.execute.type}:${job.execute.value}`);
109
+ }
110
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * `instar server start|stop` — Manage the persistent agent server.
3
+ *
4
+ * Start launches the server in a tmux session (background) or foreground.
5
+ * Stop kills the server tmux session.
6
+ *
7
+ * When Telegram is configured, wires up message routing:
8
+ * topic message → find/spawn session → inject message → session replies via [telegram:N]
9
+ */
10
+
11
+ import { execSync } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import pc from 'picocolors';
15
+ import { loadConfig, ensureStateDir, detectTmuxPath } from '../core/Config.js';
16
+ import { SessionManager } from '../core/SessionManager.js';
17
+ import { StateManager } from '../core/StateManager.js';
18
+ import { JobScheduler } from '../scheduler/JobScheduler.js';
19
+ import { AgentServer } from '../server/AgentServer.js';
20
+ import { TelegramAdapter } from '../messaging/TelegramAdapter.js';
21
+ import { RelationshipManager } from '../core/RelationshipManager.js';
22
+ import type { Message } from '../core/types.js';
23
+
24
+ interface StartOptions {
25
+ foreground?: boolean;
26
+ dir?: string;
27
+ }
28
+
29
+ /**
30
+ * Respawn a session for a topic, including thread history in the bootstrap.
31
+ * This prevents "thread drift" where respawned sessions lose context.
32
+ */
33
+ async function respawnSessionForTopic(
34
+ sessionManager: SessionManager,
35
+ telegram: TelegramAdapter,
36
+ targetSession: string,
37
+ topicId: number,
38
+ latestMessage?: string,
39
+ ): Promise<void> {
40
+ console.log(`[telegram→session] Session "${targetSession}" needs respawn for topic ${topicId}`);
41
+
42
+ const msg = latestMessage || 'Session respawned — send a message to continue.';
43
+
44
+ // Fetch thread history for context
45
+ let historyLines: string[] = [];
46
+ try {
47
+ const history = telegram.getTopicHistory(topicId, 20);
48
+ if (history.length > 0) {
49
+ historyLines.push(`--- Thread History (last ${history.length} messages) ---`);
50
+ historyLines.push(`IMPORTANT: Read this history carefully before taking any action.`);
51
+ historyLines.push(`Your task is to continue THIS conversation, not start something new.`);
52
+ historyLines.push(``);
53
+ for (const m of history) {
54
+ const sender = m.fromUser ? 'User' : 'Agent';
55
+ const ts = m.timestamp ? new Date(m.timestamp).toISOString().slice(11, 19) : '??:??';
56
+ const text = (m.text || '').slice(0, 300);
57
+ historyLines.push(`[${ts}] ${sender}: ${text}`);
58
+ }
59
+ historyLines.push(``);
60
+ historyLines.push(`--- End Thread History ---`);
61
+ }
62
+ } catch (err) {
63
+ console.error(`[telegram→session] Failed to fetch thread history:`, err);
64
+ }
65
+
66
+ // Always keep the user's message inline — never hide it behind a file reference.
67
+ // Only thread history goes into a file for supplementary context.
68
+ let bootstrapMessage: string;
69
+
70
+ if (historyLines.length > 0) {
71
+ const historyContent = historyLines.join('\n');
72
+ const tmpDir = '/tmp/instar-telegram';
73
+ fs.mkdirSync(tmpDir, { recursive: true });
74
+ const filepath = path.join(tmpDir, `history-${topicId}-${Date.now()}.txt`);
75
+ fs.writeFileSync(filepath, historyContent);
76
+
77
+ bootstrapMessage = [
78
+ `[telegram:${topicId}] ${msg}`,
79
+ ``,
80
+ `(Session was respawned. Thread history is at ${filepath} — read it for context. Then RESPOND to the user's message above via Telegram relay.)`,
81
+ ].join('\n');
82
+ } else {
83
+ bootstrapMessage = `[telegram:${topicId}] ${msg}`;
84
+ }
85
+
86
+ const storedName = telegram.getTopicName(topicId);
87
+ const topicName = storedName || targetSession;
88
+ const newSessionName = await sessionManager.spawnInteractiveSession(bootstrapMessage, topicName);
89
+
90
+ telegram.registerTopicSession(topicId, newSessionName);
91
+ await telegram.sendToTopic(topicId, `Session respawned.`);
92
+ console.log(`[telegram→session] Respawned "${newSessionName}" for topic ${topicId}`);
93
+ }
94
+
95
+ /**
96
+ * Wire up Telegram message routing: topic messages → Claude sessions.
97
+ * This is the core handler that makes Telegram topics work like sessions.
98
+ */
99
+ function wireTelegramRouting(
100
+ telegram: TelegramAdapter,
101
+ sessionManager: SessionManager,
102
+ ): void {
103
+ telegram.onTopicMessage = (msg: Message) => {
104
+ const topicId = (msg.metadata?.messageThreadId as number) ?? null;
105
+ if (!topicId) return;
106
+
107
+ const text = msg.content;
108
+
109
+ // Handle /new command — spawn a new session with its own topic
110
+ const newMatch = text.match(/^\/new(?:\s+(.+))?$/);
111
+ if (newMatch) {
112
+ const sessionName = newMatch[1]?.trim() || null;
113
+ const topicName = sessionName || `session-${new Date().toISOString().slice(5, 16).replace('T', '-').replace(':', '')}`;
114
+
115
+ (async () => {
116
+ try {
117
+ const topic = await telegram.createForumTopic(topicName, 9367192); // Green
118
+ const newSession = await sessionManager.spawnInteractiveSession(
119
+ `[telegram:${topic.topicId}] New session started.`,
120
+ topicName,
121
+ );
122
+ telegram.registerTopicSession(topic.topicId, newSession);
123
+ await telegram.sendToTopic(topic.topicId, `Session created. I'm here.`);
124
+ await telegram.sendToTopic(topicId, `New session created: "${topicName}" — check the new topic above.`);
125
+ console.log(`[telegram] Spawned session "${newSession}" for new topic ${topic.topicId}`);
126
+ } catch (err) {
127
+ console.error(`[telegram] /new failed:`, err);
128
+ await telegram.sendToTopic(topicId, `Failed to spawn session: ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
129
+ }
130
+ })();
131
+ return;
132
+ }
133
+
134
+ // Route message to corresponding session
135
+ const targetSession = telegram.getSessionForTopic(topicId);
136
+
137
+ if (targetSession) {
138
+ // Session is mapped — check if it's alive, inject or respawn
139
+ if (sessionManager.isSessionAlive(targetSession)) {
140
+ console.log(`[telegram→session] Injecting into ${targetSession}: "${text.slice(0, 80)}"`);
141
+ sessionManager.injectTelegramMessage(targetSession, topicId, text);
142
+ } else {
143
+ // Session died — respawn with thread history
144
+ respawnSessionForTopic(sessionManager, telegram, targetSession, topicId, text).catch(err => {
145
+ console.error(`[telegram→session] Respawn failed:`, err);
146
+ });
147
+ }
148
+ } else {
149
+ // No session mapped — auto-spawn one
150
+ console.log(`[telegram→session] No session for topic ${topicId}, auto-spawning...`);
151
+ const storedName = telegram.getTopicName(topicId) || `topic-${topicId}`;
152
+
153
+ // Always keep the user's message inline — never hide it behind a file reference
154
+ const bootstrapMessage = [
155
+ `[telegram:${topicId}] ${text}`,
156
+ ``,
157
+ `(This session was auto-created for a Telegram topic. Respond to the user's message above via Telegram relay.)`,
158
+ ].join('\n');
159
+
160
+ sessionManager.spawnInteractiveSession(bootstrapMessage, storedName).then((newSessionName) => {
161
+ telegram.registerTopicSession(topicId, newSessionName);
162
+ telegram.sendToTopic(topicId, `Session auto-created. I'm here.`).catch(() => {});
163
+ console.log(`[telegram→session] Auto-spawned "${newSessionName}" for topic ${topicId}`);
164
+ }).catch((err) => {
165
+ console.error(`[telegram→session] Auto-spawn failed:`, err);
166
+ telegram.sendToTopic(topicId, `Failed to create session: ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
167
+ });
168
+ }
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Ensure the Agent Attention topic exists — the agent's direct line to the user.
174
+ * Created once on first server start, persisted in state.
175
+ */
176
+ async function ensureAgentAttentionTopic(
177
+ telegram: TelegramAdapter,
178
+ state: StateManager,
179
+ ): Promise<void> {
180
+ const existingTopicId = state.get<number>('agent-attention-topic');
181
+ if (existingTopicId) {
182
+ console.log(` Agent Attention topic: ${existingTopicId}`);
183
+ return;
184
+ }
185
+
186
+ try {
187
+ const topic = await telegram.createForumTopic(
188
+ 'Agent Attention',
189
+ 9367192, // Green — direct line to user
190
+ );
191
+ state.set('agent-attention-topic', topic.topicId);
192
+ await telegram.sendToTopic(topic.topicId,
193
+ `This is your agent's direct line to you.\n\nInfrastructure issues, proactive observations, relationship insights, and anything that doesn't fit into another topic will appear here.`
194
+ );
195
+ console.log(pc.green(` Created Agent Attention topic: ${topic.topicId}`));
196
+ } catch (err) {
197
+ console.error(` Failed to create Agent Attention topic: ${err}`);
198
+ }
199
+ }
200
+
201
+ export async function startServer(options: StartOptions): Promise<void> {
202
+ const config = loadConfig(options.dir);
203
+ ensureStateDir(config.stateDir);
204
+
205
+ const serverSessionName = `${config.projectName}-server`;
206
+
207
+ if (options.foreground) {
208
+ // Run in foreground — useful for development
209
+ console.log(pc.bold(`Starting instar server for ${pc.cyan(config.projectName)}`));
210
+ console.log(` Port: ${config.port}`);
211
+ console.log(` State: ${config.stateDir}`);
212
+ console.log();
213
+
214
+ const state = new StateManager(config.stateDir);
215
+ const sessionManager = new SessionManager(config.sessions, state);
216
+ const relationships = new RelationshipManager(config.relationships);
217
+ console.log(pc.green(` Relationships loaded: ${relationships.getAll().length} tracked`));
218
+
219
+ let scheduler: JobScheduler | undefined;
220
+ if (config.scheduler.enabled) {
221
+ scheduler = new JobScheduler(config.scheduler, sessionManager, state);
222
+ scheduler.start();
223
+ console.log(pc.green(' Scheduler started'));
224
+ }
225
+
226
+ // Set up Telegram if configured
227
+ let telegram: TelegramAdapter | undefined;
228
+ const telegramConfig = config.messaging.find(m => m.type === 'telegram' && m.enabled);
229
+ if (telegramConfig) {
230
+ telegram = new TelegramAdapter(telegramConfig.config as any, config.stateDir);
231
+ await telegram.start();
232
+ console.log(pc.green(' Telegram connected'));
233
+
234
+ // Wire up topic → session routing
235
+ wireTelegramRouting(telegram, sessionManager);
236
+ console.log(pc.green(' Telegram message routing active'));
237
+
238
+ if (scheduler) {
239
+ scheduler.setMessenger(telegram);
240
+ scheduler.setTelegram(telegram);
241
+ }
242
+
243
+ // Ensure Agent Attention topic exists (the agent's direct line to the user)
244
+ ensureAgentAttentionTopic(telegram, state).catch(err => {
245
+ console.error(`[server] Failed to ensure Agent Attention topic: ${err}`);
246
+ });
247
+ }
248
+
249
+ sessionManager.startMonitoring();
250
+ if (scheduler) {
251
+ sessionManager.on('sessionComplete', (session) => {
252
+ scheduler!.processQueue();
253
+ scheduler!.notifyJobComplete(session.id, session.tmuxSession);
254
+ });
255
+ }
256
+
257
+ const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships });
258
+ await server.start();
259
+
260
+ // Graceful shutdown
261
+ const shutdown = async () => {
262
+ console.log('\nShutting down...');
263
+ scheduler?.stop();
264
+ if (telegram) await telegram.stop();
265
+ sessionManager.stopMonitoring();
266
+ await server.stop();
267
+ process.exit(0);
268
+ };
269
+
270
+ process.on('SIGINT', shutdown);
271
+ process.on('SIGTERM', shutdown);
272
+ } else {
273
+ // Run in tmux background session
274
+ const tmuxPath = detectTmuxPath();
275
+ if (!tmuxPath) {
276
+ console.log(pc.red('tmux not found. Use --foreground to run without tmux.'));
277
+ process.exit(1);
278
+ }
279
+
280
+ // Check if already running
281
+ try {
282
+ execSync(`${tmuxPath} has-session -t '=${serverSessionName}' 2>/dev/null`);
283
+ console.log(pc.yellow(`Server already running in tmux session: ${serverSessionName}`));
284
+ console.log(` Attach with: tmux attach -t '=${serverSessionName}'`);
285
+ return;
286
+ } catch {
287
+ // Not running — good
288
+ }
289
+
290
+ // Get the path to the CLI entry point
291
+ const cliPath = new URL('../cli.js', import.meta.url).pathname;
292
+
293
+ const nodeCmd = `node '${cliPath}' server start --foreground`;
294
+ const cmd = `${tmuxPath} new-session -d -s '${serverSessionName}' -c '${config.projectDir}' '${nodeCmd}'`;
295
+
296
+ try {
297
+ execSync(cmd);
298
+ console.log(pc.green(`Server started in tmux session: ${pc.bold(serverSessionName)}`));
299
+ console.log(` Port: ${config.port}`);
300
+ console.log(` Attach: tmux attach -t '=${serverSessionName}'`);
301
+ console.log(` Health: curl http://localhost:${config.port}/health`);
302
+ } catch (err) {
303
+ console.log(pc.red(`Failed to start server: ${err}`));
304
+ process.exit(1);
305
+ }
306
+ }
307
+ }
308
+
309
+ export async function stopServer(options: { dir?: string }): Promise<void> {
310
+ const config = loadConfig(options.dir);
311
+ const serverSessionName = `${config.projectName}-server`;
312
+ const tmuxPath = detectTmuxPath();
313
+
314
+ if (!tmuxPath) {
315
+ console.log(pc.red('tmux not found'));
316
+ process.exit(1);
317
+ }
318
+
319
+ try {
320
+ execSync(`${tmuxPath} kill-session -t '=${serverSessionName}'`);
321
+ console.log(pc.green(`Server stopped (killed tmux session: ${serverSessionName})`));
322
+ } catch {
323
+ console.log(pc.yellow(`No server running (no tmux session: ${serverSessionName})`));
324
+ }
325
+ }