@frontera-sdk/cli 1.52.39 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -0,0 +1,435 @@
1
+ import { spawn, spawnSync } from 'node:child_process'
2
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
+ import { basename, dirname, join, relative } from 'node:path'
4
+
5
+ import { isLoopbackPortFree } from '../dev-broker'
6
+ import { findSolutionRoot, localProject, type LocalProject } from '../local/local-project'
7
+ import { isAlive } from '../local/local-state'
8
+ import { readProject } from '../project'
9
+ import { ensureAppPackages } from '../project-bootstrap'
10
+ import { detectAppFramework } from '../provenance'
11
+ import { selfCommand } from './mcp-cli'
12
+ import type { RuntimeLink } from './mcp-runtime'
13
+
14
+ /**
15
+ * Custom Apps a coding agent works on from a solution: each is its own
16
+ * project (`package.json` with `frontera.config.json`), found at the solution
17
+ * root, in `apps/<slug>/` or one folder down. Their dev servers run against
18
+ * the local runtime and are kept per App in `.frontera/local/app-dev/`, so
19
+ * every MCP call (and Forge) reuses the one already running.
20
+ */
21
+
22
+ export interface AppProjectRef {
23
+ slug: string
24
+ name: string
25
+ /** Absolute. */
26
+ root: string
27
+ /** From the solution root, for messages. */
28
+ path: string
29
+ framework: 'next' | 'vite' | 'unknown'
30
+ }
31
+
32
+ function isAppRoot(dir: string): boolean {
33
+ if (!existsSync(join(dir, 'package.json'))) return false
34
+ if (existsSync(join(dir, 'frontera.config.json'))) return true
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { frontera?: unknown }
37
+ return typeof pkg.frontera === 'object' && pkg.frontera !== null
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ const SKIP = new Set(['node_modules', '.git', '.frontera', 'dist', 'out', '.next', 'build'])
44
+
45
+ function children(dir: string): string[] {
46
+ try {
47
+ return readdirSync(dir, { withFileTypes: true })
48
+ .filter((d) => d.isDirectory() && !SKIP.has(d.name) && !d.name.startsWith('.'))
49
+ .map((d) => join(dir, d.name))
50
+ } catch {
51
+ return []
52
+ }
53
+ }
54
+
55
+ export function findAppProjects(solutionRoot: string): AppProjectRef[] {
56
+ const candidates = [solutionRoot, ...children(join(solutionRoot, 'apps')), ...children(solutionRoot)]
57
+ const seen = new Set<string>()
58
+ const out: AppProjectRef[] = []
59
+ for (const dir of candidates) {
60
+ if (seen.has(dir) || !isAppRoot(dir)) continue
61
+ seen.add(dir)
62
+ try {
63
+ const project = readProject(dir)
64
+ out.push({ slug: project.slug, name: project.displayName, root: dir, path: relative(solutionRoot, dir) || '.', framework: detectAppFramework(dir) })
65
+ } catch {
66
+ // A broken package.json is not an App the agent can run.
67
+ }
68
+ }
69
+ return out.sort((a, b) => a.slug.localeCompare(b.slug))
70
+ }
71
+
72
+ /** The App a tool means: by slug, or the only one there is. */
73
+ export function resolveAppProject(solutionRoot: string, slug: string | undefined): AppProjectRef {
74
+ const apps = findAppProjects(solutionRoot)
75
+ if (!apps.length) throw new Error('No Custom App in this project. Create one with `frontera app init <name>` in apps/, then try again.')
76
+ if (slug) {
77
+ const app = apps.find((a) => a.slug === slug || basename(a.root) === slug)
78
+ if (!app) throw new Error(`No App "${slug}" here; there is ${apps.map((a) => a.slug).join(', ')}.`)
79
+ return app
80
+ }
81
+ if (apps.length > 1) throw new Error(`This project has ${apps.length} Apps (${apps.map((a) => a.slug).join(', ')}): say which with app.`)
82
+ return apps[0]!
83
+ }
84
+
85
+ export interface DevServer {
86
+ slug: string
87
+ pid: number
88
+ port: number
89
+ /** What to open: the App's origin, or the Vite dev host page. */
90
+ url: string
91
+ startedAt: string
92
+ log: string
93
+ /**
94
+ * The process that started it: an MCP session (`app_dev`, `app_render`) or
95
+ * Forge (its Start). The server stops when that process ends (`frontera app
96
+ * dev` watches it), so a server an agent or Forge started never outlives it.
97
+ * Absent: started by hand.
98
+ */
99
+ owner?: { kind: 'mcp' | 'forge'; pid: number }
100
+ }
101
+
102
+ /** How a dev server this process started ended: its exit code (or signal) and the last lines it wrote. */
103
+ export interface DevServerExit {
104
+ code: number | null
105
+ signal: string | null
106
+ at: string
107
+ /** The last lines of this run's output, from its log. */
108
+ tail: string
109
+ }
110
+
111
+ export interface EnsureDevServerOptions {
112
+ /**
113
+ * The `frontera` command to run `app dev` with. Default: this same CLI
114
+ * (`selfCommand`), right for `frontera mcp`; a host that only imports this
115
+ * module (Forge) passes its own, or it would start a copy of itself.
116
+ */
117
+ command?: string[]
118
+ /** Who owns the server's lifetime (this process either way). Default: an MCP session. */
119
+ owner?: 'mcp' | 'forge'
120
+ /** Called once when the server this call started ends, however it ends. */
121
+ onExit?: (exit: DevServerExit) => void
122
+ }
123
+
124
+ /** What tells `frontera app dev` which process started it, so it stops when that one ends. */
125
+ export const DEV_OWNER_ENV = 'FRONTERA_APP_DEV_OWNER_PID'
126
+
127
+ /** The dev servers this process started, by project and App, so they stop with the MCP session or Forge (`stopStartedDevServers`). */
128
+ const startedHere = new Map<string, { project: LocalProject; slug: string; pid: number }>()
129
+
130
+ const devDir = (project: LocalProject) => join(project.stateDir, 'app-dev')
131
+ const devFile = (project: LocalProject, slug: string) => join(devDir(project), `${slug.replace(/[^a-z0-9-]+/gi, '-')}.json`)
132
+
133
+ export function readDevServer(project: LocalProject, slug: string): DevServer | null {
134
+ try {
135
+ const server = JSON.parse(readFileSync(devFile(project, slug), 'utf8')) as DevServer
136
+ return isAlive(server.pid) ? server : null
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
142
+ export function listDevServers(project: LocalProject): DevServer[] {
143
+ try {
144
+ return readdirSync(devDir(project))
145
+ .filter((f) => f.endsWith('.json'))
146
+ .map((f) => readDevServer(project, f.replace(/\.json$/, '')))
147
+ .filter((s): s is DevServer => s !== null)
148
+ } catch {
149
+ return []
150
+ }
151
+ }
152
+
153
+ /**
154
+ * The solution an App folder sits in, when it sits in one: the nearest folder
155
+ * above it marked as a Frontera project. Null for an App on its own.
156
+ */
157
+ export function enclosingSolution(appRoot: string): LocalProject | null {
158
+ const root = findSolutionRoot(dirname(appRoot))
159
+ if (root === appRoot || !['frontera.yaml', 'frontera.solution.yaml'].some((f) => existsSync(join(root, f)))) return null
160
+ return localProject(root)
161
+ }
162
+
163
+ /**
164
+ * Record a dev server another command started (`frontera app dev` run by hand
165
+ * or by another session), so `app_dev`, `app_render` and Forge reuse it rather
166
+ * than start a second one, which Next.js refuses in the same folder.
167
+ */
168
+ export function registerDevServer(project: LocalProject, server: DevServer): void {
169
+ mkdirSync(devDir(project), { recursive: true })
170
+ writeFileSync(devFile(project, server.slug), `${JSON.stringify(server, null, 2)}\n`)
171
+ }
172
+
173
+ /** Forget a dev server this process recorded, unless another has since taken its place. */
174
+ export function forgetDevServer(project: LocalProject, slug: string, pid: number): void {
175
+ try {
176
+ const server = JSON.parse(readFileSync(devFile(project, slug), 'utf8')) as DevServer
177
+ if (server.pid === pid) rmSync(devFile(project, slug), { force: true })
178
+ } catch {
179
+ // Nothing recorded, or already gone.
180
+ }
181
+ }
182
+
183
+ async function answers(url: string): Promise<boolean> {
184
+ try {
185
+ const res = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) })
186
+ return res.status < 500
187
+ } catch {
188
+ return false
189
+ }
190
+ }
191
+
192
+ async function freePort(from = 3100): Promise<number> {
193
+ // Free on both loopbacks: the dev server binds 127.0.0.1, but a browser opening localhost may reach ::1 first.
194
+ for (let port = from; port < from + 100; port++) if (await isLoopbackPortFree(port)) return port
195
+ throw new Error('no free port for the App dev server between 3100 and 3199')
196
+ }
197
+
198
+ /** The env that points an App command at the local runtime instead of the person's profile. */
199
+ export function localAppEnv(link: RuntimeLink): Record<string, string> {
200
+ const key = link.creds.workspaceKey
201
+ if (!key) throw new Error('The local runtime has no workspace key yet. Restart it with `frontera local start`.')
202
+ return { FRONTERA_TOKEN: key, FRONTERA_API_URL: link.serviceUrl, FRONTERA_ORG_ID: link.creds.organizationId }
203
+ }
204
+
205
+ const READY_MS = 60_000
206
+
207
+ /**
208
+ * The App's dev server against the local runtime: the one already running,
209
+ * or a new `frontera app dev` started in the background, its output in
210
+ * `.frontera/local/logs/app-<slug>.log`.
211
+ */
212
+ export async function ensureDevServer(project: LocalProject, app: AppProjectRef, link: RuntimeLink, options: EnsureDevServerOptions = {}): Promise<{ server: DevServer; reused: boolean; installed?: string; trackedFiles?: string[] }> {
213
+ const running = readDevServer(project, app.slug)
214
+ if (running && (await answers(running.url))) return { server: running, reused: true }
215
+ if (app.framework === 'unknown') throw new Error(`${app.slug} has neither Next.js nor Vite in package.json, so it has no dev server to start.`)
216
+ // A pulled App comes without node_modules: its packages are installed first (bun install), or `next` isn't there to start.
217
+ // From its bun.lock as it is when it has one: a pulled App's lockfile is never rewritten here. One whose
218
+ // package.json names its own SDK copy where bun.lock locked a release installs from the copy, writing neither.
219
+ const install = await ensureAppPackages(app.root)
220
+ if (install?.lockMismatch) throw new Error(`${app.slug} has no node_modules, and they weren't installed: ${install.summary}.`)
221
+ if (install && !install.installed) throw new Error(`${app.slug} has no node_modules, and ${install.fromCopy ? '' : `\`bun install\` in ${relative(project.root, app.root) || '.'} failed: `}${install.summary}. Fix that, then try again.`)
222
+ const frozen = existsSync(join(app.root, 'bun.lock')) || existsSync(join(app.root, 'bun.lockb'))
223
+ // From its own SDK copy (package.json and bun.lock disagree because of it): said with what kept both files as pulled.
224
+ const where = relative(project.root, app.root) || app.slug
225
+ const installed = install ? (install.fromCopy || install.fromLock ? `${where} had no node_modules: ${install.summary}` : `${where} had no node_modules: ran bun install first${frozen ? ' from its bun.lock as it is (--frozen-lockfile; the lockfile is unchanged)' : ''} (${install.summary})`) : undefined
226
+ const port = await freePort()
227
+ mkdirSync(join(project.stateDir, 'logs'), { recursive: true })
228
+ mkdirSync(devDir(project), { recursive: true })
229
+ const log = join(project.stateDir, 'logs', `app-${app.slug}.log`)
230
+ const fd = openSync(log, 'a')
231
+ // Where this run's output starts in the log, which keeps earlier runs: its tail is this run's alone.
232
+ const from = statSync(log).size
233
+ const [exe, ...pre] = options.command ?? selfCommand()
234
+ const child = spawn(exe!, [...pre, 'app', 'dev', '--no-open', '--port', String(port)], {
235
+ cwd: app.root,
236
+ detached: true,
237
+ stdio: ['ignore', fd, fd],
238
+ // The server stops when this process ends, however it ends (`frontera app dev` watches it).
239
+ env: { ...process.env, ...localAppEnv(link), NO_COLOR: '1', [DEV_OWNER_ENV]: String(process.pid) },
240
+ })
241
+ closeSync(fd)
242
+ let exited: DevServerExit | null = null
243
+ child.on('exit', (code, signal) => {
244
+ exited = { code, signal, at: new Date().toISOString(), tail: logTail(log, from) }
245
+ options.onExit?.(exited)
246
+ })
247
+ child.on('error', () => {
248
+ // A command that could not start at all exits too ('exit' follows with its code); nothing else to do here.
249
+ })
250
+ child.unref()
251
+ const origin = `http://127.0.0.1:${port}`
252
+ const server: DevServer = { slug: app.slug, pid: child.pid!, port, url: app.framework === 'vite' ? `${origin}/dev-host.html` : origin, startedAt: new Date().toISOString(), log: relative(project.root, log), owner: { kind: options.owner ?? 'mcp', pid: process.pid } }
253
+ startedHere.set(`${project.root}\0${app.slug}`, { project, slug: app.slug, pid: server.pid })
254
+ writeFileSync(devFile(project, app.slug), `${JSON.stringify(server, null, 2)}\n`)
255
+ const deadline = Date.now() + READY_MS
256
+ while (Date.now() < deadline) {
257
+ if (exited || !isAlive(server.pid)) break
258
+ // Ready once it answers and `app dev` said so: by then it has put back the tracked files the server wrote.
259
+ if ((await answers(server.url)) && devSaidReady(log, from)) {
260
+ const tracked = trackedFileLines(log, from)
261
+ return { server, reused: false, ...(installed ? { installed } : {}), ...(tracked.length ? { trackedFiles: tracked } : {}) }
262
+ }
263
+ await new Promise((r) => setTimeout(r, 400))
264
+ }
265
+ rmSync(devFile(project, app.slug), { force: true })
266
+ startedHere.delete(`${project.root}\0${app.slug}`)
267
+ const gone = exited as DevServerExit | null
268
+ // Not ready in time: the server this call started is stopped, never left running unrecorded.
269
+ if (!gone) stopGroup(server.pid)
270
+ const tail = logTail(log, from)
271
+ const notFound = /command not found|not found: (next|vite)\b/i.test(tail) ? ` Its dev command isn't installed: run \`bun install\` in ${relative(project.root, app.root) || '.'}.` : ''
272
+ const why = gone ? `The dev server for ${app.slug} exited ${exitWords(gone)} before it was ready.` : `The dev server for ${app.slug} did not start within ${READY_MS / 1000} seconds, so it was stopped.`
273
+ throw new Error(`${why}${notFound}${tail ? `\n${tail}` : ''}`)
274
+ }
275
+
276
+ /** Whether this run of `frontera app dev` has said it is ready (`[dev] ready: …`), after putting back what the server wrote. */
277
+ function devSaidReady(log: string, from: number): boolean {
278
+ return /^\[dev\] ready\b/m.test(logSince(log, from))
279
+ }
280
+
281
+ /**
282
+ * What this run of `frontera app dev` said of the App's tracked files: the
283
+ * ones the dev server rewrote and it put back (`next-env.d.ts`), the ones it
284
+ * left because they were edited, and an SDK path it set aside. Without the
285
+ * `[dev] ` prefix.
286
+ */
287
+ export function trackedFileLines(log: string, from: number): string[] {
288
+ return logSince(log, from)
289
+ .split('\n')
290
+ .map((l) => l.trim())
291
+ .filter((l) => /^\[dev\] (put back|left|tsconfig\.json)\b/.test(l))
292
+ .map((l) => l.replace(/^\[dev\] /, ''))
293
+ }
294
+
295
+ function logSince(log: string, from: number): string {
296
+ try {
297
+ return readFileSync(log).subarray(from).toString('utf8')
298
+ } catch {
299
+ return ''
300
+ }
301
+ }
302
+
303
+ /** "with code 1", "on SIGTERM": how a dev server ended, for a sentence. */
304
+ export function exitWords(exit: Pick<DevServerExit, 'code' | 'signal'>): string {
305
+ return exit.code !== null ? `with code ${exit.code}` : exit.signal ? `on ${exit.signal}` : 'with no exit code'
306
+ }
307
+
308
+ const TAIL_LINES = 30
309
+
310
+ /** The last lines written to a log from an offset on: one run's output. */
311
+ function logTail(log: string, from: number): string {
312
+ try {
313
+ // A byte offset: the log holds UTF-8 (Next.js prints ▲ and ✓), so it is cut as bytes, then read.
314
+ const text = readFileSync(log).subarray(from).toString('utf8')
315
+ return text.split('\n').map((l) => l.trimEnd()).filter((l, i, all) => l || i < all.length - 1).slice(-TAIL_LINES).join('\n').trim()
316
+ } catch {
317
+ return ''
318
+ }
319
+ }
320
+
321
+ /** SIGTERM to a dev server's whole group (`app dev` and the framework's server under it), or to it alone. */
322
+ function stopGroup(pid: number): boolean {
323
+ try {
324
+ process.kill(-pid, 'SIGTERM')
325
+ return true
326
+ } catch {
327
+ try {
328
+ process.kill(pid, 'SIGTERM')
329
+ return true
330
+ } catch {
331
+ return false
332
+ }
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Whether a recorded pid is still an App dev server (`frontera app dev`, or the
338
+ * framework under it), not an unrelated process that took the pid since: only
339
+ * such a process is ever signalled. Where `ps` can't say, the record is trusted.
340
+ */
341
+ export function isDevServerProcess(pid: number): boolean {
342
+ const res = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' })
343
+ if (res.error) return true
344
+ if (res.status !== 0) return false
345
+ return /\bapp\b.*\bdev\b|\bnext\b.*\bdev\b|\bvite\b/.test(res.stdout)
346
+ }
347
+
348
+ /**
349
+ * Stop an App's dev server and wait (a while) for it to end, then say what
350
+ * `frontera app dev` put back as it stopped: the tracked files the server
351
+ * rewrote. `stopped` false when none ran.
352
+ */
353
+ export async function stopDevServerAndSay(project: LocalProject, slug: string, waitMs = 15_000): Promise<{ stopped: boolean; trackedFiles: string[] }> {
354
+ const server = readDevServer(project, slug)
355
+ const log = server?.log ? join(project.root, server.log) : null
356
+ let from = 0
357
+ try {
358
+ from = log ? statSync(log).size : 0
359
+ } catch {
360
+ // No log (started by hand): nothing to read back.
361
+ }
362
+ if (!stopDevServer(project, slug) || !server) return { stopped: false, trackedFiles: [] }
363
+ const deadline = Date.now() + waitMs
364
+ while (Date.now() < deadline && isAlive(server.pid)) await new Promise((r) => setTimeout(r, 200))
365
+ return { stopped: true, trackedFiles: log && existsSync(log) ? trackedFileLines(log, from) : [] }
366
+ }
367
+
368
+ /** Stop an App's dev server, when one runs. Only this project's record is read, so another project's server is never touched. */
369
+ export function stopDevServer(project: LocalProject, slug: string): boolean {
370
+ const server = readDevServer(project, slug)
371
+ rmSync(devFile(project, slug), { force: true })
372
+ if (!server || !isDevServerProcess(server.pid)) return false
373
+ // The whole group: `app dev` and the framework's server under it.
374
+ return stopGroup(server.pid)
375
+ }
376
+
377
+ /**
378
+ * Stop every App dev server this project records (`local stop`, `local
379
+ * remove`): the ones an MCP session started and the ones run by hand, which
380
+ * point at the runtime that is going away. Records of servers already gone
381
+ * are cleared. Returns the servers stopped.
382
+ */
383
+ export function stopDevServers(project: LocalProject): DevServer[] {
384
+ const stopped: DevServer[] = []
385
+ let files: string[] = []
386
+ try {
387
+ files = readdirSync(devDir(project)).filter((f) => f.endsWith('.json'))
388
+ } catch {
389
+ return stopped
390
+ }
391
+ for (const f of files) {
392
+ const slug = f.replace(/\.json$/, '')
393
+ const server = readDevServer(project, slug)
394
+ if (stopDevServer(project, slug) && server) stopped.push(server)
395
+ }
396
+ return stopped
397
+ }
398
+
399
+ /**
400
+ * Stop the dev servers this process started: an MCP session's when its
401
+ * connection closes, Forge's when it quits. One another session or a person
402
+ * has since taken over (a different pid in the record) is left running.
403
+ */
404
+ export function stopStartedDevServers(): number {
405
+ let n = 0
406
+ for (const { project, slug, pid } of startedHere.values()) {
407
+ if (readDevServer(project, slug)?.pid === pid && stopDevServer(project, slug)) n++
408
+ }
409
+ startedHere.clear()
410
+ return n
411
+ }
412
+
413
+ /**
414
+ * Call `onGone` once, when the process `owner` is no longer there (checked
415
+ * every `everyMs`): how `frontera app dev` started by an MCP session stops
416
+ * with it, however the session ends. Returns the stop for the watch.
417
+ */
418
+ export function watchOwner(owner: number, onGone: () => void, everyMs = 5_000): () => void {
419
+ const timer = setInterval(() => {
420
+ if (isAlive(owner)) return
421
+ clearInterval(timer)
422
+ onGone()
423
+ }, everyMs)
424
+ return () => clearInterval(timer)
425
+ }
426
+
427
+ /** "cdl-console on port 3101 (http://127.0.0.1:3101), started by an MCP session (pid 4242)" */
428
+ export function devServerLine(s: DevServer): string {
429
+ const by = !s.owner
430
+ ? 'started by hand (`frontera app dev`)'
431
+ : s.owner.kind === 'forge'
432
+ ? `started from Forge (pid ${s.owner.pid}); it stops when Forge stops it or quits`
433
+ : `started by an MCP session (pid ${s.owner.pid}); it stops when that session ends`
434
+ return `${s.slug} on port ${s.port} (${s.url}), ${by}`
435
+ }
@@ -0,0 +1,226 @@
1
+ import { parkCall, type ToolCall } from '../local/local-test-runner'
2
+ import type { RuntimeLink } from './mcp-runtime'
3
+
4
+ /**
5
+ * One Agent turn as `agent_chat` reads it: the reply streamed from
6
+ * `/v1/chat`, read until the turn finishes or the Agent waits on the person.
7
+ *
8
+ * A turn that waits on the person keeps its stream open: a Function asked
9
+ * the person to confirm (`data-person-confirmation`), or a tool call waits
10
+ * for an approval (`tool-approval-request`). Read to its end, such a turn
11
+ * never ends, which is how a follow-up looked like a timeout. So the reader
12
+ * stops there, says what is waiting, and keeps the stream: the answer
13
+ * (`agent_chat` with `confirm` or `approval`) settles it and the rest of the
14
+ * turn is read from the same stream, or from the run's journal when that
15
+ * connection is gone.
16
+ */
17
+
18
+ export interface PendingConfirmation {
19
+ kind: 'confirmation'
20
+ id: string
21
+ summary: string
22
+ details: string | null
23
+ confirmLabel: string
24
+ cancelLabel: string
25
+ expiresAt?: string
26
+ }
27
+
28
+ export interface PendingApproval {
29
+ kind: 'approval'
30
+ id: string
31
+ toolCallId: string | null
32
+ tool: string | null
33
+ summary?: string | null
34
+ expiresAt?: string
35
+ /** The card's reason, as the person reads it. */
36
+ reason?: string | null
37
+ /** What asked: the binding's own setting, or the chat's approval mode. */
38
+ askedBecause?: 'binding' | 'approval-mode'
39
+ }
40
+
41
+ export type PendingDecision = PendingConfirmation | PendingApproval
42
+
43
+ export type TurnEnd =
44
+ | { status: 'finished' }
45
+ | { status: 'waiting'; pending: PendingDecision }
46
+ /** Nothing arrived for the idle window: the runtime took the turn and sent nothing back. */
47
+ | { status: 'idle'; silentMs: number }
48
+ | { status: 'timeout'; elapsedMs: number }
49
+
50
+ export interface TurnRead {
51
+ reply: string
52
+ toolCalls: ToolCall[]
53
+ end: TurnEnd
54
+ /** The run behind the turn (`x-workflow-run-id`), to read its journal again. */
55
+ runId: string | null
56
+ /** Stream chunks read so far: where the journal picks up. */
57
+ chunks: number
58
+ /** Events seen, by type, for a turn that ended oddly. */
59
+ events: Record<string, number>
60
+ lastEvent: string | null
61
+ }
62
+
63
+ /** A turn's stream, kept between calls while the Agent waits on the person. */
64
+ /** The part of a stream reader a held turn uses. */
65
+ interface ChunkReader {
66
+ read(): Promise<{ done: boolean; value?: Uint8Array }>
67
+ cancel(): Promise<void>
68
+ }
69
+
70
+ export interface HeldTurn {
71
+ reader: ChunkReader | null
72
+ buffer: string
73
+ runId: string | null
74
+ chunks: number
75
+ reply: string
76
+ calls: Map<string, ToolCall & { id: string }>
77
+ events: Record<string, number>
78
+ lastEvent: string | null
79
+ /** Decisions already reported, so a replay of the journal does not stop on them again. */
80
+ seen: Set<string>
81
+ }
82
+
83
+ export const TURN_IDLE_MS = 90_000
84
+ export const TURN_TOTAL_MS = 240_000
85
+
86
+ function headers(link: RuntimeLink): Record<string, string> {
87
+ return {
88
+ authorization: `Bearer ${link.creds.session}`,
89
+ 'x-org-id': link.creds.organizationId,
90
+ 'x-workspace-id': link.creds.workspaceId,
91
+ 'content-type': 'application/json',
92
+ }
93
+ }
94
+
95
+ /** Send the person's message and hold the turn's stream. */
96
+ export async function startTurn(link: RuntimeLink, args: { conversationId: string; agentName: string; message: string }): Promise<HeldTurn> {
97
+ const res = await fetch(new URL('/v1/chat', link.serviceUrl), {
98
+ method: 'POST',
99
+ headers: headers(link),
100
+ body: JSON.stringify({
101
+ conversationId: args.conversationId,
102
+ agentName: args.agentName,
103
+ messages: [{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: args.message }] }],
104
+ }),
105
+ })
106
+ // A 409 carries what waits on the person (`decision_pending`): kept whole for the caller to read.
107
+ if (!res.ok || !res.body) throw new Error(`chat answered ${res.status}: ${(await res.text()).slice(0, res.status === 409 ? 20_000 : 300)}`)
108
+ return { reader: res.body.getReader() as ChunkReader, buffer: '', runId: res.headers.get('x-workflow-run-id'), chunks: 0, reply: '', calls: new Map(), events: {}, lastEvent: null, seen: new Set() }
109
+ }
110
+
111
+ /** Pick the held turn up again from the run's journal, after its connection went away. */
112
+ export async function reattachTurn(link: RuntimeLink, held: HeldTurn): Promise<HeldTurn> {
113
+ if (!held.runId) throw new Error('the turn has no run to reattach to')
114
+ const res = await fetch(new URL(`/v1/chat/${encodeURIComponent(held.runId)}/stream?startIndex=${held.chunks}`, link.serviceUrl), { headers: headers(link) })
115
+ if (res.status === 404) {
116
+ // The run settled while nobody read it: what it said is in the conversation.
117
+ return { ...held, reader: null }
118
+ }
119
+ if (!res.ok || !res.body) throw new Error(`the turn's stream answered ${res.status}: ${(await res.text()).slice(0, 300)}`)
120
+ return { ...held, reader: res.body.getReader() as ChunkReader, buffer: '' }
121
+ }
122
+
123
+ interface StreamEvent {
124
+ type?: string
125
+ delta?: string
126
+ errorText?: string
127
+ toolCallId?: string
128
+ toolName?: string
129
+ input?: unknown
130
+ args?: unknown
131
+ approvalId?: string
132
+ data?: Record<string, unknown>
133
+ }
134
+
135
+ /** One event: reply text, a tool call, or a decision the Agent now waits on. */
136
+ function take(held: HeldTurn, event: StreamEvent): PendingDecision | null {
137
+ const type = event.type ?? 'unknown'
138
+ held.events[type] = (held.events[type] ?? 0) + 1
139
+ held.lastEvent = type
140
+ if (type === 'text-delta' && typeof event.delta === 'string') held.reply += event.delta
141
+ if (type === 'tool-input-available' && event.toolCallId && event.toolName) held.calls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, args: event.input, ok: false })
142
+ if (type === 'tool-output-available' && event.toolCallId && held.calls.has(event.toolCallId)) Object.assign(held.calls.get(event.toolCallId)!, { ok: true, parked: false })
143
+ // Denied or failed: it waits no longer, and never ran.
144
+ if ((type === 'tool-output-denied' || type === 'tool-output-error') && event.toolCallId && held.calls.has(event.toolCallId)) held.calls.get(event.toolCallId)!.parked = false
145
+ if (type === 'error') throw new Error(event.errorText ?? 'the turn failed')
146
+ if (type === 'tool-approval-request' && event.approvalId && !held.seen.has(event.approvalId)) {
147
+ held.seen.add(event.approvalId)
148
+ // The call waits on the person: a test saved from this turn counts it as called.
149
+ const parked = parkCall(held.calls, event)
150
+ const call = parked ? held.calls.get(parked.id) : event.toolCallId ? held.calls.get(event.toolCallId) : undefined
151
+ return { kind: 'approval', id: event.approvalId, toolCallId: event.toolCallId ?? null, tool: call?.name ?? null }
152
+ }
153
+ if (type === 'data-person-confirmation' && event.data) {
154
+ const d = event.data as Partial<PendingConfirmation> & { status?: string; id?: string }
155
+ if (d.status === 'pending' && d.id && !held.seen.has(d.id)) {
156
+ held.seen.add(d.id)
157
+ return { kind: 'confirmation', id: d.id, summary: String(d.summary ?? ''), details: (d.details as string | null) ?? null, confirmLabel: String(d.confirmLabel ?? 'Confirm'), cancelLabel: String(d.cancelLabel ?? 'Cancel'), ...(d.expiresAt ? { expiresAt: String(d.expiresAt) } : {}) }
158
+ }
159
+ }
160
+ return null
161
+ }
162
+
163
+ /**
164
+ * Read the held turn until it finishes, waits on the person, goes silent
165
+ * for `idleMs`, or runs past `totalMs`. The reply and tool calls are
166
+ * everything since the person's message, across reads.
167
+ */
168
+ export async function readTurn(held: HeldTurn, opts: { idleMs?: number; totalMs?: number } = {}): Promise<TurnRead> {
169
+ const idleMs = opts.idleMs ?? TURN_IDLE_MS
170
+ const totalMs = opts.totalMs ?? TURN_TOTAL_MS
171
+ const started = Date.now()
172
+ const decoder = new TextDecoder()
173
+ const result = (end: TurnEnd): TurnRead => ({ reply: held.reply, toolCalls: [...held.calls.values()].map(({ id: _id, ...c }) => c), end, runId: held.runId, chunks: held.chunks, events: { ...held.events }, lastEvent: held.lastEvent })
174
+ if (!held.reader) return result({ status: 'finished' })
175
+ let lastData = Date.now()
176
+ for (;;) {
177
+ // Keepalive comments arrive without data; only data counts against the idle window.
178
+ const wait = Math.max(0, Math.min(idleMs - (Date.now() - lastData), totalMs - (Date.now() - started)))
179
+ let timer: ReturnType<typeof setTimeout> | undefined
180
+ const next = await Promise.race([
181
+ held.reader.read(),
182
+ new Promise<'timer'>((r) => {
183
+ timer = setTimeout(() => r('timer'), wait)
184
+ }),
185
+ ]).finally(() => clearTimeout(timer))
186
+ if (next === 'timer') {
187
+ if (Date.now() - started >= totalMs) return result({ status: 'timeout', elapsedMs: Date.now() - started })
188
+ return result({ status: 'idle', silentMs: Date.now() - lastData })
189
+ }
190
+ if (next.done) {
191
+ held.reader = null
192
+ return result({ status: 'finished' })
193
+ }
194
+ held.buffer += decoder.decode(next.value, { stream: true })
195
+ let newline: number
196
+ while ((newline = held.buffer.indexOf('\n')) >= 0) {
197
+ const line = held.buffer.slice(0, newline).trim()
198
+ held.buffer = held.buffer.slice(newline + 1)
199
+ if (!line.startsWith('data:')) continue
200
+ const payload = line.slice(5).trim()
201
+ if (payload === '[DONE]') continue
202
+ let event: StreamEvent
203
+ try {
204
+ event = JSON.parse(payload) as StreamEvent
205
+ } catch {
206
+ continue
207
+ }
208
+ held.chunks++
209
+ lastData = Date.now()
210
+ const pending = take(held, event)
211
+ if (pending) return result({ status: 'waiting', pending })
212
+ if (event.type === 'finish') {
213
+ // The turn is over; nothing after `finish` belongs to the reply.
214
+ void held.reader.cancel().catch(() => {})
215
+ held.reader = null
216
+ return result({ status: 'finished' })
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ /** Stop reading a held turn: the conversation moved on or the connection is being dropped. */
223
+ export function dropTurn(held: HeldTurn | undefined): void {
224
+ void held?.reader?.cancel().catch(() => {})
225
+ if (held) held.reader = null
226
+ }