@agentuity/cli 0.0.102 → 0.0.104

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 (51) hide show
  1. package/bin/cli.ts +12 -12
  2. package/dist/cmd/build/entry-generator.d.ts.map +1 -1
  3. package/dist/cmd/build/entry-generator.js +5 -5
  4. package/dist/cmd/build/entry-generator.js.map +1 -1
  5. package/dist/cmd/build/vite/agent-discovery.d.ts.map +1 -1
  6. package/dist/cmd/build/vite/agent-discovery.js +41 -2
  7. package/dist/cmd/build/vite/agent-discovery.js.map +1 -1
  8. package/dist/cmd/build/vite/bun-dev-server.d.ts.map +1 -1
  9. package/dist/cmd/build/vite/bun-dev-server.js +5 -27
  10. package/dist/cmd/build/vite/bun-dev-server.js.map +1 -1
  11. package/dist/cmd/build/vite/server-bundler.js +1 -1
  12. package/dist/cmd/build/vite/server-bundler.js.map +1 -1
  13. package/dist/cmd/build/vite/vite-asset-server.d.ts.map +1 -1
  14. package/dist/cmd/build/vite/vite-asset-server.js +53 -4
  15. package/dist/cmd/build/vite/vite-asset-server.js.map +1 -1
  16. package/dist/cmd/build/vite/vite-builder.d.ts.map +1 -1
  17. package/dist/cmd/build/vite/vite-builder.js +3 -0
  18. package/dist/cmd/build/vite/vite-builder.js.map +1 -1
  19. package/dist/cmd/cloud/deploy.d.ts.map +1 -1
  20. package/dist/cmd/cloud/deploy.js +3 -1
  21. package/dist/cmd/cloud/deploy.js.map +1 -1
  22. package/dist/cmd/dev/dev-lock.d.ts +62 -0
  23. package/dist/cmd/dev/dev-lock.d.ts.map +1 -0
  24. package/dist/cmd/dev/dev-lock.js +250 -0
  25. package/dist/cmd/dev/dev-lock.js.map +1 -0
  26. package/dist/cmd/dev/index.d.ts.map +1 -1
  27. package/dist/cmd/dev/index.js +91 -22
  28. package/dist/cmd/dev/index.js.map +1 -1
  29. package/dist/cmd/dev/sync.js +6 -6
  30. package/dist/cmd/dev/sync.js.map +1 -1
  31. package/dist/cmd/project/download.d.ts +1 -0
  32. package/dist/cmd/project/download.d.ts.map +1 -1
  33. package/dist/cmd/project/download.js +7 -5
  34. package/dist/cmd/project/download.js.map +1 -1
  35. package/dist/cmd/project/template-flow.d.ts.map +1 -1
  36. package/dist/cmd/project/template-flow.js +3 -1
  37. package/dist/cmd/project/template-flow.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/cmd/ai/prompt/web.md +47 -47
  40. package/src/cmd/build/entry-generator.ts +5 -6
  41. package/src/cmd/build/vite/agent-discovery.ts +45 -3
  42. package/src/cmd/build/vite/bun-dev-server.ts +5 -32
  43. package/src/cmd/build/vite/server-bundler.ts +1 -1
  44. package/src/cmd/build/vite/vite-asset-server.ts +54 -4
  45. package/src/cmd/build/vite/vite-builder.ts +4 -1
  46. package/src/cmd/cloud/deploy.ts +6 -2
  47. package/src/cmd/dev/dev-lock.ts +332 -0
  48. package/src/cmd/dev/index.ts +116 -30
  49. package/src/cmd/dev/sync.ts +6 -6
  50. package/src/cmd/project/download.ts +8 -6
  51. package/src/cmd/project/template-flow.ts +4 -1
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Dev Lock Manager
3
+ *
4
+ * Manages a lockfile to track the dev server process and its children.
5
+ * On startup, detects and cleans up stale processes from previous sessions.
6
+ * Ensures proper cleanup on all exit paths.
7
+ */
8
+
9
+ import { join, dirname } from 'node:path';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { existsSync, unlinkSync } from 'node:fs';
12
+ import { promises as fs } from 'node:fs';
13
+
14
+ interface LoggerLike {
15
+ debug: (msg: string, ...args: unknown[]) => void;
16
+ warn: (msg: string, ...args: unknown[]) => void;
17
+ error: (msg: string, ...args: unknown[]) => void;
18
+ }
19
+
20
+ /**
21
+ * Lockfile format for tracking dev server processes
22
+ */
23
+ export interface DevLockFileV1 {
24
+ version: 1;
25
+ projectRoot: string;
26
+ mainPid: number;
27
+ instanceId: string;
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ ports: {
31
+ bun?: number;
32
+ vite?: number;
33
+ gravity?: number;
34
+ };
35
+ children: Array<{
36
+ pid: number;
37
+ type: 'gravity' | 'vite' | 'other';
38
+ description?: string;
39
+ }>;
40
+ }
41
+
42
+ export interface DevLockManager {
43
+ state: DevLockFileV1;
44
+ registerChild: (info: {
45
+ pid: number;
46
+ type: 'gravity' | 'vite' | 'other';
47
+ description?: string;
48
+ }) => Promise<void>;
49
+ updatePorts: (ports: Partial<DevLockFileV1['ports']>) => Promise<void>;
50
+ release: () => Promise<void>;
51
+ }
52
+
53
+ function getLockPath(rootDir: string): string {
54
+ return join(rootDir, '.agentuity', 'devserver.lock');
55
+ }
56
+
57
+ /**
58
+ * Check if a process with the given PID exists
59
+ */
60
+ function pidExists(pid: number): boolean {
61
+ if (!Number.isInteger(pid) || pid <= 0) return false;
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ } catch (err: unknown) {
66
+ const error = err as NodeJS.ErrnoException;
67
+ if (error.code === 'ESRCH' || error.code === 'EINVAL') return false;
68
+ // EPERM means it exists but we can't signal it
69
+ return error.code === 'EPERM';
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Kill a process by PID with SIGTERM, then SIGKILL if still alive
75
+ */
76
+ async function killPid(pid: number, logger: LoggerLike): Promise<void> {
77
+ if (!pidExists(pid)) return;
78
+
79
+ try {
80
+ process.kill(pid, 'SIGTERM');
81
+ logger.debug('Sent SIGTERM to pid %d', pid);
82
+ } catch (err: unknown) {
83
+ const error = err as NodeJS.ErrnoException;
84
+ if (error.code === 'ESRCH') return;
85
+ logger.debug('Error sending SIGTERM to pid %d: %s', pid, error.message);
86
+ }
87
+
88
+ // Give it a moment to exit gracefully
89
+ await new Promise((r) => setTimeout(r, 500));
90
+
91
+ if (!pidExists(pid)) return;
92
+
93
+ // Force kill
94
+ try {
95
+ process.kill(pid, 'SIGKILL');
96
+ logger.debug('Sent SIGKILL to pid %d', pid);
97
+ } catch (err: unknown) {
98
+ const error = err as NodeJS.ErrnoException;
99
+ if (error.code !== 'ESRCH') {
100
+ logger.debug('Error sending SIGKILL to pid %d: %s', pid, error.message);
101
+ }
102
+ }
103
+
104
+ // Wait for process to fully terminate
105
+ await new Promise((r) => setTimeout(r, 100));
106
+ }
107
+
108
+ /**
109
+ * Read an existing lockfile (if any)
110
+ */
111
+ async function readLock(lockPath: string, logger: LoggerLike): Promise<DevLockFileV1 | null> {
112
+ if (!existsSync(lockPath)) return null;
113
+ try {
114
+ const raw = await fs.readFile(lockPath, 'utf8');
115
+ const parsed = JSON.parse(raw);
116
+ if (parsed && parsed.version === 1) return parsed as DevLockFileV1;
117
+ } catch (err) {
118
+ logger.warn('Failed to read/parse devserver.lock: %s', err);
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Remove lockfile if it exists
125
+ */
126
+ async function removeLock(lockPath: string, logger: LoggerLike): Promise<void> {
127
+ try {
128
+ await fs.unlink(lockPath);
129
+ logger.debug('Removed devserver.lock');
130
+ } catch (err: unknown) {
131
+ const error = err as NodeJS.ErrnoException;
132
+ if (error.code !== 'ENOENT') {
133
+ logger.debug('Failed to remove devserver.lock: %s', error.message);
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Check if a port is in use by attempting to connect to it.
140
+ * Uses GET instead of HEAD since some servers return 405 for HEAD requests.
141
+ * Any response (including errors like 404, 500) means the port is in use.
142
+ */
143
+ async function isPortResponding(port: number): Promise<boolean> {
144
+ try {
145
+ const response = await fetch(`http://127.0.0.1:${port}/`, {
146
+ method: 'GET',
147
+ signal: AbortSignal.timeout(500),
148
+ });
149
+ // Consume body to avoid memory leaks
150
+ await response.text().catch(() => {});
151
+ return true;
152
+ } catch (err: unknown) {
153
+ // Connection refused (ECONNREFUSED) means nothing is listening
154
+ // Other errors (timeout, reset) might indicate a busy port
155
+ const error = err as Error & { cause?: { code?: string } };
156
+ const code = error.cause?.code;
157
+ if (code === 'ECONNREFUSED' || code === 'ECONNRESET') {
158
+ return false;
159
+ }
160
+ // For other errors (like timeout), assume port might be in use but unresponsive
161
+ return false;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Kill processes referenced by a stale lock, then remove the lock
167
+ */
168
+ async function cleanupStaleLock(
169
+ rootDir: string,
170
+ lock: DevLockFileV1,
171
+ logger: LoggerLike
172
+ ): Promise<void> {
173
+ const lockPath = getLockPath(rootDir);
174
+ logger.debug(
175
+ 'Cleaning up stale devserver.lock (pid=%d, instance=%s)',
176
+ lock.mainPid,
177
+ lock.instanceId
178
+ );
179
+
180
+ // Collect all PIDs to kill (children first, then main)
181
+ const childPids: number[] = [];
182
+ for (const child of lock.children ?? []) {
183
+ if (child.pid && child.pid !== lock.mainPid && child.pid !== process.pid) {
184
+ childPids.push(child.pid);
185
+ }
186
+ }
187
+
188
+ // Kill children first
189
+ for (const pid of childPids) {
190
+ await killPid(pid, logger);
191
+ }
192
+
193
+ // Kill main process if it's not us
194
+ if (lock.mainPid !== process.pid) {
195
+ await killPid(lock.mainPid, logger);
196
+ }
197
+
198
+ // Remove the stale lockfile
199
+ await removeLock(lockPath, logger);
200
+ }
201
+
202
+ /**
203
+ * Ensure there is no conflicting dev server for this project
204
+ * Always cleans up any existing lock and kills associated processes
205
+ */
206
+ async function ensureNoActiveDevForProject(
207
+ rootDir: string,
208
+ port: number,
209
+ logger: LoggerLike
210
+ ): Promise<void> {
211
+ const lockPath = getLockPath(rootDir);
212
+ const existing = await readLock(lockPath, logger);
213
+ if (!existing) return;
214
+
215
+ const now = Date.now();
216
+ const createdAt = Date.parse(existing.createdAt || '');
217
+ const ageMs = isFinite(createdAt) ? now - createdAt : Infinity;
218
+
219
+ const mainAlive = pidExists(existing.mainPid);
220
+
221
+ // Check if the recorded Bun port is still responding
222
+ let bunPortInUse = false;
223
+ if (existing.ports?.bun) {
224
+ bunPortInUse = await isPortResponding(existing.ports.bun);
225
+ }
226
+
227
+ logger.debug(
228
+ 'Found existing lock (pid=%d, mainAlive=%s, bunPortInUse=%s, age=%dms) - cleaning up',
229
+ existing.mainPid,
230
+ mainAlive,
231
+ bunPortInUse,
232
+ ageMs
233
+ );
234
+
235
+ await cleanupStaleLock(rootDir, existing, logger);
236
+ }
237
+
238
+ /**
239
+ * Initialize a new lock for the current dev run
240
+ * This should be called after ensureNoActiveDevForProject has possibly cleaned stale state
241
+ */
242
+ async function initDevLock(
243
+ rootDir: string,
244
+ port: number,
245
+ logger: LoggerLike
246
+ ): Promise<DevLockManager> {
247
+ const lockPath = getLockPath(rootDir);
248
+ await fs.mkdir(dirname(lockPath), { recursive: true });
249
+
250
+ const state: DevLockFileV1 = {
251
+ version: 1,
252
+ projectRoot: rootDir,
253
+ mainPid: process.pid,
254
+ instanceId: randomUUID(),
255
+ createdAt: new Date().toISOString(),
256
+ updatedAt: new Date().toISOString(),
257
+ ports: { bun: port },
258
+ children: [],
259
+ };
260
+
261
+ const writeLock = async () => {
262
+ state.updatedAt = new Date().toISOString();
263
+ await fs.writeFile(lockPath, JSON.stringify(state, null, 2), { encoding: 'utf8' });
264
+ };
265
+
266
+ await writeLock();
267
+ logger.debug('Created devserver.lock (pid=%d, instance=%s)', state.mainPid, state.instanceId);
268
+
269
+ const manager: DevLockManager = {
270
+ state,
271
+
272
+ async registerChild(child) {
273
+ if (!child.pid) return;
274
+ // Avoid duplicates
275
+ if (state.children.some((c) => c.pid === child.pid)) return;
276
+ state.children.push(child);
277
+ await writeLock();
278
+ logger.debug('Registered child process (pid=%d, type=%s)', child.pid, child.type);
279
+ },
280
+
281
+ async updatePorts(ports) {
282
+ state.ports = { ...state.ports, ...ports };
283
+ await writeLock();
284
+ },
285
+
286
+ async release() {
287
+ await removeLock(lockPath, logger);
288
+ },
289
+ };
290
+
291
+ return manager;
292
+ }
293
+
294
+ /**
295
+ * Main entry point for dev lock management
296
+ * Call this early in the dev command to:
297
+ * 1. Clean up any stale processes from previous sessions
298
+ * 2. Create a new lockfile for this session
299
+ */
300
+ export async function prepareDevLock(
301
+ rootDir: string,
302
+ port: number,
303
+ logger: LoggerLike
304
+ ): Promise<DevLockManager> {
305
+ await ensureNoActiveDevForProject(rootDir, port, logger);
306
+ return initDevLock(rootDir, port, logger);
307
+ }
308
+
309
+ /**
310
+ * Utility to kill all processes in a lockfile by path
311
+ * Useful for emergency cleanup without creating a new lock
312
+ */
313
+ export async function cleanupLockfile(rootDir: string, logger: LoggerLike): Promise<void> {
314
+ const lockPath = getLockPath(rootDir);
315
+ const existing = await readLock(lockPath, logger);
316
+ if (existing) {
317
+ await cleanupStaleLock(rootDir, existing, logger);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Synchronous lockfile removal for use in process.on('exit') handlers
323
+ * Does not kill processes - just removes the file
324
+ */
325
+ export function releaseLockSync(rootDir: string): void {
326
+ const lockPath = getLockPath(rootDir);
327
+ try {
328
+ unlinkSync(lockPath);
329
+ } catch {
330
+ // Ignore errors - file may already be gone
331
+ }
332
+ }
@@ -16,6 +16,7 @@ import { getDefaultConfigDir, saveConfig } from '../../config';
16
16
  import type { Config } from '../../types';
17
17
  import { createFileWatcher } from './file-watcher';
18
18
  import { regenerateSkillsAsync } from './skills';
19
+ import { prepareDevLock, releaseLockSync } from './dev-lock';
19
20
 
20
21
  const DEFAULT_PORT = 3500;
21
22
  const MIN_PORT = 1024;
@@ -42,9 +43,9 @@ interface BunServer {
42
43
  * Kill any lingering gravity processes from previous dev sessions.
43
44
  * This is a defensive measure to clean up orphaned processes.
44
45
  */
45
- async function killLingeringGravityProcesses(
46
- logger: { debug: (msg: string, ...args: unknown[]) => void }
47
- ): Promise<void> {
46
+ async function killLingeringGravityProcesses(logger: {
47
+ debug: (msg: string, ...args: unknown[]) => void;
48
+ }): Promise<void> {
48
49
  // Only attempt on Unix-like systems (macOS, Linux)
49
50
  if (process.platform === 'win32') {
50
51
  return;
@@ -72,7 +73,7 @@ async function killLingeringGravityProcesses(
72
73
 
73
74
  /**
74
75
  * Stop the existing Bun server if one is running.
75
- * Waits for the port to become available before returning.
76
+ * Waits for the port to become available before returning (with timeout).
76
77
  */
77
78
  async function stopBunServer(
78
79
  port: number,
@@ -81,26 +82,32 @@ async function stopBunServer(
81
82
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
83
  const globalAny = globalThis as any;
83
84
  const server = globalAny.__AGENTUITY_SERVER__ as BunServer | undefined;
84
- if (!server) return;
85
+ if (!server) {
86
+ logger.debug('No Bun server to stop');
87
+ return;
88
+ }
85
89
 
86
90
  try {
87
- logger.debug('Stopping previous Bun server...');
91
+ logger.debug('Stopping Bun server...');
88
92
  server.stop(true); // Close active connections immediately
93
+ logger.debug('Bun server stop() called');
89
94
  } catch (err) {
90
- logger.debug('Error stopping previous Bun server: %s', err);
95
+ logger.debug('Error stopping Bun server: %s', err);
91
96
  }
92
97
 
93
- // Wait for socket to close to avoid EADDRINUSE races
94
- for (let i = 0; i < 20; i++) {
98
+ // Wait for socket to close (max 2 seconds to avoid hanging on shutdown)
99
+ const MAX_WAIT_ITERATIONS = 10;
100
+ for (let i = 0; i < MAX_WAIT_ITERATIONS; i++) {
95
101
  try {
96
102
  await fetch(`http://127.0.0.1:${port}/`, {
97
103
  method: 'HEAD',
98
- signal: AbortSignal.timeout(200),
104
+ signal: AbortSignal.timeout(150),
99
105
  });
100
106
  // Still responding, wait a bit more
101
107
  await new Promise((r) => setTimeout(r, 50));
102
108
  } catch {
103
109
  // Connection refused or timeout => server is down
110
+ logger.debug('Bun server stopped');
104
111
  break;
105
112
  }
106
113
  }
@@ -196,8 +203,12 @@ export const command = createCommand({
196
203
  originalExit(1);
197
204
  }
198
205
 
206
+ // Prepare dev lock: cleans up stale processes from previous sessions
207
+ // and creates a new lockfile for this session
208
+ const devLock = await prepareDevLock(rootDir, opts.port, logger);
209
+
199
210
  // Kill any lingering gravity processes from previous dev sessions
200
- // This prevents "zombie" gravity clients from blocking the public URL
211
+ // This is a fallback for cases where the lockfile was corrupted
201
212
  await killLingeringGravityProcesses(logger);
202
213
 
203
214
  // Setup devmode and gravity (if using public URL)
@@ -336,11 +347,16 @@ export const command = createCommand({
336
347
  });
337
348
  viteServer = viteResult.server;
338
349
  vitePort = viteResult.port;
350
+
351
+ // Update dev lock with actual Vite port
352
+ await devLock.updatePorts({ vite: vitePort });
353
+
339
354
  logger.debug(
340
355
  `Vite asset server running on port ${vitePort} (stays running across backend restarts)`
341
356
  );
342
357
  } catch (error) {
343
358
  tui.error(`Failed to start Vite asset server: ${error}`);
359
+ await devLock.release();
344
360
  originalExit(1);
345
361
  return;
346
362
  }
@@ -371,6 +387,8 @@ export const command = createCommand({
371
387
 
372
388
  // Track if cleanup is in progress to avoid duplicate cleanup
373
389
  let cleaningUp = false;
390
+ // Track if shutdown was requested (SIGINT/SIGTERM) to break the main loop
391
+ let shutdownRequested = false;
374
392
 
375
393
  /**
376
394
  * Centralized cleanup function for all resources.
@@ -403,6 +421,7 @@ export const command = createCommand({
403
421
 
404
422
  // Kill gravity client with SIGTERM first, then SIGKILL as fallback
405
423
  if (gravityProcess) {
424
+ logger.debug('Killing gravity process...');
406
425
  try {
407
426
  gravityProcess.kill('SIGTERM');
408
427
  // Give it a moment to gracefully shutdown
@@ -410,6 +429,7 @@ export const command = createCommand({
410
429
  if (gravityProcess.exitCode === null) {
411
430
  gravityProcess.kill('SIGKILL');
412
431
  }
432
+ logger.debug('Gravity process killed');
413
433
  } catch (err) {
414
434
  logger.debug('Error killing gravity process: %s', err);
415
435
  } finally {
@@ -417,10 +437,20 @@ export const command = createCommand({
417
437
  }
418
438
  }
419
439
 
420
- // Close Vite asset server last (it handles frontend, should stay up longest)
440
+ // Close Vite asset server with timeout to prevent hanging
421
441
  if (viteServer) {
442
+ logger.debug('Closing Vite server...');
422
443
  try {
423
- await viteServer.close();
444
+ // Use Promise.race with timeout to prevent hanging
445
+ const closePromise = viteServer.close();
446
+ const timeoutPromise = new Promise<void>((resolve) => {
447
+ setTimeout(() => {
448
+ logger.debug('Vite server close timed out, continuing...');
449
+ resolve();
450
+ }, 2000);
451
+ });
452
+ await Promise.race([closePromise, timeoutPromise]);
453
+ logger.debug('Vite server closed');
424
454
  } catch (err) {
425
455
  logger.debug('Error closing Vite server: %s', err);
426
456
  } finally {
@@ -428,10 +458,20 @@ export const command = createCommand({
428
458
  }
429
459
  }
430
460
 
461
+ // Release the dev lockfile
462
+ logger.debug('Releasing dev lock...');
463
+ try {
464
+ await devLock.release();
465
+ logger.debug('Dev lock released');
466
+ } catch (err) {
467
+ logger.debug('Error releasing dev lock: %s', err);
468
+ }
469
+
431
470
  // Reset cleanup flag if not exiting (allows restart)
432
471
  if (!exitAfter) {
433
472
  cleaningUp = false;
434
473
  } else {
474
+ logger.debug('Exiting with code %d', exitCode);
435
475
  originalExit(exitCode);
436
476
  }
437
477
  };
@@ -474,6 +514,7 @@ export const command = createCommand({
474
514
  if (reason) {
475
515
  logger.debug('DevMode terminating (%d) due to: %s', code, reason);
476
516
  }
517
+ shutdownRequested = true;
477
518
  await cleanup(true, code);
478
519
  };
479
520
 
@@ -488,7 +529,7 @@ export const command = createCommand({
488
529
  // Handle uncaught exceptions - clean up and exit rather than limping on
489
530
  process.on('uncaughtException', (err) => {
490
531
  tui.error(
491
- `Uncaught exception: ${err instanceof Error ? err.stack ?? err.message : String(err)}`
532
+ `Uncaught exception: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`
492
533
  );
493
534
  void safeExit(1, 'uncaughtException');
494
535
  });
@@ -497,7 +538,7 @@ export const command = createCommand({
497
538
  process.on('unhandledRejection', (reason) => {
498
539
  logger.warn(
499
540
  'Unhandled promise rejection: %s',
500
- reason instanceof Error ? reason.stack ?? reason.message : String(reason)
541
+ reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)
501
542
  );
502
543
  });
503
544
  }
@@ -532,9 +573,12 @@ export const command = createCommand({
532
573
  // Ignore errors during exit cleanup
533
574
  }
534
575
  }
576
+
577
+ // Release the dev lockfile synchronously
578
+ releaseLockSync(rootDir);
535
579
  });
536
580
 
537
- while (true) {
581
+ while (!shutdownRequested) {
538
582
  shouldRestart = false;
539
583
 
540
584
  // Pause file watcher during build to avoid loops
@@ -589,10 +633,16 @@ export const command = createCommand({
589
633
 
590
634
  const srcDir = join(rootDir, 'src');
591
635
 
636
+ const promises: Promise<void>[] = [];
637
+
592
638
  // Generate/update prompt files (non-blocking)
593
- import('../build/vite/prompt-generator')
594
- .then(({ generatePromptFiles }) => generatePromptFiles(srcDir, logger))
595
- .catch((err) => logger.warn('Failed to generate prompt files: %s', err.message));
639
+ promises.push(
640
+ import('../build/vite/prompt-generator')
641
+ .then(({ generatePromptFiles }) => generatePromptFiles(srcDir, logger))
642
+ .catch((err) =>
643
+ logger.warn('Failed to generate prompt files: %s', err.message)
644
+ )
645
+ );
596
646
  const agents = await discoverAgents(
597
647
  srcDir,
598
648
  project?.projectId ?? '',
@@ -621,14 +671,17 @@ export const command = createCommand({
621
671
 
622
672
  // Sync metadata with backend (creates agents and evals in the database)
623
673
  if (syncService && project?.projectId) {
624
- await syncService.sync(
625
- metadata,
626
- previousMetadata,
627
- project.projectId,
628
- deploymentId
674
+ promises.push(
675
+ syncService.sync(
676
+ metadata,
677
+ previousMetadata,
678
+ project.projectId,
679
+ deploymentId
680
+ )
629
681
  );
630
682
  previousMetadata = metadata;
631
683
  }
684
+ await Promise.all(promises);
632
685
  },
633
686
  clearOnSuccess: true,
634
687
  });
@@ -694,22 +747,35 @@ export const command = createCommand({
694
747
  // Wait for app.ts to finish loading (Vite is ready but app may still be initializing)
695
748
  // Give it 2 seconds to ensure app initialization completes
696
749
  await new Promise((resolve) => setTimeout(resolve, 2000));
750
+
751
+ // Check if shutdown was requested during startup
752
+ if (shutdownRequested) {
753
+ break;
754
+ }
697
755
  } catch (error) {
698
756
  tui.error(`Failed to start dev server: ${error}`);
699
757
  tui.warn('Waiting for file changes to retry...');
700
758
 
701
- // Wait for next restart trigger
759
+ // Wait for next restart trigger or shutdown
702
760
  await new Promise<void>((resolve) => {
703
761
  const checkRestart = setInterval(() => {
704
- if (shouldRestart) {
762
+ if (shouldRestart || shutdownRequested) {
705
763
  clearInterval(checkRestart);
706
764
  resolve();
707
765
  }
708
766
  }, 100);
709
767
  });
768
+ if (shutdownRequested) {
769
+ break;
770
+ }
710
771
  continue;
711
772
  }
712
773
 
774
+ // Exit early if shutdown was requested
775
+ if (shutdownRequested) {
776
+ break;
777
+ }
778
+
713
779
  try {
714
780
  // Start gravity client if we have devmode
715
781
  if (gravityBin && gravityURL && devmode) {
@@ -734,6 +800,16 @@ export const command = createCommand({
734
800
  }
735
801
  );
736
802
 
803
+ // Register gravity process in dev lock for cleanup tracking
804
+ const gravityPid = (gravityProcess as { pid?: number }).pid;
805
+ if (gravityPid) {
806
+ await devLock.registerChild({
807
+ pid: gravityPid,
808
+ type: 'gravity',
809
+ description: 'Gravity public URL tunnel',
810
+ });
811
+ }
812
+
737
813
  // Log gravity output
738
814
  (async () => {
739
815
  try {
@@ -824,16 +900,21 @@ export const command = createCommand({
824
900
  // Start/resume file watcher now that server is ready
825
901
  fileWatcher.resume();
826
902
 
827
- // Wait for restart signal
903
+ // Wait for restart signal or shutdown
828
904
  await new Promise<void>((resolve) => {
829
905
  const checkRestart = setInterval(() => {
830
- if (shouldRestart) {
906
+ if (shouldRestart || shutdownRequested) {
831
907
  clearInterval(checkRestart);
832
908
  resolve();
833
909
  }
834
910
  }, 100);
835
911
  });
836
912
 
913
+ // Exit loop if shutdown was requested
914
+ if (shutdownRequested) {
915
+ break;
916
+ }
917
+
837
918
  // Restart triggered - cleanup and loop (Vite stays running)
838
919
  logger.debug('Restarting backend server...');
839
920
 
@@ -849,13 +930,18 @@ export const command = createCommand({
849
930
  // Cleanup on error (Vite stays running)
850
931
  await cleanupForRestart();
851
932
 
933
+ // Exit if shutdown was requested during error handling
934
+ if (shutdownRequested) {
935
+ break;
936
+ }
937
+
852
938
  // Resume file watcher to detect changes for retry
853
939
  fileWatcher.resume();
854
940
 
855
- // Wait for next restart trigger
941
+ // Wait for next restart trigger or shutdown
856
942
  await new Promise<void>((resolve) => {
857
943
  const checkRestart = setInterval(() => {
858
- if (shouldRestart) {
944
+ if (shouldRestart || shutdownRequested) {
859
945
  clearInterval(checkRestart);
860
946
  resolve();
861
947
  }