@amodalai/amodal 0.3.49 → 0.3.51

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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # @amodalai/amodal
2
2
 
3
+ ## 0.3.51
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [427bd23]
8
+ - @amodalai/studio@0.3.51
9
+ - @amodalai/types@0.3.51
10
+ - @amodalai/core@0.3.51
11
+ - @amodalai/runtime@0.3.51
12
+ - @amodalai/runtime-app@0.3.51
13
+ - @amodalai/db@0.3.51
14
+
15
+ ## 0.3.50
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [1a0732b]
20
+ - Updated dependencies [1a0732b]
21
+ - Updated dependencies [1a0732b]
22
+ - Updated dependencies [1a0732b]
23
+ - Updated dependencies [1a0732b]
24
+ - Updated dependencies [1a0732b]
25
+ - Updated dependencies [1a0732b]
26
+ - Updated dependencies [1a0732b]
27
+ - Updated dependencies [1a0732b]
28
+ - Updated dependencies [1a0732b]
29
+ - Updated dependencies [1a0732b]
30
+ - @amodalai/types@0.3.50
31
+ - @amodalai/runtime@0.3.50
32
+ - @amodalai/studio@0.3.50
33
+ - @amodalai/core@0.3.50
34
+ - @amodalai/db@0.3.50
35
+ - @amodalai/runtime-app@0.3.50
36
+
3
37
  ## 0.3.49
4
38
 
5
39
  ### Patch Changes
@@ -7,8 +7,6 @@ import type { CommandModule } from 'yargs';
7
7
  export interface DevOptions {
8
8
  cwd?: string;
9
9
  port?: number;
10
- studioPort?: number;
11
- adminPort?: number;
12
10
  host?: string;
13
11
  resume?: string;
14
12
  verbose?: number;
@@ -18,6 +16,34 @@ export interface DevOptions {
18
16
  /** Disable admin agent subprocess. */
19
17
  noAdmin?: boolean;
20
18
  }
19
+ /**
20
+ * Pipe a child process's stdout/stderr to the parent's stderr, prefixed
21
+ * with a label. Lines are buffered per-stream to avoid interleaved output
22
+ * from concurrent subprocesses.
23
+ */
24
+ /**
25
+ * Predicate for pipeWithLabel's quiet mode. Exported for unit tests so a
26
+ * format change in the runtime logger can't silently break dev observability.
27
+ *
28
+ * Passes warnings/errors and the Phase 4 intent-routing telemetry through
29
+ * (the latter is exactly what makes intent vs LLM ratios visible in dev).
30
+ */
31
+ export declare function passesQuietFilter(line: string): boolean;
32
+ /**
33
+ * Best-effort pretty-printer for routing telemetry. The runtime emits
34
+ * `[INFO] route_intent {…json…}` style lines (for production aggregators);
35
+ * in the dev terminal that's mostly visual noise. This rewrites the few
36
+ * route/intent lifecycle events into one-liner status lines so a human
37
+ * scanning their terminal can answer "is intent routing working?" at a
38
+ * glance. Falls back to the original line when parsing fails so we never
39
+ * eat a line that the user might need.
40
+ *
41
+ * Returns:
42
+ * - a string (possibly the same as input) — print it verbatim
43
+ * - null — suppress the line entirely (e.g. dropping intent_matched
44
+ * once route_intent has already been printed for the same turn)
45
+ */
46
+ export declare function formatLineForDev(line: string): string | null;
21
47
  /**
22
48
  * Starts a local development server for the repo with hot reload enabled,
23
49
  * and optionally spawns Studio and admin agent as subprocesses.
@@ -1 +1 @@
1
- {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../../src/commands/dev.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,OAAO,CAAC;AAwEzC,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAkRD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,OAAO,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAoPpE;AAED,eAAO,MAAM,UAAU,EAAE,aA2ExB,CAAC"}
1
+ {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../../src/commands/dev.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,OAAO,CAAC;AA0EzC,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAYD;;;;GAIG;AACH;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAUvD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA8E5D;AAuQD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,OAAO,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAgYpE;AAED,eAAO,MAAM,UAAU,EAAE,aA8DxB,CAAC"}
@@ -3,14 +3,14 @@
3
3
  * Copyright 2025 Amodal Labs, Inc.
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
- import { existsSync, readFileSync } from 'node:fs';
6
+ import { existsSync, readFileSync, statSync } from 'node:fs';
7
7
  import { createRequire } from 'node:module';
8
8
  import { spawn } from 'node:child_process';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { createLocalServer, initLogLevel, interceptConsole, log } from '@amodalai/runtime';
12
12
  import { ensureAdminAgent, getAdminAgentConfig, getAdminAgentVersion, checkRegistryVersion } from '@amodalai/core';
13
- import { findRepoRoot } from '../shared/repo-discovery.js';
13
+ import { findRepoRootOrCwd } from '../shared/repo-discovery.js';
14
14
  import { createServer } from 'node:net';
15
15
  import { runConnectionPreflight, printPreflightTable } from '../shared/connection-preflight.js';
16
16
  import { resolveEnv } from '../shared/env-resolution.js';
@@ -19,6 +19,8 @@ import { getDb, ensureSchema, closeDb } from '@amodalai/db';
19
19
  // Constants
20
20
  // ---------------------------------------------------------------------------
21
21
  const DEFAULT_RUNTIME_PORT = 3847;
22
+ const DEFAULT_STUDIO_PORT = 3848;
23
+ const DEFAULT_ADMIN_PORT = 3849;
22
24
  // ---------------------------------------------------------------------------
23
25
  // Port checking
24
26
  // ---------------------------------------------------------------------------
@@ -66,9 +68,122 @@ function resolveStudioDir() {
66
68
  * with a label. Lines are buffered per-stream to avoid interleaved output
67
69
  * from concurrent subprocesses.
68
70
  */
71
+ /**
72
+ * Predicate for pipeWithLabel's quiet mode. Exported for unit tests so a
73
+ * format change in the runtime logger can't silently break dev observability.
74
+ *
75
+ * Passes warnings/errors and the Phase 4 intent-routing telemetry through
76
+ * (the latter is exactly what makes intent vs LLM ratios visible in dev).
77
+ */
78
+ export function passesQuietFilter(line) {
79
+ return (line.includes('[WARN]') ||
80
+ line.includes('[ERROR]') ||
81
+ line.includes('Error') ||
82
+ line.includes('intent_') ||
83
+ line.includes('agent_loop_start') ||
84
+ line.includes('route_intent') ||
85
+ line.includes('route_llm'));
86
+ }
87
+ /**
88
+ * Best-effort pretty-printer for routing telemetry. The runtime emits
89
+ * `[INFO] route_intent {…json…}` style lines (for production aggregators);
90
+ * in the dev terminal that's mostly visual noise. This rewrites the few
91
+ * route/intent lifecycle events into one-liner status lines so a human
92
+ * scanning their terminal can answer "is intent routing working?" at a
93
+ * glance. Falls back to the original line when parsing fails so we never
94
+ * eat a line that the user might need.
95
+ *
96
+ * Returns:
97
+ * - a string (possibly the same as input) — print it verbatim
98
+ * - null — suppress the line entirely (e.g. dropping intent_matched
99
+ * once route_intent has already been printed for the same turn)
100
+ */
101
+ export function formatLineForDev(line) {
102
+ // Only touch our recognized events. Match `[LEVEL] event_name {json}` form.
103
+ const m = /^\[(INFO|WARN|ERROR)\] ([a-z_]+) (\{.*\})\s*$/.exec(line);
104
+ if (!m)
105
+ return line;
106
+ const [, , event, jsonStr] = m;
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(jsonStr);
110
+ }
111
+ catch {
112
+ return line;
113
+ }
114
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
115
+ return line;
116
+ }
117
+ const data = Object.fromEntries(Object.entries(parsed));
118
+ const str = (k) => typeof data[k] === 'string' ? (data[k]) : '';
119
+ const num = (k) => typeof data[k] === 'number' ? (data[k]) : 0;
120
+ switch (event) {
121
+ case 'route_intent': {
122
+ const preview = str('userMessagePreview');
123
+ return `→ INTENT ${str('intentId').padEnd(22)} "${preview}"`;
124
+ }
125
+ case 'route_llm': {
126
+ const reason = str('reason');
127
+ const detail = str('intentId') || str('userMessagePreview');
128
+ return `→ LLM ${reason.padEnd(22)} ${detail ? `"${detail}"` : ''}`.trimEnd();
129
+ }
130
+ case 'intent_completed': {
131
+ const toolCount = num('toolCount');
132
+ const ms = num('durationMs');
133
+ return ` ✓ ${str('intentId')} done (${String(toolCount)} tools, ${String(ms)}ms)`;
134
+ }
135
+ case 'intent_fell_through': {
136
+ const ms = num('durationMs');
137
+ return ` ↓ ${str('intentId')} fell through to LLM (${String(ms)}ms)`;
138
+ }
139
+ case 'intent_errored': {
140
+ return ` ✗ ${str('intentId')} ERRORED: ${str('error')}`;
141
+ }
142
+ case 'intent_blocked_by_confirmation': {
143
+ return ` ✗ ${str('intentId')} blocked: ${str('toolName')} requires confirmation`;
144
+ }
145
+ case 'intent_returned_null_after_committing': {
146
+ return ` ⚠ ${str('intentId')} returned null after ${String(num('toolCallsStarted'))} tool calls — treating as completion`;
147
+ }
148
+ case 'intent_matched':
149
+ case 'agent_loop_start': {
150
+ // Both are redundant in dev: intent_matched duplicates the
151
+ // route_intent line that fires a millisecond earlier, and
152
+ // agent_loop_start always follows a route_llm line (manager.ts
153
+ // emits route_llm immediately before invoking the LLM). Drop
154
+ // them to keep the dev terminal scannable; production
155
+ // aggregators still get them on stderr from the runtime
156
+ // process directly (this formatter only runs in pipeWithLabel).
157
+ return null;
158
+ }
159
+ case 'tool_log': {
160
+ // Tools call ctx.log(...) for noteworthy progress (e.g.
161
+ // install_template emits "Cloned whodatdev/template-X into
162
+ // agent repo (N connection packages installed)"). When fired
163
+ // during an intent run the callId starts with `intent_`, so
164
+ // these lines pass the quiet filter — but as raw JSON they
165
+ // bury the useful message inside callId/session noise. Strip
166
+ // to a clean nested bullet.
167
+ const msg = str('message');
168
+ if (!msg)
169
+ return null;
170
+ return ` · ${msg}`;
171
+ }
172
+ default:
173
+ return line;
174
+ }
175
+ }
69
176
  function pipeWithLabel(child, label, opts) {
70
177
  const prefix = `[${label}] `;
71
178
  const quiet = opts?.quiet ?? false;
179
+ const writeLine = (line) => {
180
+ if (quiet && !passesQuietFilter(line))
181
+ return;
182
+ const pretty = formatLineForDev(line);
183
+ if (pretty === null)
184
+ return;
185
+ process.stderr.write(`${prefix}${pretty}\n`);
186
+ };
72
187
  for (const stream of [child.stdout, child.stderr]) {
73
188
  if (!stream)
74
189
  continue;
@@ -78,17 +193,12 @@ function pipeWithLabel(child, label, opts) {
78
193
  buffer += chunk;
79
194
  const lines = buffer.split('\n');
80
195
  buffer = lines.pop() ?? '';
81
- for (const line of lines) {
82
- if (quiet && !line.includes('[WARN]') && !line.includes('[ERROR]') && !line.includes('Error'))
83
- continue;
84
- process.stderr.write(`${prefix}${line}\n`);
85
- }
196
+ for (const line of lines)
197
+ writeLine(line);
86
198
  });
87
199
  stream.on('end', () => {
88
200
  if (buffer.length > 0) {
89
- if (!quiet || buffer.includes('[WARN]') || buffer.includes('[ERROR]') || buffer.includes('Error')) {
90
- process.stderr.write(`${prefix}${buffer}\n`);
91
- }
201
+ writeLine(buffer);
92
202
  buffer = '';
93
203
  }
94
204
  });
@@ -136,7 +246,6 @@ function spawnStudio(opts) {
136
246
  HOSTNAME: '0.0.0.0',
137
247
  ...(opts.agentId ? { AGENT_ID: opts.agentId } : {}),
138
248
  ...(opts.adminAgentUrl ? { ADMIN_AGENT_URL: opts.adminAgentUrl } : {}),
139
- ...(opts.basePath ? { BASE_PATH: opts.basePath } : {}),
140
249
  };
141
250
  // Pre-built server (npm install): dist-server/studio-server.js
142
251
  // Source mode (monorepo dev): src/server/studio-server.ts via tsx
@@ -244,6 +353,9 @@ async function spawnAdminAgent(opts) {
244
353
  AMODAL_NO_STUDIO: '1',
245
354
  REPO_PATH: opts.repoPath,
246
355
  };
356
+ if (opts.studioUrl) {
357
+ env['STUDIO_URL'] = opts.studioUrl;
358
+ }
247
359
  const child = spawn(process.execPath, [cliEntrypoint, 'dev', '--port', String(opts.port)], {
248
360
  cwd: adminAgentPath,
249
361
  env,
@@ -274,25 +386,13 @@ async function spawnAdminAgent(opts) {
274
386
  export async function runDev(options = {}) {
275
387
  initLogLevel({ verbosity: options.verbose ?? 0, quiet: options.quiet ?? false });
276
388
  interceptConsole();
277
- let repoPath;
278
- try {
279
- repoPath = findRepoRoot(options.cwd);
280
- }
281
- catch {
282
- process.stderr.write(`
283
- No amodal.json found.
284
-
285
- Create a new agent:
286
-
287
- amodal init Initialize this directory
288
- amodal dev Start the dev server
289
-
290
- Or if your agent is in another directory:
291
-
292
- cd /path/to/agent && amodal dev
293
-
294
- `);
295
- process.exit(1);
389
+ // Empty directories are allowed: the create flow in Studio scaffolds
390
+ // amodal.json from a chosen template (or admin-agent conversation), so
391
+ // the user can `amodal dev` before they have a project at all. We just
392
+ // skip the runtime in that case — it can't loadRepo without a manifest.
393
+ const { root: repoPath, hasManifest } = findRepoRootOrCwd(options.cwd);
394
+ if (!hasManifest) {
395
+ log.info('dev_create_flow_mode', { repoPath });
296
396
  }
297
397
  // -------------------------------------------------------------------------
298
398
  // Require DATABASE_URL
@@ -319,7 +419,18 @@ Or add it to your agent's .env file:
319
419
  }
320
420
  // Make DATABASE_URL available to child processes (runtime, Studio)
321
421
  process.env['DATABASE_URL'] = databaseUrl;
322
- // Read agent name from amodal.json for AGENT_ID
422
+ // Resolve AGENT_ID must be set BEFORE spawning subprocesses so
423
+ // Studio, runtime, and admin-agent all key `setup_state` rows by
424
+ // the same id. Three sources, in priority order:
425
+ // 1. amodal.json#name when the file exists (post-setup repos)
426
+ // 2. The repo dir basename (pre-setup repos — what the user calls
427
+ // their working directory; stable across the setup flow)
428
+ // 3. 'default' as a last-ditch fallback
429
+ // Without this, the admin-agent process would compute its own id
430
+ // from its own bundle (name: "admin") and Studio's `getAgentId()`
431
+ // would fall back to "default", leaving `commit_setup` marking a
432
+ // different row than Studio reads — IndexPage would loop the user
433
+ // back to /setup even after a successful commit.
323
434
  const amodalJsonPath = path.join(repoPath, 'amodal.json');
324
435
  let agentId;
325
436
  if (existsSync(amodalJsonPath)) {
@@ -332,7 +443,6 @@ Or add it to your agent's .env file:
332
443
  const nameValue = parsed !== undefined ? parsed['name'] : undefined;
333
444
  if (typeof nameValue === 'string') {
334
445
  agentId = nameValue;
335
- process.env['AGENT_ID'] = agentId;
336
446
  }
337
447
  }
338
448
  catch (err) {
@@ -342,6 +452,14 @@ Or add it to your agent's .env file:
342
452
  });
343
453
  }
344
454
  }
455
+ if (!agentId) {
456
+ // Pre-setup fallback. `path.basename(repoPath)` gives "test-empty"
457
+ // or whatever the user named their working dir — stable enough
458
+ // for setup_state coordination, and the agent name will switch
459
+ // to amodal.json#name on the next CLI invocation post-commit.
460
+ agentId = path.basename(repoPath) || 'default';
461
+ }
462
+ process.env['AGENT_ID'] = agentId;
345
463
  // -------------------------------------------------------------------------
346
464
  // Run schema migrations
347
465
  // -------------------------------------------------------------------------
@@ -363,15 +481,17 @@ Or add it to your agent's .env file:
363
481
  // Port allocation
364
482
  // -------------------------------------------------------------------------
365
483
  const runtimePort = options.port ?? DEFAULT_RUNTIME_PORT;
366
- const studioPort = options.studioPort ?? runtimePort + 1;
367
- const adminPort = options.adminPort ?? runtimePort + 2;
368
- await assertPortFree(runtimePort);
484
+ const studioPort = DEFAULT_STUDIO_PORT;
485
+ const adminPort = DEFAULT_ADMIN_PORT;
486
+ if (hasManifest) {
487
+ await assertPortFree(runtimePort);
488
+ }
369
489
  if (!options.noStudio)
370
490
  await assertPortFree(studioPort);
371
491
  if (!options.noAdmin)
372
492
  await assertPortFree(adminPort);
373
493
  log.debug('ports_allocated', {
374
- runtime: runtimePort,
494
+ runtime: hasManifest ? runtimePort : null,
375
495
  studio: options.noStudio ? null : studioPort,
376
496
  admin: options.noAdmin ? null : adminPort,
377
497
  });
@@ -408,44 +528,63 @@ Or add it to your agent's .env file:
408
528
  }
409
529
  }
410
530
  // -------------------------------------------------------------------------
411
- // Start the runtime server
531
+ // Start the runtime server (skipped when there's no amodal.json — the
532
+ // runtime can't loadRepo on an empty directory; the create flow in Studio
533
+ // takes the user from an empty repo to a configured one, after which they
534
+ // ctrl+C and re-run `amodal dev` to pick up the runtime).
412
535
  // -------------------------------------------------------------------------
413
- log.debug('starting_dev_server', { repoPath });
536
+ log.debug('starting_dev_server', { repoPath, hasManifest });
414
537
  try {
415
- let staticAppDir;
416
- // Use pre-built static assets for the SPA.
417
- // Vite dev middleware is only used inside the monorepo with `pnpm dev`.
418
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
419
- const candidates = [
420
- // esbuild bundle: bundle/app/
421
- path.resolve(scriptDir, 'app'),
422
- ];
423
- // Resolve @amodalai/runtime-app via Node module resolution (works regardless of install layout)
424
- const require = createRequire(import.meta.url);
425
- const runtimeAppPkg = require.resolve('@amodalai/runtime-app/package.json');
426
- candidates.push(path.join(path.dirname(runtimeAppPkg), 'dist'));
427
- for (const dir of candidates) {
428
- if (existsSync(path.join(dir, 'index.html'))) {
429
- log.debug('serving_prebuilt_app', { path: staticAppDir });
430
- staticAppDir = dir;
431
- break;
538
+ let server = null;
539
+ /**
540
+ * Boot the runtime server. Factored out so the empty-repo
541
+ * branch (Phase E.9) can call it lazily when amodal.json lands.
542
+ */
543
+ const bootRuntime = async () => {
544
+ let staticAppDir;
545
+ // Use pre-built static assets for the SPA.
546
+ // Vite dev middleware is only used inside the monorepo with `pnpm dev`.
547
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
548
+ const candidates = [
549
+ // esbuild bundle: bundle/app/
550
+ path.resolve(scriptDir, 'app'),
551
+ ];
552
+ // Resolve @amodalai/runtime-app via Node module resolution (works regardless of install layout)
553
+ const require = createRequire(import.meta.url);
554
+ const runtimeAppPkg = require.resolve('@amodalai/runtime-app/package.json');
555
+ candidates.push(path.join(path.dirname(runtimeAppPkg), 'dist'));
556
+ for (const dir of candidates) {
557
+ if (existsSync(path.join(dir, 'index.html'))) {
558
+ log.debug('serving_prebuilt_app', { path: staticAppDir });
559
+ staticAppDir = dir;
560
+ break;
561
+ }
432
562
  }
563
+ const created = await createLocalServer({
564
+ repoPath,
565
+ port: runtimePort,
566
+ host,
567
+ hotReload: true,
568
+ corsOrigin: '*',
569
+ staticAppDir,
570
+ resumeSessionId: options.resume,
571
+ studioUrl: studioUrl ?? undefined,
572
+ adminAgentUrl: adminAgentUrl ?? undefined,
573
+ });
574
+ await created.start();
575
+ return created;
576
+ };
577
+ if (hasManifest) {
578
+ server = await bootRuntime();
433
579
  }
434
- const server = await createLocalServer({
435
- repoPath,
436
- port: runtimePort,
437
- host,
438
- hotReload: true,
439
- corsOrigin: '*',
440
- staticAppDir,
441
- resumeSessionId: options.resume,
442
- studioUrl: studioUrl ?? undefined,
443
- adminAgentUrl: adminAgentUrl ?? undefined,
444
- });
445
- await server.start();
446
580
  // Print clean startup summary
447
581
  process.stderr.write('\n');
448
- process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n`);
582
+ if (server) {
583
+ process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n`);
584
+ }
585
+ else {
586
+ process.stderr.write(' Runtime: waiting for amodal.json (auto-boots when Studio finishes setup)\n');
587
+ }
449
588
  if (studioUrl) {
450
589
  process.stderr.write(` Studio: ${studioUrl}\n`);
451
590
  }
@@ -455,25 +594,130 @@ Or add it to your agent's .env file:
455
594
  const redactedUrl = databaseUrl.replace(/\/\/([^:]+):([^@]+)@/, '//$1:***@');
456
595
  process.stderr.write(` Database: ${redactedUrl}\n`);
457
596
  process.stderr.write('\n');
458
- // Preflight connection check (non-blocking)
459
- const preflight = await runConnectionPreflight(repoPath);
460
- if (preflight.results.length > 0) {
461
- process.stderr.write('\n');
462
- printPreflightTable(preflight.results);
463
- if (preflight.hasFailures) {
464
- process.stderr.write('\n WARNING: Some connections failed. The agent may not work correctly.\n');
597
+ // Preflight connection check (non-blocking) — only meaningful when
598
+ // there's a manifest to load connections from.
599
+ if (hasManifest) {
600
+ const preflight = await runConnectionPreflight(repoPath);
601
+ if (preflight.results.length > 0) {
602
+ process.stderr.write('\n');
603
+ printPreflightTable(preflight.results);
604
+ if (preflight.hasFailures) {
605
+ process.stderr.write('\n WARNING: Some connections failed. The agent may not work correctly.\n');
606
+ }
607
+ process.stderr.write('\n');
465
608
  }
466
- process.stderr.write('\n');
467
609
  }
610
+ // -------------------------------------------------------------------
611
+ // Phase E.9 — runtime auto-(re)spawn on amodal.json change.
612
+ //
613
+ // Two flows watched by the same poller:
614
+ //
615
+ // 1. AUTO-BOOT — runtime didn't start at CLI launch (no manifest
616
+ // yet). Once amodal.json lands (commit_setup, the user-button
617
+ // commit-setup endpoint, or init-repo's skip-onboarding
618
+ // write), boot the runtime in place. Studio's runtime URL
619
+ // probe picks it up on the next tick.
620
+ //
621
+ // 2. AUTO-RESTART — runtime is already up but amodal.json has
622
+ // been rewritten since the last spawn. Happens after a
623
+ // Restart-Setup → re-commit, or when the user edits
624
+ // amodal.json by hand (adding a connection package, etc.).
625
+ // Without a restart, the running runtime keeps the stale
626
+ // bundle in memory and the new packages/config never load.
627
+ //
628
+ // 500ms debounce after detecting a change — lets the writer
629
+ // (commit_setup's atomic rename, init-repo's full write) settle
630
+ // before loadRepo tries to read.
631
+ // -------------------------------------------------------------------
632
+ const RUNTIME_WATCH_INTERVAL_MS = 2_000;
633
+ let runtimeWatcher = null;
634
+ let lastManifestMtime = null;
635
+ const stopRuntimeWatch = () => {
636
+ if (runtimeWatcher !== null) {
637
+ clearTimeout(runtimeWatcher);
638
+ runtimeWatcher = null;
639
+ }
640
+ };
641
+ const manifestPath = path.join(repoPath, 'amodal.json');
642
+ /**
643
+ * Read the manifest's last-modified timestamp without throwing.
644
+ * Returns null when the file is missing.
645
+ */
646
+ const manifestMtime = () => {
647
+ try {
648
+ if (!existsSync(manifestPath))
649
+ return null;
650
+ return statSync(manifestPath).mtimeMs;
651
+ }
652
+ catch {
653
+ return null;
654
+ }
655
+ };
656
+ // Seed lastManifestMtime if the runtime was booted at startup so
657
+ // we don't immediately self-restart on the first poll.
658
+ if (server)
659
+ lastManifestMtime = manifestMtime();
660
+ const watchForRuntime = () => {
661
+ const mtime = manifestMtime();
662
+ const exists = mtime !== null;
663
+ // Auto-boot path: no runtime yet, manifest just appeared.
664
+ if (!server && exists) {
665
+ runtimeWatcher = setTimeout(() => {
666
+ (async () => {
667
+ try {
668
+ process.stderr.write('\n[dev] amodal.json appeared — booting runtime...\n');
669
+ server = await bootRuntime();
670
+ lastManifestMtime = manifestMtime();
671
+ process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n\n`);
672
+ }
673
+ catch (err) {
674
+ const msg = err instanceof Error ? err.message : String(err);
675
+ process.stderr.write(`[dev] Runtime auto-boot failed: ${msg}\n`);
676
+ process.stderr.write(' Try ctrl+C and re-running `amodal dev`.\n');
677
+ }
678
+ })().catch(() => undefined);
679
+ }, 500);
680
+ return;
681
+ }
682
+ // Auto-restart path: runtime is up, manifest was rewritten.
683
+ if (server && exists && lastManifestMtime !== null && mtime > lastManifestMtime) {
684
+ const previousServer = server;
685
+ // Clear server immediately so a second mtime change while
686
+ // we're restarting doesn't re-enter this branch.
687
+ server = null;
688
+ runtimeWatcher = setTimeout(() => {
689
+ (async () => {
690
+ try {
691
+ process.stderr.write('\n[dev] amodal.json changed — restarting runtime...\n');
692
+ await previousServer.stop();
693
+ server = await bootRuntime();
694
+ lastManifestMtime = manifestMtime();
695
+ process.stderr.write(` Runtime: http://localhost:${String(runtimePort)} (restarted)\n\n`);
696
+ }
697
+ catch (err) {
698
+ const msg = err instanceof Error ? err.message : String(err);
699
+ process.stderr.write(`[dev] Runtime restart failed: ${msg}\n`);
700
+ process.stderr.write(' Try ctrl+C and re-running `amodal dev`.\n');
701
+ }
702
+ })().catch(() => undefined);
703
+ }, 500);
704
+ return;
705
+ }
706
+ runtimeWatcher = setTimeout(watchForRuntime, RUNTIME_WATCH_INTERVAL_MS);
707
+ };
708
+ runtimeWatcher = setTimeout(watchForRuntime, RUNTIME_WATCH_INTERVAL_MS);
468
709
  // Graceful shutdown
469
710
  const shutdown = async (signal) => {
470
711
  process.stderr.write(`\n[dev] Received ${signal}, shutting down...\n`);
712
+ stopRuntimeWatch();
471
713
  // Kill subprocesses first
472
714
  if (managedProcesses.length > 0) {
473
715
  log.debug('subprocess_shutdown', { count: managedProcesses.length });
474
716
  await killAll(managedProcesses);
475
717
  }
476
- await server.stop();
718
+ if (server) {
719
+ await server.stop();
720
+ }
477
721
  process.exit(0);
478
722
  };
479
723
  process.on('SIGTERM', () => void shutdown('SIGTERM'));
@@ -517,14 +761,6 @@ export const devCommand = {
517
761
  describe: 'Only show errors',
518
762
  default: false,
519
763
  },
520
- 'studio-port': {
521
- type: 'number',
522
- describe: 'Port for Studio (defaults to port + 1)',
523
- },
524
- 'admin-port': {
525
- type: 'number',
526
- describe: 'Port for admin agent (defaults to port + 2)',
527
- },
528
764
  'no-studio': {
529
765
  type: 'boolean',
530
766
  describe: 'Do not spawn Studio subprocess',
@@ -548,15 +784,11 @@ export const devCommand = {
548
784
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
549
785
  const quiet = argv['quiet'];
550
786
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
551
- const studioPort = argv['studio-port'];
552
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
553
- const adminPort = argv['admin-port'];
554
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
555
787
  const noStudio = argv['no-studio'] || process.env['AMODAL_NO_STUDIO'] === '1';
556
788
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
557
789
  const noAdmin = argv['no-admin'] || process.env['AMODAL_NO_ADMIN'] === '1';
558
790
  try {
559
- await runDev({ port, studioPort, adminPort, host, resume, verbose, quiet, noStudio, noAdmin });
791
+ await runDev({ port, host, resume, verbose, quiet, noStudio, noAdmin });
560
792
  }
561
793
  catch (err) {
562
794
  const msg = err instanceof Error ? err.message : String(err);