@xdelivered/emberflow 0.2.0 → 0.5.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 (116) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +208 -16
  19. package/dist/server/nodesPayload.d.ts +14 -1
  20. package/dist/server/nodesPayload.js +15 -2
  21. package/dist/server/projectMode.js +5 -2
  22. package/dist/server/runRegistry.d.ts +52 -1
  23. package/dist/server/runRegistry.js +170 -1
  24. package/dist/server/sourceNav.d.ts +77 -0
  25. package/dist/server/sourceNav.js +503 -0
  26. package/dist/server/subflowRunner.d.ts +48 -1
  27. package/dist/server/subflowRunner.js +34 -7
  28. package/dist/server/updateCheck.d.ts +68 -0
  29. package/dist/server/updateCheck.js +142 -0
  30. package/dist/src/engine/executor.js +4 -3
  31. package/dist/src/engine/registry.d.ts +27 -2
  32. package/dist/src/engine/registry.js +84 -2
  33. package/dist/src/nodes/index.d.ts +8 -2
  34. package/dist/src/nodes/index.js +7 -3
  35. package/dist/src/nodes/login.d.ts +3 -1
  36. package/dist/src/nodes/login.js +2 -2
  37. package/package.json +28 -7
  38. package/server/agentRoute.test.ts +20 -0
  39. package/server/agents/codexParse.test.ts +37 -0
  40. package/server/agents/codexParse.ts +34 -0
  41. package/server/agents/detect.test.ts +26 -7
  42. package/server/agents/detect.ts +82 -14
  43. package/server/agents/modelRejectionHint.ts +2 -1
  44. package/server/agents/prompt.test.ts +128 -0
  45. package/server/agents/prompt.ts +102 -3
  46. package/server/agents/runManager.test.ts +9 -0
  47. package/server/agents/runManager.ts +37 -7
  48. package/server/client.ts +10 -4
  49. package/server/index.ts +213 -15
  50. package/server/nodeRun.test.ts +189 -0
  51. package/server/nodesPayload.test.ts +38 -0
  52. package/server/nodesPayload.ts +28 -3
  53. package/server/projectMode.ts +5 -2
  54. package/server/runRegistry.ts +200 -3
  55. package/server/setupStatus.test.ts +3 -0
  56. package/server/sourceNav.test.ts +382 -0
  57. package/server/sourceNav.ts +644 -0
  58. package/server/sourceNavRoute.test.ts +163 -0
  59. package/server/stepDrill.test.ts +380 -0
  60. package/server/subflowRunner.ts +92 -11
  61. package/server/updateCheck.test.ts +209 -0
  62. package/server/updateCheck.ts +175 -0
  63. package/server/workflowTestRoute.test.ts +1 -1
  64. package/src/App.tsx +82 -2
  65. package/src/components/AgentConsole.tsx +7 -280
  66. package/src/components/AgentStream.test.tsx +88 -0
  67. package/src/components/AgentStream.tsx +303 -0
  68. package/src/components/CreateModal.tsx +43 -9
  69. package/src/components/Dock.tsx +1 -1
  70. package/src/components/EmptyState.test.tsx +49 -0
  71. package/src/components/EmptyState.tsx +61 -0
  72. package/src/components/EnvironmentDialog.tsx +4 -1
  73. package/src/components/EnvironmentPicker.tsx +8 -3
  74. package/src/components/EnvironmentsDialog.tsx +4 -1
  75. package/src/components/InfraPanel.tsx +30 -2
  76. package/src/components/InfrastructureDialog.test.tsx +97 -0
  77. package/src/components/InfrastructureDialog.tsx +111 -0
  78. package/src/components/Inspector.tsx +9 -26
  79. package/src/components/RunbookView.tsx +70 -6
  80. package/src/components/SettingsDialog.tsx +4 -59
  81. package/src/components/Sidebar.tsx +6 -26
  82. package/src/components/SourceNavigator.test.tsx +226 -0
  83. package/src/components/SourceNavigator.tsx +520 -0
  84. package/src/components/StatusBar.tsx +227 -26
  85. package/src/components/Toolbar.tsx +28 -16
  86. package/src/components/UpdateChip.test.tsx +31 -0
  87. package/src/components/WelcomeDialog.test.tsx +384 -13
  88. package/src/components/WelcomeDialog.tsx +996 -177
  89. package/src/engine/executor.ts +4 -3
  90. package/src/engine/registry.sourceRef.test.ts +112 -0
  91. package/src/engine/registry.ts +103 -2
  92. package/src/lib/guidedQuestions.test.ts +224 -0
  93. package/src/lib/guidedQuestions.ts +181 -0
  94. package/src/lib/runbookModel.ts +3 -3
  95. package/src/lib/runbookProjection.test.ts +43 -0
  96. package/src/lib/runbookProjection.ts +26 -6
  97. package/src/nodes/index.ts +10 -3
  98. package/src/nodes/login.ts +5 -2
  99. package/src/store/agentClient.ts +27 -8
  100. package/src/store/apiTree.ts +12 -0
  101. package/src/store/builderStore.test.ts +441 -99
  102. package/src/store/builderStore.ts +385 -314
  103. package/src/store/nodeMeta.ts +10 -2
  104. package/src/store/serverRunner.ts +56 -5
  105. package/src/store/setupClient.ts +7 -2
  106. package/src/store/sourceNavClient.ts +48 -0
  107. package/src/store/updateClient.ts +50 -0
  108. package/studio-dist/assets/index-BbzppDpt.js +65 -0
  109. package/studio-dist/assets/index-CLf6blcA.css +1 -0
  110. package/studio-dist/index.html +2 -2
  111. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  112. package/templates/skills/emberflow-model-process/SKILL.md +92 -9
  113. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  114. package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
  115. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  116. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -1,20 +1,40 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
  import type { AgentKind } from './types';
3
5
 
4
- const CANDIDATES: AgentKind[] = ['codex', 'claude'];
5
-
6
6
  const VERSION_RE = /\d+\.\d+\.\d+/;
7
7
 
8
+ /**
9
+ * Where each agent CLI may live, beyond whatever PATH resolves. PATH shims can
10
+ * pin an OLD version (e.g. a superset-managed ~/.superset/bin/codex frozen at
11
+ * a release the API no longer accepts) while a newer binary sits elsewhere —
12
+ * so we probe every known location and pick the NEWEST, never just the first.
13
+ */
14
+ const CANDIDATE_BINS: Record<AgentKind, string[]> = {
15
+ codex: [
16
+ 'codex', // PATH resolution (may be a pinned shim)
17
+ join(homedir(), '.local', 'bin', 'codex'),
18
+ '/opt/homebrew/bin/codex',
19
+ '/usr/local/bin/codex',
20
+ // The ChatGPT desktop app bundles its own codex, updated with the app.
21
+ '/Applications/ChatGPT.app/Contents/Resources/codex',
22
+ ],
23
+ claude: ['claude', join(homedir(), '.local', 'bin', 'claude'), '/opt/homebrew/bin/claude', '/usr/local/bin/claude'],
24
+ };
25
+
8
26
  export interface DetectedAgent {
9
27
  kind: AgentKind;
10
28
  version: string | null;
29
+ /** The concrete binary the newest version was found at — what spawns should use. */
30
+ bin: string;
11
31
  }
12
32
 
13
33
  /**
14
- * Real PATH probe: runs `<bin> --version` and parses the first semver-ish
15
- * token out of stdout. Returns `undefined` when the binary isn't present/
16
- * runnable (spawn error, timeout, or nonzero exit); `null` version when it
17
- * ran but produced no parseable version string.
34
+ * Real probe: runs `<bin> --version` and parses the first semver-ish token out
35
+ * of stdout. Returns `undefined` when the binary isn't present/runnable (spawn
36
+ * error, timeout, or nonzero exit); `null` version when it ran but produced no
37
+ * parseable version string.
18
38
  */
19
39
  export function probe(bin: string): { version: string | null } | undefined {
20
40
  const result = spawnSync(bin, ['--version'], {
@@ -27,17 +47,65 @@ export function probe(bin: string): { version: string | null } | undefined {
27
47
  return { version: match ? match[0] : null };
28
48
  }
29
49
 
50
+ /** Numeric segment compare; a parseable version always beats null.
51
+ * Shared: agent-CLI detection here, and the package update check
52
+ * (server/updateCheck.ts) both rank semver-ish strings with it. */
53
+ export function newer(a: string | null, b: string | null): boolean {
54
+ if (a === null) return false;
55
+ if (b === null) return true;
56
+ const as = a.split('.').map(Number);
57
+ const bs = b.split('.').map(Number);
58
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
59
+ const d = (as[i] ?? 0) - (bs[i] ?? 0);
60
+ if (d !== 0) return d > 0;
61
+ }
62
+ return false;
63
+ }
64
+
30
65
  /**
31
- * Detects which coding-agent CLIs are available on PATH, and the version
32
- * each reports (null when present but unparseable). `probeBin` is
33
- * injectable so tests can stub PATH presence/version without shelling out
34
- * for real binaries.
66
+ * Detects which coding-agent CLIs are available probing PATH plus the known
67
+ * install locations for each kind and keeps the NEWEST version found per
68
+ * kind, with the binary path it came from. `probeBin` is injectable so tests
69
+ * can stub presence/version per location without shelling out.
35
70
  */
36
- export function detectAgents(probeBin: (bin: string) => { version: string | null } | undefined = probe): DetectedAgent[] {
71
+ export function detectAgents(
72
+ probeBin: (bin: string) => { version: string | null } | undefined = probe,
73
+ ): DetectedAgent[] {
37
74
  const found: DetectedAgent[] = [];
38
- for (const kind of CANDIDATES) {
39
- const result = probeBin(kind);
40
- if (result) found.push({ kind, version: result.version });
75
+ for (const kind of Object.keys(CANDIDATE_BINS) as AgentKind[]) {
76
+ let best: DetectedAgent | undefined;
77
+ const seen = new Set<string>();
78
+ for (const bin of CANDIDATE_BINS[kind]) {
79
+ if (seen.has(bin)) continue;
80
+ seen.add(bin);
81
+ const result = probeBin(bin);
82
+ if (!result) continue;
83
+ if (!best || newer(result.version, best.version)) {
84
+ best = { kind, version: result.version, bin };
85
+ }
86
+ }
87
+ if (best) found.push(best);
41
88
  }
42
89
  return found;
43
90
  }
91
+
92
+ // Probing every candidate spawns several subprocesses; /setup-status and
93
+ // /agent/available are polled by the studio, so cache briefly.
94
+ const RESOLVE_TTL_MS = 30_000;
95
+ let cachedAt = 0;
96
+ let cached: DetectedAgent[] | null = null;
97
+
98
+ /** Cached detection — the newest binary per kind, refreshed every 30s. */
99
+ export function detectAgentsCached(): DetectedAgent[] {
100
+ const now = Date.now();
101
+ if (!cached || now - cachedAt > RESOLVE_TTL_MS) {
102
+ cached = detectAgents();
103
+ cachedAt = now;
104
+ }
105
+ return cached;
106
+ }
107
+
108
+ /** The concrete binary path spawns should use for `kind` — the newest found. */
109
+ export function resolveAgentBin(kind: AgentKind): string | undefined {
110
+ return detectAgentsCached().find((a) => a.kind === kind)?.bin;
111
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AgentKind } from './types';
2
2
 
3
- const MODEL_REJECTION_RE = /unknown model|unsupported model|invalid model|model.*not.*(found|supported)/i;
3
+ const MODEL_REJECTION_RE =
4
+ /unknown model|unsupported model|invalid model|model.*not.*(found|supported)|model requires a newer version/i;
4
5
 
5
6
  /**
6
7
  * When an agent CLI exits nonzero without ever emitting a terminal `done`,
@@ -484,6 +484,15 @@ describe('buildPrompt', () => {
484
484
  expect(prompt).toMatch(/open questions/i);
485
485
  });
486
486
 
487
+ it('supports amending an existing manifest in place for targeted add/remove/correct instructions', () => {
488
+ const prompt = buildPrompt(scout, apisDir, relPath);
489
+ expect(prompt).toMatch(/AMENDMENT vs FULL RESCAN/);
490
+ expect(prompt).toMatch(/UPDATE that file in place/i);
491
+ expect(prompt).toMatch(/preserve every item you were not asked to change/i);
492
+ expect(prompt).toMatch(/bump "scannedAt"/);
493
+ expect(prompt).toMatch(/FULL RESCAN.*remains the default/i);
494
+ });
495
+
487
496
  it('does NOT get the doctor rule, the mocks guidance, or the traceKind nudge (it edits no operation)', () => {
488
497
  const prompt = buildPrompt(scout, apisDir, relPath);
489
498
  expect(prompt).not.toMatch(/run doctor/i);
@@ -504,6 +513,125 @@ describe('buildPrompt', () => {
504
513
  });
505
514
  });
506
515
 
516
+ describe('guided-setup', () => {
517
+ const guided: AgentIntent = {
518
+ action: 'guided-setup',
519
+ instruction: 'I want dev and prod environments.',
520
+ };
521
+
522
+ it('includes the user notes verbatim and orchestrates the steps IN ORDER, reading state first + skipping done work', () => {
523
+ const prompt = buildPrompt(guided, apisDir, relPath);
524
+ expect(prompt).toContain(guided.instruction);
525
+ expect(prompt).toMatch(/IN ORDER/);
526
+ expect(prompt).toMatch(/READ THE GROUND TRUTH FIRST/);
527
+ expect(prompt).toMatch(/skip(ping)? .*(done|already satisfied)/i);
528
+ });
529
+
530
+ it('explicitly PERMITS exploration (unlike setup-environments) — it may read the project like the scout', () => {
531
+ const prompt = buildPrompt(guided, apisDir, relPath);
532
+ expect(prompt).toMatch(/MAY read the project/i);
533
+ expect(prompt).toMatch(/the way the infrastructure scout does/i);
534
+ // Names the setup-environments no-explore rule it is inverting.
535
+ expect(prompt).toMatch(/setup-environments/);
536
+ });
537
+
538
+ it('covers greenfield vs brownfield judgment and writes the infrastructure manifest', () => {
539
+ const prompt = buildPrompt(guided, apisDir, relPath);
540
+ expect(prompt).toMatch(/GREENFIELD/);
541
+ expect(prompt).toMatch(/BROWNFIELD/);
542
+ expect(prompt).toMatch(/emberflow\/infrastructure\.json/);
543
+ expect(prompt).toMatch(/"greenfield": true/);
544
+ });
545
+
546
+ it('installs skills via the in-process bin with the project-language flag (--ts for a TS project)', () => {
547
+ const prompt = buildPrompt(guided, apisDir, relPath, [], 'typescript');
548
+ expect(prompt).toMatch(/emberflow\.mjs init --local --no-launch --no-git --ts/);
549
+ });
550
+
551
+ it('installs skills with --js for a javascript project', () => {
552
+ const prompt = buildPrompt(guided, apisDir, relPath, [], 'javascript');
553
+ expect(prompt).toMatch(/emberflow\.mjs init --local --no-launch --no-git --js/);
554
+ });
555
+
556
+ // With a runner-verified snapshot, the prompt states KNOWN facts and
557
+ // forbids re-probing them — no wasted first turn of CLI checks.
558
+ it('injects the runner-verified state as KNOWN facts instead of a probe step', () => {
559
+ const prompt = buildPrompt(guided, apisDir, relPath, [], 'typescript', null, {
560
+ gitRepo: true,
561
+ skillsInstalled: true,
562
+ environmentsConfigured: false,
563
+ infrastructurePresent: false,
564
+ opCount: 1,
565
+ onlyHello: true,
566
+ });
567
+ expect(prompt).toMatch(/KNOWN PROJECT STATE/);
568
+ expect(prompt).toMatch(/TRUST it, do not re-check/);
569
+ expect(prompt).toMatch(/git repository: yes/);
570
+ expect(prompt).toMatch(/only the default hello example/);
571
+ expect(prompt).not.toMatch(/READ THE GROUND TRUTH FIRST/);
572
+ });
573
+
574
+ it('missing git in the snapshot carries the stop-and-tell-user instruction', () => {
575
+ const prompt = buildPrompt(guided, apisDir, relPath, [], 'typescript', null, {
576
+ gitRepo: false,
577
+ skillsInstalled: false,
578
+ environmentsConfigured: false,
579
+ infrastructurePresent: false,
580
+ opCount: 1,
581
+ onlyHello: true,
582
+ });
583
+ expect(prompt).toMatch(/There is NO git repository: STOP/);
584
+ });
585
+
586
+ it('without a snapshot, falls back to the self-probe ground-truth step', () => {
587
+ const prompt = buildPrompt(guided, apisDir, relPath);
588
+ expect(prompt).toMatch(/READ THE GROUND TRUTH FIRST/);
589
+ });
590
+
591
+ it('runs the environments interview and ENDS with numbered questions', () => {
592
+ const prompt = buildPrompt(guided, apisDir, relPath);
593
+ expect(prompt).toMatch(/ENVIRONMENTS INTERVIEW/);
594
+ expect(prompt).toMatch(/emberflow\.environments\.json/);
595
+ // The interview is a staged, structured form: ask BEFORE writing, via
596
+ // the emberflow-questions block the studio renders interactively —
597
+ // and skipping it is called out as failing the step.
598
+ expect(prompt).toMatch(/ask FIRST \(on the very first turn/i);
599
+ // Turn-shape contract: questions land immediately, work happens after.
600
+ expect(prompt).toMatch(/TURN SHAPE/);
601
+ expect(prompt).toMatch(/run NO commands and write NO files first/);
602
+ expect(prompt).toMatch(/emberflow-questions/);
603
+ expect(prompt).toMatch(/asks nothing has FAILED/i);
604
+ // Terse-output contract + the closing first-build question with the
605
+ // look-around escape hatch.
606
+ expect(prompt).toMatch(/Be TERSE/);
607
+ expect(prompt).toMatch(/What do you want to build first\?/);
608
+ expect(prompt).toMatch(/"action":"finish"/);
609
+ });
610
+
611
+ it('never writes secret VALUES and states the connection-proof + wrap-up summary', () => {
612
+ const prompt = buildPrompt(guided, apisDir, relPath);
613
+ expect(prompt).toMatch(/NEVER write secret or credential VALUES/i);
614
+ expect(prompt).toMatch(/CONNECTION PROOF/);
615
+ expect(prompt).toMatch(/no model ids/i);
616
+ expect(prompt).toMatch(/WRAP-UP \+ FIRST BUILD/);
617
+ expect(prompt).toMatch(/One line per completed step/);
618
+ });
619
+
620
+ it('does NOT get the doctor rule, mocks guidance, or the known-infrastructure preamble (it writes the manifest)', () => {
621
+ const manifest = {
622
+ version: 1,
623
+ greenfield: false,
624
+ items: [
625
+ { id: 'pg', kind: 'database' as const, name: 'Postgres', evidence: [], suggestedSecretRefs: ['DATABASE_URL'], suggestedVars: [] },
626
+ ],
627
+ };
628
+ const prompt = buildPrompt(guided, apisDir, relPath, [], 'typescript', manifest);
629
+ expect(prompt).not.toMatch(/run doctor/i);
630
+ expect(prompt).not.toMatch(/"mocks"/);
631
+ expect(prompt).not.toMatch(/Known project infrastructure \(from/);
632
+ });
633
+ });
634
+
507
635
  describe('known-infrastructure preamble injection', () => {
508
636
  const editFlow: AgentIntent = { action: 'edit-flow', flowId: 'hello', instruction: 'x' };
509
637
  const manifest = {
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { dirname, join, resolve } from 'node:path';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import type { InfrastructureManifest } from '../infrastructure';
@@ -5,8 +6,17 @@ import type { InfrastructureManifest } from '../infrastructure';
5
6
  /** Absolute path to the register-API CLI bin (`node <this> <cmd>`), resolved
6
7
  * from this file's location. This exact invocation is the ONLY sandbox-safe
7
8
  * way to run the CLI: `npx emberflow` / `tsx` spawn a tsx IPC pipe the codex
8
- * sandbox blocks; this bin runs the CLI in-process under tsx's register API. */
9
- const EMBERFLOW_BIN = resolve(dirname(fileURLToPath(import.meta.url)), '../../bin/emberflow.mjs');
9
+ * sandbox blocks; this bin runs the CLI in-process under tsx's register API.
10
+ *
11
+ * Two layouts: in the source repo this file is server/agents/prompt.ts and
12
+ * bin/ is two levels up; in the shipped package it runs from
13
+ * dist/server/agents/prompt.js while bin/ stays at the package ROOT — three
14
+ * levels up. Probe both so consumer installs don't hand agents a dead path. */
15
+ const EMBERFLOW_BIN = (() => {
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const candidates = [resolve(here, '../../bin/emberflow.mjs'), resolve(here, '../../../bin/emberflow.mjs')];
18
+ return candidates.find((p) => existsSync(p)) ?? candidates[0];
19
+ })();
10
20
 
11
21
  export type AgentIntent =
12
22
  | { action: 'new-scenario'; flowId: string; instruction: string }
@@ -16,6 +26,7 @@ export type AgentIntent =
16
26
  | { action: 'setup-auth'; environment: string; instruction: string }
17
27
  | { action: 'setup-environments'; instruction: string }
18
28
  | { action: 'scout-infrastructure'; instruction: string }
29
+ | { action: 'guided-setup'; instruction: string }
19
30
  | { action: 'cover-operation'; flowId: string; instruction: string }
20
31
  | { action: 'ask'; flowId?: string; instruction: string };
21
32
 
@@ -60,6 +71,18 @@ export interface AvailableNode {
60
71
  * instead of inventing parallel config. The `scout-infrastructure` intent
61
72
  * itself is what WRITES this manifest, so it does not receive the block.
62
73
  */
74
+ /** Ground truth the RUNNER already verified — injected into the guided-setup
75
+ * prompt so the agent starts from known facts instead of burning its first
76
+ * turn re-deriving them with CLI probes and file reads. */
77
+ export interface GuidedSetupState {
78
+ gitRepo: boolean;
79
+ skillsInstalled: boolean;
80
+ environmentsConfigured: boolean;
81
+ infrastructurePresent: boolean;
82
+ opCount: number;
83
+ onlyHello: boolean;
84
+ }
85
+
63
86
  export function buildPrompt(
64
87
  intent: AgentIntent,
65
88
  apisDir: string,
@@ -67,6 +90,7 @@ export function buildPrompt(
67
90
  availableNodes: AvailableNode[] = [],
68
91
  projectLanguage: 'javascript' | 'typescript' = 'typescript',
69
92
  infrastructure: InfrastructureManifest | null = null,
93
+ guidedState: GuidedSetupState | null = null,
70
94
  ): string {
71
95
  const lines: string[] = [];
72
96
 
@@ -120,7 +144,7 @@ export function buildPrompt(
120
144
  // REUSES it instead of inventing parallel config. Omitted for the scout
121
145
  // intent — it's the one that WRITES the manifest, so priming it with the
122
146
  // manifest's own contents would be circular.
123
- if (intent.action !== 'scout-infrastructure') {
147
+ if (intent.action !== 'scout-infrastructure' && intent.action !== 'guided-setup') {
124
148
  const INFRASTRUCTURE_ITEM_CAP = 30;
125
149
  if (infrastructure && infrastructure.items.length > 0) {
126
150
  lines.push('Known project infrastructure (from emberflow/infrastructure.json — what this project already uses):');
@@ -420,6 +444,10 @@ export function buildPrompt(
420
444
  lines.push(`User instruction (verbatim): ${intent.instruction}`);
421
445
  lines.push('');
422
446
  }
447
+ lines.push(
448
+ `AMENDMENT vs FULL RESCAN: if the instruction above asks for an amendment to the existing manifest — add, remove, or correct SPECIFIC items — and emberflow/infrastructure.json already exists, UPDATE that file in place: preserve every item you were not asked to change, apply only the requested change, and bump "scannedAt" to the current timestamp. Do NOT regenerate the whole manifest from scratch for a targeted amendment. A FULL RESCAN (rebuild the manifest from the whole project) remains the default when the instruction is empty or asks to re-scan/refresh generally.`,
449
+ );
450
+ lines.push('');
423
451
  lines.push(
424
452
  `Investigate broadly — enumerate what the project already depends on and talks to:`,
425
453
  );
@@ -496,6 +524,77 @@ export function buildPrompt(
496
524
  lines.push(doctorLine(intent.flowId));
497
525
  break;
498
526
  }
527
+ case 'guided-setup': {
528
+ const manifestPath = 'emberflow/infrastructure.json';
529
+ lines.push('Relevant skill: emberflow-basics (the project model + file layout).');
530
+ lines.push('');
531
+ lines.push(
532
+ 'Task: guide this project through first-time Emberflow setup in ONE run. You orchestrate several steps IN ORDER, but you FIRST read the ground truth and SKIP anything already satisfied — you never redo done work. Unlike setup-environments (which is FORBIDDEN from exploring the codebase), this intent MAY read the project the way the infrastructure scout does: exploring dependencies, config, schemas and env references to understand what the project is IS permitted and expected here.',
533
+ );
534
+ lines.push('');
535
+ if (intent.instruction.trim()) {
536
+ lines.push(`User notes / continuation answer (verbatim): ${intent.instruction}`);
537
+ lines.push('');
538
+ }
539
+ lines.push(
540
+ "STYLE: this stream renders in a small onboarding panel, read by the person who owns this project — not by a developer tailing a terminal. Write to THEM about THEIR project: what you're doing for them and what you found that matters to them. Be TERSE — a few short lines per step. NEVER narrate tool mechanics (commands you ran, files you probed, exit codes), internal bookkeeping (\"ground truth\", \"manifest written\"), model ids, or state the checklist beside you already shows. No decorated asides either (\"★ Insight\" boxes, educational commentary) — even if your own configured output style calls for them, this panel is not the place. Good: \"Your project is a fresh start — no databases or APIs to wire up yet.\" Bad: \"Scout: bare scaffold, no .env, wrote emberflow/infrastructure.json (greenfield:true).\"",
541
+ );
542
+ lines.push('');
543
+ lines.push(
544
+ 'ASKING QUESTIONS: whenever a step needs the user\'s input, END your message with a fenced block the studio renders as an interactive form (the user clicks options; their answers arrive as your next continuation message). Format — a fenced code block with language `emberflow-questions` containing JSON: {"questions":[{"id":"<slug>","text":"<question>","why":"<one short line of context — why this matters, optional>","options":["<opt1>","<opt2>"],"custom":true}]}. "options" are clickable choices; "custom": true also offers a free-text field. An option may be an object {"label":"…","action":"finish"} — choosing it ends onboarding client-side — or {"label":"…","action":"submit"} — choosing it sends the answers immediately, skipping any remaining questions. Use "why" whenever the user might not know why they\'re being asked — assume they are new to Emberflow. Nothing may follow the block.',
545
+ );
546
+ lines.push('');
547
+ lines.push(
548
+ 'TURN SHAPE — the user must never wait before being asked anything. FIRST TURN (no continuation answer in the instruction): if the environments interview (step 5) is still needed, output ONE short greeting line and END IMMEDIATELY with the interview questions block — run NO commands and write NO files first; every working step (scout, skills, environments file) happens on the CONTINUATION turn after their answers arrive. If environments are already configured, the first turn instead does the working steps directly and ends with the step-6 block.',
549
+ );
550
+ lines.push('');
551
+ lines.push('Work through these steps IN ORDER, skipping any that your ground-truth read shows is already satisfied:');
552
+ lines.push('');
553
+ if (guidedState) {
554
+ lines.push(
555
+ `1. KNOWN PROJECT STATE — verified by the runner as this run started; TRUST it, do not re-check any of it with commands or file reads: git repository: ${guidedState.gitRepo ? 'yes' : 'NO'}; agent skills installed: ${guidedState.skillsInstalled ? 'yes' : 'no'}; environments configured: ${guidedState.environmentsConfigured ? 'yes' : 'no'}; infrastructure manifest (${manifestPath}): ${guidedState.infrastructurePresent ? 'present' : 'absent'}; operations: ${guidedState.opCount}${guidedState.onlyHello ? ' (only the default hello example)' : ''}. Skip every step this state already satisfies.${guidedState.gitRepo ? '' : ' There is NO git repository: STOP and tell the user to run `git init && git add -A && git commit -m "initial"` — nothing else can proceed without it.'}`,
556
+ );
557
+ } else {
558
+ lines.push(
559
+ '1. READ THE GROUND TRUTH FIRST. Before acting, inspect what already exists so you can skip done work: whether this is a git repo, whether the agent skills are installed (.claude/skills/emberflow-basics or the codex/home equivalents), whether emberflow.environments.json exists, whether ' +
560
+ manifestPath +
561
+ ' exists, and whether the project has operations beyond the default hello example (list-workflows). If there is NO git repository, STOP and tell the user the exact command to run — `git init && git add -A && git commit -m "initial"` — because every later step depends on git snapshotting and nothing here can proceed without it.',
562
+ );
563
+ }
564
+ lines.push('');
565
+ lines.push(
566
+ `2. GREENFIELD JUDGMENT + SCOUT. If ${manifestPath} is already present, SKIP this step (say so). Otherwise inspect the project the way the infrastructure scout does — dependencies (package.json + lockfiles, requirements.txt / pyproject.toml, go.mod, Gemfile, composer.json), config (docker-compose.yml, Dockerfile, .env.example NAMES only, prisma/schema.prisma, ORM/framework config), ORM schemas / migrations, env-var REFERENCES in source (process.env.X — NAMES only), HTTP clients / SDKs, and the project's own routes. If the project has real infrastructure (BROWNFIELD), run the full scout and WRITE ${manifestPath} describing it (version, scannedAt, greenfield:false, summary, items[] each with kind, name, evidence file+note, suggestedSecretRefs env-var NAMES only). If it is a bare scaffold (GREENFIELD — no databases, external APIs or providers), WRITE ${manifestPath} with "greenfield": true, empty items, and a one-line summary saying so — do NOT invent items to fill space. Manifest kinds: database, http-api, queue, cache, email, llm, auth, framework, storage, other.`,
567
+ );
568
+ lines.push('');
569
+ lines.push(
570
+ `3. SKILLS. If the agent skills are already installed (from your ground-truth read), SKIP this step. Otherwise install them by running the init CLI — it is idempotent and, because this project already has a config, adds ONLY the skills (and any missing .gitignore lines), leaving your config and operations untouched:`,
571
+ );
572
+ lines.push(
573
+ ` node ${EMBERFLOW_BIN} init --local --no-launch --no-git ${projectLanguage === 'javascript' ? '--js' : '--ts'}`,
574
+ );
575
+ lines.push(
576
+ ` Report which skill files it created. The ${projectLanguage === 'javascript' ? '--js' : '--ts'} flag matches this project's language so init doesn't refuse to run against the existing config. If init CANNOT install them (the command errors), emit the exact command for the user to run themselves and move on — don't fail the whole run over skills.`,
577
+ );
578
+ lines.push('');
579
+ lines.push(
580
+ '4. CONNECTION PROOF. This run itself proves the agent CLI works end-to-end — you ARE that agent. No separate action, no model ids: one plain sentence at wrap-up like "Connection verified — I\'m running against your project."',
581
+ );
582
+ lines.push('');
583
+ lines.push(
584
+ `5. ENVIRONMENTS INTERVIEW — ask FIRST (on the very first turn, before any other work; see TURN SHAPE), write after. If emberflow.environments.json already configures environments, SKIP (you may still refine on request). Otherwise end the FIRST turn with an emberflow-questions block whose FIRST question is whether to set environments up now at all: text like "Set up your environments now?", why like "Environments tell Emberflow where runs point — mock data now, your real dev or prod systems later. Takes a minute, and you can change it any time.", options ["Set up now",{"label":"Later — I'll do it from the setup checklist","action":"submit"}] (action "submit" sends the answer immediately, skipping the remaining questions). If they choose later: on the continuation turn do steps 2–4 only, then step 6, noting they can return via the Environments step in the setup checklist. If now: the SAME block also carries the detail questions (each with a one-line "why"): which environments (options like "dev + prod", "dev + staging + prod", custom), which is the default, which are production-like (protected), and their base URLs (custom text) — the form is a one-at-a-time wizard, so later answers simply arrive alongside; if they chose "Later" the other answers are ignored. A guided setup that decides everything itself and asks nothing has FAILED this step. CONTINUATION turn (answers arrive as your instruction): NOW do the working steps 2–4 (scout, skills, connection) and write emberflow.environments.json (STRUCTURE ONLY) from their answers — "defaultEnvironment" plus entries under "environments" (kebab/lower-case names); "secrets" is a LIST of key NAMES, never a value map; base URLs and non-secret config under "vars"; production-like environments get "protected": true (forces studio safe mode). Unknown values → clearly-named placeholders (e.g. "baseUrl": "https://CHANGE-ME.example.com"). Mention once that the file is gitignored. Then finish with step 6.`,
585
+ );
586
+ lines.push('');
587
+ lines.push(
588
+ 'NEVER write secret or credential VALUES anywhere — not in emberflow.environments.json (which holds key NAMES only), not in ' +
589
+ manifestPath +
590
+ " (env-var NAMES only), and not in your output. Environment auth is set only via set-environment-auth (refs/names, never values); manifests carry names only. When auth or API keys are involved, scaffold at most the secret key NAMES and tell the user to enter the actual secret values in the studio's Manage Environment dialog — never in this chat.",
591
+ );
592
+ lines.push('');
593
+ lines.push(
594
+ '6. WRAP-UP + FIRST BUILD. One line per completed step (done/skipped) plus anything that genuinely needs the user (secret values to enter) — no prose report. Then END with a final emberflow-questions block: {"id":"first-build","text":"What do you want to build first?","options":[{"label":"Just look around","action":"finish"}],"custom":true}. If the user answers with a description, scaffold that operation in a continuation turn — create the operation file(s) following the emberflow-new-workflow skill (runnable in mock mode, scenarios included) and say which operation to open.',
595
+ );
596
+ break;
597
+ }
499
598
  case 'ask': {
500
599
  lines.push('Relevant skill: emberflow-basics (how the project is structured) — read it only if answering needs it.');
501
600
  lines.push('');
@@ -127,6 +127,15 @@ describe('AgentRunManager', () => {
127
127
  await collectUntilDone(manager, id);
128
128
  });
129
129
 
130
+ it('shutdown cancels the active run (and no-ops when idle)', async () => {
131
+ const manager = new AgentRunManager(projectDir, flowsDir, pathOf);
132
+ expect(() => manager.shutdown()).not.toThrow(); // idle: nothing active
133
+ const id = manager.start(intent);
134
+ expect(() => manager.shutdown()).not.toThrow();
135
+ // The cancelled run must still reach a terminal state — no hung adapter.
136
+ await collectUntilDone(manager, id);
137
+ });
138
+
130
139
  it('rejects a flowId that tries to escape flowsDir', () => {
131
140
  const manager = new AgentRunManager(projectDir, flowsDir, pathOf);
132
141
  const badIntent: AgentIntent = { action: 'edit-flow', flowId: '../../etc/passwd', instruction: 'x' };
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { changedFiles, diffSince, isGitRepo, revert as gitRevert, snapshot, type GitSnapshot } from './gitScope';
3
- import { buildPrompt, type AgentIntent, type AvailableNode } from './prompt';
3
+ import { buildPrompt, type AgentIntent, type AvailableNode, type GuidedSetupState } from './prompt';
4
4
  import type { InfrastructureManifest } from '../infrastructure';
5
5
  import { spawnCodex, type SpawnedAgent } from './codexAdapter';
6
6
  import { spawnClaude } from './claudeAdapter';
7
+ import { resolveAgentBin } from './detect';
7
8
  import type { AgentEvent, AgentKind } from './types';
8
9
  import { isPathWithin } from '../pathSafety';
9
10
 
@@ -102,6 +103,12 @@ export class AgentRunManager {
102
103
  * so agents REUSE known infrastructure instead of inventing parallel config.
103
104
  */
104
105
  private readonly infrastructure: () => InfrastructureManifest | null = () => null,
106
+ /**
107
+ * Getter for the runner-verified setup snapshot injected into guided-setup
108
+ * prompts as KNOWN state — so the agent doesn't burn its first turn
109
+ * re-deriving facts (git? skills? envs? ops?) the server already computed.
110
+ */
111
+ private readonly guidedState: () => GuidedSetupState | null = () => null,
105
112
  ) {}
106
113
 
107
114
  /** Validates the project + flow, snapshots git, spawns the adapter, and returns the new run's id. */
@@ -110,7 +117,10 @@ export class AgentRunManager {
110
117
  throw new AgentStartError('An agent run is already active for this project', 409);
111
118
  }
112
119
  if (!isGitRepo(this.projectDir)) {
113
- throw new AgentStartError(`${this.projectDir} is not a git repository`, 400);
120
+ throw new AgentStartError(
121
+ `${this.projectDir} is not a git repository — Emberflow snapshots agent changes with git so you can review and revert them. Run: git init && git add -A && git commit -m "initial" — then retry.`,
122
+ 400,
123
+ );
114
124
  }
115
125
 
116
126
  // `new-operation` creates a brand-new flow, so there's no existing
@@ -128,11 +138,13 @@ export class AgentRunManager {
128
138
  } else if (
129
139
  intent.action === 'setup-auth' ||
130
140
  intent.action === 'setup-environments' ||
131
- intent.action === 'scout-infrastructure'
141
+ intent.action === 'scout-infrastructure' ||
142
+ intent.action === 'guided-setup'
132
143
  ) {
133
144
  // No flow file: setup-auth targets an environment, setup-environments the
134
- // environments file, and scout-infrastructure reads the whole project and
135
- // writes emberflow/infrastructure.json none has a relPath to resolve.
145
+ // environments file, scout-infrastructure reads the whole project and
146
+ // writes emberflow/infrastructure.json, and guided-setup orchestrates all
147
+ // of setup across project-root files — none has a relPath to resolve.
136
148
  relPath = '';
137
149
  } else if (intent.action === 'ask') {
138
150
  // `ask`'s flowId is optional: with one, resolve the op's relPath like
@@ -171,17 +183,21 @@ export class AgentRunManager {
171
183
  this.availableNodes(),
172
184
  this.projectLanguage,
173
185
  this.infrastructure(),
186
+ intent.action === 'guided-setup' ? this.guidedState() : null,
174
187
  );
188
+ // Env override wins (tests stub agents this way); otherwise use the NEWEST
189
+ // binary detection found across PATH + known install locations — a PATH
190
+ // shim pinned to a stale release must not shadow a newer install elsewhere.
175
191
  const adapter =
176
192
  opts.agent === 'claude'
177
193
  ? spawnClaude(prompt, this.projectDir, {
178
194
  model: opts.model,
179
- bin: process.env.EMBERFLOW_CLAUDE_BIN,
195
+ bin: process.env.EMBERFLOW_CLAUDE_BIN ?? resolveAgentBin('claude'),
180
196
  })
181
197
  : spawnCodex(prompt, this.projectDir, {
182
198
  model: opts.model,
183
199
  reasoning: opts.reasoning ?? 'medium',
184
- bin: process.env.EMBERFLOW_CODEX_BIN,
200
+ bin: process.env.EMBERFLOW_CODEX_BIN ?? resolveAgentBin('codex'),
185
201
  });
186
202
 
187
203
  const id = randomUUID();
@@ -266,6 +282,20 @@ export class AgentRunManager {
266
282
  return true;
267
283
  }
268
284
 
285
+ /**
286
+ * Kills the active agent run's process tree, if any. Agent CLIs are spawned
287
+ * DETACHED (their own process group, so cancel can tree-kill their
288
+ * subprocesses) — which also means they survive the runner dying unless the
289
+ * shutdown path explicitly cancels them. An orphaned agent keeps editing the
290
+ * project long after the studio is gone; call this from the server's
291
+ * SIGINT/SIGTERM handlers.
292
+ */
293
+ shutdown(): void {
294
+ if (!this.activeRunId) return;
295
+ const run = this.runs.get(this.activeRunId);
296
+ run?.adapter.cancel();
297
+ }
298
+
269
299
  /**
270
300
  * Evicts oldest terminal (done|error) runs beyond MAX_TERMINAL_RUNS so the
271
301
  * `runs` map — and each run's full event buffer — doesn't grow unbounded
package/server/client.ts CHANGED
@@ -10,15 +10,21 @@ import type { ApiTree, FolderNode, ApiNode, OpSummary } from './apiStore';
10
10
 
11
11
  export type { ApiTree, FolderNode, ApiNode, OpSummary } from './apiStore';
12
12
 
13
- const DEFAULT_BASE_URL = 'http://127.0.0.1:8092';
14
-
15
13
  function baseUrl(): string {
16
- return process.env.EMBERFLOW_RUNNER_URL ?? DEFAULT_BASE_URL;
14
+ // EMBERFLOW_RUNNER_PORT is how the runner itself is launched on a custom
15
+ // port (bin/commands.ts sets it for `emberflow dev --port`), and child
16
+ // processes — including agent runs invoking this CLI — inherit it. Without
17
+ // honoring it here, an agent spawned by a runner on :8095 would call back
18
+ // to the default :8092 and report the runner down.
19
+ return (
20
+ process.env.EMBERFLOW_RUNNER_URL ??
21
+ `http://127.0.0.1:${process.env.EMBERFLOW_RUNNER_PORT ?? 8092}`
22
+ );
17
23
  }
18
24
 
19
25
  /** SSE-shaped events emitted on GET /runs/:id/events — mirrors server/runRegistry.ts RunEvent. */
20
26
  export type RunEvent =
21
- | { type: 'nodeState'; nodeId: string; state: NodeRunState }
27
+ | { type: 'nodeState'; workflowId: string; nodeId: string; state: NodeRunState }
22
28
  | { type: 'log'; line: LogLine }
23
29
  | { type: 'finished'; run: WorkflowRun };
24
30