@amodalai/amodal 0.3.48 → 0.3.50

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.
@@ -6,14 +6,14 @@
6
6
 
7
7
  import type {CommandModule} from 'yargs';
8
8
  import type {ChildProcess} from 'node:child_process';
9
- import {existsSync, readFileSync} from 'node:fs';
9
+ import {existsSync, readFileSync, statSync} from 'node:fs';
10
10
  import {createRequire} from 'node:module';
11
11
  import {spawn} from 'node:child_process';
12
12
  import path from 'node:path';
13
13
  import {fileURLToPath} from 'node:url';
14
14
  import {createLocalServer, initLogLevel, interceptConsole, log} from '@amodalai/runtime';
15
15
  import {ensureAdminAgent, getAdminAgentConfig, getAdminAgentVersion, checkRegistryVersion} from '@amodalai/core';
16
- import {findRepoRoot} from '../shared/repo-discovery.js';
16
+ import {findRepoRootOrCwd} from '../shared/repo-discovery.js';
17
17
  import {createServer} from 'node:net';
18
18
  import {runConnectionPreflight, printPreflightTable} from '../shared/connection-preflight.js';
19
19
  import {resolveEnv} from '../shared/env-resolution.js';
@@ -24,6 +24,8 @@ import {getDb, ensureSchema, closeDb} from '@amodalai/db';
24
24
  // ---------------------------------------------------------------------------
25
25
 
26
26
  const DEFAULT_RUNTIME_PORT = 3847;
27
+ const DEFAULT_STUDIO_PORT = 3848;
28
+ const DEFAULT_ADMIN_PORT = 3849;
27
29
 
28
30
  // ---------------------------------------------------------------------------
29
31
  // Port checking
@@ -79,8 +81,6 @@ function resolveStudioDir(): string | null {
79
81
  export interface DevOptions {
80
82
  cwd?: string;
81
83
  port?: number;
82
- studioPort?: number;
83
- adminPort?: number;
84
84
  host?: string;
85
85
  resume?: string;
86
86
  verbose?: number;
@@ -106,9 +106,130 @@ interface ManagedProcess {
106
106
  * with a label. Lines are buffered per-stream to avoid interleaved output
107
107
  * from concurrent subprocesses.
108
108
  */
109
+ /**
110
+ * Predicate for pipeWithLabel's quiet mode. Exported for unit tests so a
111
+ * format change in the runtime logger can't silently break dev observability.
112
+ *
113
+ * Passes warnings/errors and the Phase 4 intent-routing telemetry through
114
+ * (the latter is exactly what makes intent vs LLM ratios visible in dev).
115
+ */
116
+ export function passesQuietFilter(line: string): boolean {
117
+ return (
118
+ line.includes('[WARN]') ||
119
+ line.includes('[ERROR]') ||
120
+ line.includes('Error') ||
121
+ line.includes('intent_') ||
122
+ line.includes('agent_loop_start') ||
123
+ line.includes('route_intent') ||
124
+ line.includes('route_llm')
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Best-effort pretty-printer for routing telemetry. The runtime emits
130
+ * `[INFO] route_intent {…json…}` style lines (for production aggregators);
131
+ * in the dev terminal that's mostly visual noise. This rewrites the few
132
+ * route/intent lifecycle events into one-liner status lines so a human
133
+ * scanning their terminal can answer "is intent routing working?" at a
134
+ * glance. Falls back to the original line when parsing fails so we never
135
+ * eat a line that the user might need.
136
+ *
137
+ * Returns:
138
+ * - a string (possibly the same as input) — print it verbatim
139
+ * - null — suppress the line entirely (e.g. dropping intent_matched
140
+ * once route_intent has already been printed for the same turn)
141
+ */
142
+ export function formatLineForDev(line: string): string | null {
143
+ // Only touch our recognized events. Match `[LEVEL] event_name {json}` form.
144
+ const m = /^\[(INFO|WARN|ERROR)\] ([a-z_]+) (\{.*\})\s*$/.exec(line);
145
+ if (!m) return line;
146
+
147
+ const [, , event, jsonStr] = m;
148
+ let parsed: unknown;
149
+ try {
150
+ parsed = JSON.parse(jsonStr);
151
+ } catch {
152
+ return line;
153
+ }
154
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
155
+ return line;
156
+ }
157
+ const data: Record<string, unknown> = Object.fromEntries(
158
+ Object.entries(parsed),
159
+ );
160
+
161
+ const str = (k: string): string =>
162
+ typeof data[k] === 'string' ? (data[k]) : '';
163
+ const num = (k: string): number =>
164
+ typeof data[k] === 'number' ? (data[k]) : 0;
165
+
166
+ switch (event) {
167
+ case 'route_intent': {
168
+ const preview = str('userMessagePreview');
169
+ return `→ INTENT ${str('intentId').padEnd(22)} "${preview}"`;
170
+ }
171
+ case 'route_llm': {
172
+ const reason = str('reason');
173
+ const detail = str('intentId') || str('userMessagePreview');
174
+ return `→ LLM ${reason.padEnd(22)} ${detail ? `"${detail}"` : ''}`.trimEnd();
175
+ }
176
+ case 'intent_completed': {
177
+ const toolCount = num('toolCount');
178
+ const ms = num('durationMs');
179
+ return ` ✓ ${str('intentId')} done (${String(toolCount)} tools, ${String(ms)}ms)`;
180
+ }
181
+ case 'intent_fell_through': {
182
+ const ms = num('durationMs');
183
+ return ` ↓ ${str('intentId')} fell through to LLM (${String(ms)}ms)`;
184
+ }
185
+ case 'intent_errored': {
186
+ return ` ✗ ${str('intentId')} ERRORED: ${str('error')}`;
187
+ }
188
+ case 'intent_blocked_by_confirmation': {
189
+ return ` ✗ ${str('intentId')} blocked: ${str('toolName')} requires confirmation`;
190
+ }
191
+ case 'intent_returned_null_after_committing': {
192
+ return ` ⚠ ${str('intentId')} returned null after ${String(num('toolCallsStarted'))} tool calls — treating as completion`;
193
+ }
194
+ case 'intent_matched':
195
+ case 'agent_loop_start': {
196
+ // Both are redundant in dev: intent_matched duplicates the
197
+ // route_intent line that fires a millisecond earlier, and
198
+ // agent_loop_start always follows a route_llm line (manager.ts
199
+ // emits route_llm immediately before invoking the LLM). Drop
200
+ // them to keep the dev terminal scannable; production
201
+ // aggregators still get them on stderr from the runtime
202
+ // process directly (this formatter only runs in pipeWithLabel).
203
+ return null;
204
+ }
205
+ case 'tool_log': {
206
+ // Tools call ctx.log(...) for noteworthy progress (e.g.
207
+ // install_template emits "Cloned whodatdev/template-X into
208
+ // agent repo (N connection packages installed)"). When fired
209
+ // during an intent run the callId starts with `intent_`, so
210
+ // these lines pass the quiet filter — but as raw JSON they
211
+ // bury the useful message inside callId/session noise. Strip
212
+ // to a clean nested bullet.
213
+ const msg = str('message');
214
+ if (!msg) return null;
215
+ return ` · ${msg}`;
216
+ }
217
+ default:
218
+ return line;
219
+ }
220
+ }
221
+
109
222
  function pipeWithLabel(child: ChildProcess, label: string, opts?: {quiet?: boolean}): void {
110
223
  const prefix = `[${label}] `;
111
224
  const quiet = opts?.quiet ?? false;
225
+
226
+ const writeLine = (line: string): void => {
227
+ if (quiet && !passesQuietFilter(line)) return;
228
+ const pretty = formatLineForDev(line);
229
+ if (pretty === null) return;
230
+ process.stderr.write(`${prefix}${pretty}\n`);
231
+ };
232
+
112
233
  for (const stream of [child.stdout, child.stderr]) {
113
234
  if (!stream) continue;
114
235
  let buffer = '';
@@ -117,16 +238,11 @@ function pipeWithLabel(child: ChildProcess, label: string, opts?: {quiet?: boole
117
238
  buffer += chunk;
118
239
  const lines = buffer.split('\n');
119
240
  buffer = lines.pop() ?? '';
120
- for (const line of lines) {
121
- if (quiet && !line.includes('[WARN]') && !line.includes('[ERROR]') && !line.includes('Error')) continue;
122
- process.stderr.write(`${prefix}${line}\n`);
123
- }
241
+ for (const line of lines) writeLine(line);
124
242
  });
125
243
  stream.on('end', () => {
126
244
  if (buffer.length > 0) {
127
- if (!quiet || buffer.includes('[WARN]') || buffer.includes('[ERROR]') || buffer.includes('Error')) {
128
- process.stderr.write(`${prefix}${buffer}\n`);
129
- }
245
+ writeLine(buffer);
130
246
  buffer = '';
131
247
  }
132
248
  });
@@ -179,7 +295,6 @@ function spawnStudio(opts: {
179
295
  repoPath: string;
180
296
  agentId?: string;
181
297
  adminAgentUrl?: string;
182
- basePath?: string;
183
298
  }): StudioSpawnResult | null {
184
299
  const studioDir = resolveStudioDir();
185
300
  if (!studioDir) {
@@ -198,7 +313,6 @@ function spawnStudio(opts: {
198
313
  HOSTNAME: '0.0.0.0',
199
314
  ...(opts.agentId ? {AGENT_ID: opts.agentId} : {}),
200
315
  ...(opts.adminAgentUrl ? {ADMIN_AGENT_URL: opts.adminAgentUrl} : {}),
201
- ...(opts.basePath ? {BASE_PATH: opts.basePath} : {}),
202
316
  };
203
317
 
204
318
  // Pre-built server (npm install): dist-server/studio-server.js
@@ -331,6 +445,9 @@ async function spawnAdminAgent(opts: {
331
445
  AMODAL_NO_STUDIO: '1',
332
446
  REPO_PATH: opts.repoPath,
333
447
  };
448
+ if (opts.studioUrl) {
449
+ env['STUDIO_URL'] = opts.studioUrl;
450
+ }
334
451
 
335
452
  const child = spawn(
336
453
  process.execPath,
@@ -371,24 +488,13 @@ export async function runDev(options: DevOptions = {}): Promise<void> {
371
488
  initLogLevel({verbosity: options.verbose ?? 0, quiet: options.quiet ?? false});
372
489
  interceptConsole();
373
490
 
374
- let repoPath: string;
375
- try {
376
- repoPath = findRepoRoot(options.cwd);
377
- } catch {
378
- process.stderr.write(`
379
- No amodal.json found.
380
-
381
- Create a new agent:
382
-
383
- amodal init Initialize this directory
384
- amodal dev Start the dev server
385
-
386
- Or if your agent is in another directory:
387
-
388
- cd /path/to/agent && amodal dev
389
-
390
- `);
391
- process.exit(1);
491
+ // Empty directories are allowed: the create flow in Studio scaffolds
492
+ // amodal.json from a chosen template (or admin-agent conversation), so
493
+ // the user can `amodal dev` before they have a project at all. We just
494
+ // skip the runtime in that case — it can't loadRepo without a manifest.
495
+ const {root: repoPath, hasManifest} = findRepoRootOrCwd(options.cwd);
496
+ if (!hasManifest) {
497
+ log.info('dev_create_flow_mode', {repoPath});
392
498
  }
393
499
 
394
500
  // -------------------------------------------------------------------------
@@ -419,7 +525,18 @@ Or add it to your agent's .env file:
419
525
  // Make DATABASE_URL available to child processes (runtime, Studio)
420
526
  process.env['DATABASE_URL'] = databaseUrl;
421
527
 
422
- // Read agent name from amodal.json for AGENT_ID
528
+ // Resolve AGENT_ID must be set BEFORE spawning subprocesses so
529
+ // Studio, runtime, and admin-agent all key `setup_state` rows by
530
+ // the same id. Three sources, in priority order:
531
+ // 1. amodal.json#name when the file exists (post-setup repos)
532
+ // 2. The repo dir basename (pre-setup repos — what the user calls
533
+ // their working directory; stable across the setup flow)
534
+ // 3. 'default' as a last-ditch fallback
535
+ // Without this, the admin-agent process would compute its own id
536
+ // from its own bundle (name: "admin") and Studio's `getAgentId()`
537
+ // would fall back to "default", leaving `commit_setup` marking a
538
+ // different row than Studio reads — IndexPage would loop the user
539
+ // back to /setup even after a successful commit.
423
540
  const amodalJsonPath = path.join(repoPath, 'amodal.json');
424
541
  let agentId: string | undefined;
425
542
  if (existsSync(amodalJsonPath)) {
@@ -432,7 +549,6 @@ Or add it to your agent's .env file:
432
549
  const nameValue = parsed !== undefined ? (parsed as Record<string, unknown>)['name'] : undefined;
433
550
  if (typeof nameValue === 'string') {
434
551
  agentId = nameValue;
435
- process.env['AGENT_ID'] = agentId;
436
552
  }
437
553
  } catch (err: unknown) {
438
554
  log.warn('amodal_json_parse_error', {
@@ -441,6 +557,14 @@ Or add it to your agent's .env file:
441
557
  });
442
558
  }
443
559
  }
560
+ if (!agentId) {
561
+ // Pre-setup fallback. `path.basename(repoPath)` gives "test-empty"
562
+ // or whatever the user named their working dir — stable enough
563
+ // for setup_state coordination, and the agent name will switch
564
+ // to amodal.json#name on the next CLI invocation post-commit.
565
+ agentId = path.basename(repoPath) || 'default';
566
+ }
567
+ process.env['AGENT_ID'] = agentId;
444
568
 
445
569
  // -------------------------------------------------------------------------
446
570
  // Run schema migrations
@@ -466,15 +590,17 @@ Or add it to your agent's .env file:
466
590
  // -------------------------------------------------------------------------
467
591
 
468
592
  const runtimePort = options.port ?? DEFAULT_RUNTIME_PORT;
469
- const studioPort = options.studioPort ?? runtimePort + 1;
470
- const adminPort = options.adminPort ?? runtimePort + 2;
593
+ const studioPort = DEFAULT_STUDIO_PORT;
594
+ const adminPort = DEFAULT_ADMIN_PORT;
471
595
 
472
- await assertPortFree(runtimePort);
596
+ if (hasManifest) {
597
+ await assertPortFree(runtimePort);
598
+ }
473
599
  if (!options.noStudio) await assertPortFree(studioPort);
474
600
  if (!options.noAdmin) await assertPortFree(adminPort);
475
601
 
476
602
  log.debug('ports_allocated', {
477
- runtime: runtimePort,
603
+ runtime: hasManifest ? runtimePort : null,
478
604
  studio: options.noStudio ? null : studioPort,
479
605
  admin: options.noAdmin ? null : adminPort,
480
606
  });
@@ -516,52 +642,71 @@ Or add it to your agent's .env file:
516
642
  }
517
643
 
518
644
  // -------------------------------------------------------------------------
519
- // Start the runtime server
645
+ // Start the runtime server (skipped when there's no amodal.json — the
646
+ // runtime can't loadRepo on an empty directory; the create flow in Studio
647
+ // takes the user from an empty repo to a configured one, after which they
648
+ // ctrl+C and re-run `amodal dev` to pick up the runtime).
520
649
  // -------------------------------------------------------------------------
521
650
 
522
- log.debug('starting_dev_server', {repoPath});
651
+ log.debug('starting_dev_server', {repoPath, hasManifest});
523
652
 
524
653
  try {
525
- let staticAppDir: string | undefined;
526
-
527
- // Use pre-built static assets for the SPA.
528
- // Vite dev middleware is only used inside the monorepo with `pnpm dev`.
529
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
530
- const candidates = [
531
- // esbuild bundle: bundle/app/
532
- path.resolve(scriptDir, 'app'),
533
- ];
534
-
535
- // Resolve @amodalai/runtime-app via Node module resolution (works regardless of install layout)
536
- const require = createRequire(import.meta.url);
537
- const runtimeAppPkg = require.resolve('@amodalai/runtime-app/package.json');
538
- candidates.push(path.join(path.dirname(runtimeAppPkg), 'dist'));
539
-
540
- for (const dir of candidates) {
541
- if (existsSync(path.join(dir, 'index.html'))) {
542
- log.debug('serving_prebuilt_app', {path: staticAppDir});
543
- staticAppDir = dir;
544
- break;
654
+ let server: Awaited<ReturnType<typeof createLocalServer>> | null = null;
655
+
656
+ /**
657
+ * Boot the runtime server. Factored out so the empty-repo
658
+ * branch (Phase E.9) can call it lazily when amodal.json lands.
659
+ */
660
+ const bootRuntime = async (): Promise<typeof server> => {
661
+ let staticAppDir: string | undefined;
662
+
663
+ // Use pre-built static assets for the SPA.
664
+ // Vite dev middleware is only used inside the monorepo with `pnpm dev`.
665
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
666
+ const candidates = [
667
+ // esbuild bundle: bundle/app/
668
+ path.resolve(scriptDir, 'app'),
669
+ ];
670
+
671
+ // Resolve @amodalai/runtime-app via Node module resolution (works regardless of install layout)
672
+ const require = createRequire(import.meta.url);
673
+ const runtimeAppPkg = require.resolve('@amodalai/runtime-app/package.json');
674
+ candidates.push(path.join(path.dirname(runtimeAppPkg), 'dist'));
675
+
676
+ for (const dir of candidates) {
677
+ if (existsSync(path.join(dir, 'index.html'))) {
678
+ log.debug('serving_prebuilt_app', {path: staticAppDir});
679
+ staticAppDir = dir;
680
+ break;
681
+ }
545
682
  }
546
- }
547
683
 
548
- const server = await createLocalServer({
549
- repoPath,
550
- port: runtimePort,
551
- host,
552
- hotReload: true,
553
- corsOrigin: '*',
554
- staticAppDir,
555
- resumeSessionId: options.resume,
556
- studioUrl: studioUrl ?? undefined,
557
- adminAgentUrl: adminAgentUrl ?? undefined,
558
- });
684
+ const created = await createLocalServer({
685
+ repoPath,
686
+ port: runtimePort,
687
+ host,
688
+ hotReload: true,
689
+ corsOrigin: '*',
690
+ staticAppDir,
691
+ resumeSessionId: options.resume,
692
+ studioUrl: studioUrl ?? undefined,
693
+ adminAgentUrl: adminAgentUrl ?? undefined,
694
+ });
695
+ await created.start();
696
+ return created;
697
+ };
559
698
 
560
- await server.start();
699
+ if (hasManifest) {
700
+ server = await bootRuntime();
701
+ }
561
702
 
562
703
  // Print clean startup summary
563
704
  process.stderr.write('\n');
564
- process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n`);
705
+ if (server) {
706
+ process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n`);
707
+ } else {
708
+ process.stderr.write(' Runtime: waiting for amodal.json (auto-boots when Studio finishes setup)\n');
709
+ }
565
710
  if (studioUrl) {
566
711
  process.stderr.write(` Studio: ${studioUrl}\n`);
567
712
  }
@@ -575,28 +720,140 @@ Or add it to your agent's .env file:
575
720
  process.stderr.write(` Database: ${redactedUrl}\n`);
576
721
  process.stderr.write('\n');
577
722
 
578
- // Preflight connection check (non-blocking)
579
- const preflight = await runConnectionPreflight(repoPath);
580
- if (preflight.results.length > 0) {
581
- process.stderr.write('\n');
582
- printPreflightTable(preflight.results);
583
- if (preflight.hasFailures) {
584
- process.stderr.write('\n WARNING: Some connections failed. The agent may not work correctly.\n');
723
+ // Preflight connection check (non-blocking) — only meaningful when
724
+ // there's a manifest to load connections from.
725
+ if (hasManifest) {
726
+ const preflight = await runConnectionPreflight(repoPath);
727
+ if (preflight.results.length > 0) {
728
+ process.stderr.write('\n');
729
+ printPreflightTable(preflight.results);
730
+ if (preflight.hasFailures) {
731
+ process.stderr.write('\n WARNING: Some connections failed. The agent may not work correctly.\n');
732
+ }
733
+ process.stderr.write('\n');
585
734
  }
586
- process.stderr.write('\n');
587
735
  }
588
736
 
737
+ // -------------------------------------------------------------------
738
+ // Phase E.9 — runtime auto-(re)spawn on amodal.json change.
739
+ //
740
+ // Two flows watched by the same poller:
741
+ //
742
+ // 1. AUTO-BOOT — runtime didn't start at CLI launch (no manifest
743
+ // yet). Once amodal.json lands (commit_setup, the user-button
744
+ // commit-setup endpoint, or init-repo's skip-onboarding
745
+ // write), boot the runtime in place. Studio's runtime URL
746
+ // probe picks it up on the next tick.
747
+ //
748
+ // 2. AUTO-RESTART — runtime is already up but amodal.json has
749
+ // been rewritten since the last spawn. Happens after a
750
+ // Restart-Setup → re-commit, or when the user edits
751
+ // amodal.json by hand (adding a connection package, etc.).
752
+ // Without a restart, the running runtime keeps the stale
753
+ // bundle in memory and the new packages/config never load.
754
+ //
755
+ // 500ms debounce after detecting a change — lets the writer
756
+ // (commit_setup's atomic rename, init-repo's full write) settle
757
+ // before loadRepo tries to read.
758
+ // -------------------------------------------------------------------
759
+
760
+ const RUNTIME_WATCH_INTERVAL_MS = 2_000;
761
+ let runtimeWatcher: ReturnType<typeof setTimeout> | null = null;
762
+ let lastManifestMtime: number | null = null;
763
+
764
+ const stopRuntimeWatch = (): void => {
765
+ if (runtimeWatcher !== null) {
766
+ clearTimeout(runtimeWatcher);
767
+ runtimeWatcher = null;
768
+ }
769
+ };
770
+
771
+ const manifestPath = path.join(repoPath, 'amodal.json');
772
+
773
+ /**
774
+ * Read the manifest's last-modified timestamp without throwing.
775
+ * Returns null when the file is missing.
776
+ */
777
+ const manifestMtime = (): number | null => {
778
+ try {
779
+ if (!existsSync(manifestPath)) return null;
780
+ return statSync(manifestPath).mtimeMs;
781
+ } catch {
782
+ return null;
783
+ }
784
+ };
785
+
786
+ // Seed lastManifestMtime if the runtime was booted at startup so
787
+ // we don't immediately self-restart on the first poll.
788
+ if (server) lastManifestMtime = manifestMtime();
789
+
790
+ const watchForRuntime = (): void => {
791
+ const mtime = manifestMtime();
792
+ const exists = mtime !== null;
793
+
794
+ // Auto-boot path: no runtime yet, manifest just appeared.
795
+ if (!server && exists) {
796
+ runtimeWatcher = setTimeout(() => {
797
+ (async () => {
798
+ try {
799
+ process.stderr.write('\n[dev] amodal.json appeared — booting runtime...\n');
800
+ server = await bootRuntime();
801
+ lastManifestMtime = manifestMtime();
802
+ process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n\n`);
803
+ } catch (err: unknown) {
804
+ const msg = err instanceof Error ? err.message : String(err);
805
+ process.stderr.write(`[dev] Runtime auto-boot failed: ${msg}\n`);
806
+ process.stderr.write(' Try ctrl+C and re-running `amodal dev`.\n');
807
+ }
808
+ })().catch(() => undefined);
809
+ }, 500);
810
+ return;
811
+ }
812
+
813
+ // Auto-restart path: runtime is up, manifest was rewritten.
814
+ if (server && exists && lastManifestMtime !== null && mtime > lastManifestMtime) {
815
+ const previousServer = server;
816
+ // Clear server immediately so a second mtime change while
817
+ // we're restarting doesn't re-enter this branch.
818
+ server = null;
819
+ runtimeWatcher = setTimeout(() => {
820
+ (async () => {
821
+ try {
822
+ process.stderr.write('\n[dev] amodal.json changed — restarting runtime...\n');
823
+ await previousServer.stop();
824
+ server = await bootRuntime();
825
+ lastManifestMtime = manifestMtime();
826
+ process.stderr.write(` Runtime: http://localhost:${String(runtimePort)} (restarted)\n\n`);
827
+ } catch (err: unknown) {
828
+ const msg = err instanceof Error ? err.message : String(err);
829
+ process.stderr.write(`[dev] Runtime restart failed: ${msg}\n`);
830
+ process.stderr.write(' Try ctrl+C and re-running `amodal dev`.\n');
831
+ }
832
+ })().catch(() => undefined);
833
+ }, 500);
834
+ return;
835
+ }
836
+
837
+ runtimeWatcher = setTimeout(watchForRuntime, RUNTIME_WATCH_INTERVAL_MS);
838
+ };
839
+
840
+ runtimeWatcher = setTimeout(watchForRuntime, RUNTIME_WATCH_INTERVAL_MS);
841
+
589
842
  // Graceful shutdown
590
843
  const shutdown = async (signal: string): Promise<void> => {
591
844
  process.stderr.write(`\n[dev] Received ${signal}, shutting down...\n`);
592
845
 
846
+ stopRuntimeWatch();
847
+
593
848
  // Kill subprocesses first
594
849
  if (managedProcesses.length > 0) {
595
850
  log.debug('subprocess_shutdown', {count: managedProcesses.length});
596
851
  await killAll(managedProcesses);
597
852
  }
598
853
 
599
- await server.stop();
854
+ if (server) {
855
+ await server.stop();
856
+ }
600
857
  process.exit(0);
601
858
  };
602
859
 
@@ -641,14 +898,6 @@ export const devCommand: CommandModule = {
641
898
  describe: 'Only show errors',
642
899
  default: false,
643
900
  },
644
- 'studio-port': {
645
- type: 'number',
646
- describe: 'Port for Studio (defaults to port + 1)',
647
- },
648
- 'admin-port': {
649
- type: 'number',
650
- describe: 'Port for admin agent (defaults to port + 2)',
651
- },
652
901
  'no-studio': {
653
902
  type: 'boolean',
654
903
  describe: 'Do not spawn Studio subprocess',
@@ -671,17 +920,12 @@ export const devCommand: CommandModule = {
671
920
  const verbose = argv['verbose'] as number;
672
921
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
673
922
  const quiet = argv['quiet'] as boolean;
674
-
675
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
676
- const studioPort = argv['studio-port'] as number | undefined;
677
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
678
- const adminPort = argv['admin-port'] as number | undefined;
679
923
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
680
924
  const noStudio = (argv['no-studio'] as boolean) || process.env['AMODAL_NO_STUDIO'] === '1';
681
925
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
682
926
  const noAdmin = (argv['no-admin'] as boolean) || process.env['AMODAL_NO_ADMIN'] === '1';
683
927
  try {
684
- await runDev({port, studioPort, adminPort, host, resume, verbose, quiet, noStudio, noAdmin});
928
+ await runDev({port, host, resume, verbose, quiet, noStudio, noAdmin});
685
929
  } catch (err: unknown) {
686
930
  const msg = err instanceof Error ? err.message : String(err);
687
931
  process.stderr.write(`\n Error: ${msg}\n\n`);
@@ -238,8 +238,8 @@ export class PlatformClient {
238
238
 
239
239
  /**
240
240
  * Trigger a remote build:
241
- * 1. Get a presigned R2 upload URL from the platform API
242
- * 2. Upload the tarball directly to R2
241
+ * 1. Get scoped R2 temp credentials from the platform API
242
+ * 2. Upload the tarball directly to R2 with those creds
243
243
  * 3. Tell the platform API to trigger a Fly Machine build
244
244
  *
245
245
  * Returns a buildId for polling.
@@ -250,27 +250,45 @@ export class PlatformClient {
250
250
  tarballPath: string,
251
251
  message?: string,
252
252
  ): Promise<{ buildId: string }> {
253
- // Step 1: Get presigned upload URL
254
-
255
- const uploadInfo = await this.request<{buildId: string; tarballKey: string; uploadUrl: string}>(
253
+ // Step 1: Mint scoped R2 temp credentials for the upload
254
+
255
+ const uploadInfo = await this.request<{
256
+ buildId: string;
257
+ tarballKey: string;
258
+ bucket: string;
259
+ endpoint: string;
260
+ accessKeyId: string;
261
+ secretAccessKey: string;
262
+ sessionToken: string;
263
+ }>(
256
264
  'POST',
257
265
  '/api/deploys/build?action=upload-url',
258
266
  {appId},
259
267
  );
260
268
 
261
- // Step 2: Upload tarball directly to R2
269
+ // Step 2: Upload tarball directly to R2 with the scoped temp creds
262
270
  const {readFileSync} = await import('node:fs');
263
271
  const tarball = readFileSync(tarballPath);
264
272
 
265
- const uploadResp = await fetch(uploadInfo.uploadUrl, {
266
- method: 'PUT',
267
- headers: {'Content-Type': 'application/gzip'},
268
- body: tarball,
273
+ const {S3Client, PutObjectCommand} = await import('@aws-sdk/client-s3');
274
+ const s3 = new S3Client({
275
+ region: 'auto',
276
+ endpoint: uploadInfo.endpoint,
277
+ credentials: {
278
+ accessKeyId: uploadInfo.accessKeyId,
279
+ secretAccessKey: uploadInfo.secretAccessKey,
280
+ sessionToken: uploadInfo.sessionToken,
281
+ },
269
282
  });
270
283
 
271
- if (!uploadResp.ok) {
272
- throw new Error(`Tarball upload failed: ${uploadResp.status}`);
273
- }
284
+ await s3.send(
285
+ new PutObjectCommand({
286
+ Bucket: uploadInfo.bucket,
287
+ Key: uploadInfo.tarballKey,
288
+ Body: tarball,
289
+ ContentType: 'application/gzip',
290
+ }),
291
+ );
274
292
 
275
293
  // Step 3: Trigger the build
276
294