@tempo-ai/mcp 0.0.103 → 0.0.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@ import {
15
15
  readAuthSync,
16
16
  resolveConvexSiteUrl,
17
17
  resolveConvexUrl
18
- } from "./chunk-KQNQSVOA.js";
18
+ } from "./chunk-KFUOQZBJ.js";
19
19
 
20
20
  // ../../node_modules/.pnpm/cronstrue@3.24.0/node_modules/cronstrue/dist/cronstrue.js
21
21
  var require_cronstrue = __commonJS({
@@ -7820,6 +7820,46 @@ async function assertCanvasEndpointsShareable(endpoints, checkCanvasPushState) {
7820
7820
  });
7821
7821
  }
7822
7822
  }
7823
+ async function assertCanvasEndpointsHavePreviews(endpoints, deps) {
7824
+ const ensure = deps.ensureCanvasPreview;
7825
+ if (!ensure) return;
7826
+ for (const endpoint of endpoints) {
7827
+ if (!endpoint || typeof endpoint !== "object") continue;
7828
+ const ep = endpoint;
7829
+ if (ep.type !== "canvas") continue;
7830
+ if (!ep.orgProjectId || !ep.canvasPath || !ep.branch) continue;
7831
+ let hasPreview = false;
7832
+ try {
7833
+ const snapshot = await deps.convex.query(
7834
+ api.canvasIndex.getCanvasPreviewSnapshot,
7835
+ {
7836
+ orgProjectId: ep.orgProjectId,
7837
+ canvasPath: ep.canvasPath,
7838
+ branch: ep.branch
7839
+ }
7840
+ );
7841
+ hasPreview = (snapshot?.storyboards?.length ?? 0) > 0;
7842
+ } catch {
7843
+ hasPreview = false;
7844
+ }
7845
+ if (hasPreview) continue;
7846
+ const result = await ensure({
7847
+ orgProjectId: ep.orgProjectId,
7848
+ canvasPath: ep.canvasPath,
7849
+ branch: ep.branch
7850
+ });
7851
+ if (result.ok) continue;
7852
+ throw new McpToolError({
7853
+ code: "canvas_preview_capture_failed",
7854
+ message: `Cannot link canvas "${ep.canvasPath}": a linked canvas must have a rendered preview so it is reviewable from the issue/doc it is linked to, and capturing one failed: ${result.message} Fix the capture (make sure the canvas devserver is running and the canvas's storyboards render \u2014 canvas_screenshot is a quick check), then retry link_items. No link was created.`,
7855
+ details: {
7856
+ canvasPath: ep.canvasPath,
7857
+ branch: ep.branch,
7858
+ captureFailure: result.message
7859
+ }
7860
+ });
7861
+ }
7862
+ }
7823
7863
  async function resolveCanvasEndpoint(endpoint, resolve2) {
7824
7864
  if (!resolve2) return endpoint;
7825
7865
  const ep = endpoint;
@@ -7900,7 +7940,7 @@ function registerArtifactTools(server, deps) {
7900
7940
  const { convex, getWritePermission } = deps;
7901
7941
  server.tool(
7902
7942
  "link_items",
7903
- "Create a bidirectional link between two items in ONE call. Endpoint types: 'issue' (issueId \u2014 a human id like TEM-42 or a Convex id; either works), 'doc' (cloud doc \u2014 pass docId from docs_create), 'canvas', or 'branch' (orgProjectId + branchName). Links point at a BRANCH, never at one person's workspace, so everyone in the org sees the work. A 'workspace' endpoint (orgProjectId + localBranch) is still accepted and is TRANSLATED to the branch it sits on. Issue, doc, workspace, and branch links are created immediately. To link a CANVAS, pass just { type: 'canvas', canvasPath: '<slug, folder, or .canvas.tsx path>' } \u2014 the slug you gave canvas_create, or the canvas_dir/file_path returned by canvas_list. The server fills in the project id and git branch from the active workspace and normalizes the path. Canvas endpoints must match the active workspace project and their named branch must be positively verified on that repository's remote; the active branch must also be clean with no unpushed commits. Otherwise the tool returns branch_not_pushed and creates no link. Commit and push the canvas, then retry link_items so collaborators can fetch it.",
7943
+ "Create a bidirectional link between two items in ONE call. Endpoint types: 'issue' (issueId \u2014 a human id like TEM-42 or a Convex id; either works), 'doc' (cloud doc \u2014 pass docId from docs_create), 'canvas', or 'branch' (orgProjectId + branchName). Links point at a BRANCH, never at one person's workspace, so everyone in the org sees the work. A 'workspace' endpoint (orgProjectId + localBranch) is still accepted and is TRANSLATED to the branch it sits on. Issue, doc, workspace, and branch links are created immediately. To link a CANVAS, pass just { type: 'canvas', canvasPath: '<slug, folder, or .canvas.tsx path>' } \u2014 the slug you gave canvas_create, or the canvas_dir/file_path returned by canvas_list. The server fills in the project id and git branch from the active workspace and normalizes the path. Canvas endpoints must match the active workspace project and their named branch must be positively verified on that repository's remote; the active branch must also be clean with no unpushed commits. Otherwise the tool returns branch_not_pushed and creates no link. Commit and push the canvas, then retry link_items so collaborators can fetch it. A linked canvas must also have a rendered PREVIEW: when none exists yet for the exact branch, the tool captures one at link time (the canvas devserver must be able to render the storyboards); if that capture fails it returns canvas_preview_capture_failed with the reason and creates no link \u2014 fix the render, then retry.",
7904
7944
  {
7905
7945
  endpointA: coercibleEndpointSchema(),
7906
7946
  endpointB: coercibleEndpointSchema()
@@ -7929,6 +7969,7 @@ function registerArtifactTools(server, deps) {
7929
7969
  [endpointA, endpointB],
7930
7970
  deps.checkCanvasPushState
7931
7971
  );
7972
+ await assertCanvasEndpointsHavePreviews([endpointA, endpointB], deps);
7932
7973
  const linkId = await convex.mutation(api.artifact_links.createLink, {
7933
7974
  orgId: ctx.orgId,
7934
7975
  endpointA,
@@ -8912,6 +8953,7 @@ function createIssuesMcpServer(context) {
8912
8953
  convex,
8913
8954
  getWritePermission,
8914
8955
  checkCanvasPushState: context.checkCanvasPushState,
8956
+ ensureCanvasPreview: context.ensureCanvasPreview,
8915
8957
  resolveCanvasLinkEndpoint: context.resolveCanvasLinkEndpoint,
8916
8958
  getWorkspaceEndpoint: context.getWorkspaceEndpoint,
8917
8959
  getOriginChatTabId: context.getOriginChatTabId,
@@ -15519,12 +15561,13 @@ scaffolded project.
15519
15561
  | \`scripts.install\` | string | Command to install dependencies (runs from the tempo root). A plain package-manager install (bare \`npm install\`/\`pnpm install\`/\u2026 or Tempo's chained \`cd .. && \u2026 && cd tempo && \u2026\` form) means "use Tempo's default install behavior": Tempo plans its own project-root + tempo-root installs with the detected package manager and the exact string is not executed. Anything else is a CUSTOM command \u2014 it runs verbatim from the tempo root, must cover BOTH roots' dependencies itself, and **Tempo never rewrites it**, so an edit you make here sticks. Only write a custom command when the default behavior actually fails (e.g. a \`root_deps_missing\` error on an unusual monorepo). |
15520
15562
  | \`scripts.start\` | string | Canvas-host launcher (runs from the tempo root). Prefer a foreground process. Tempo substitutes every literal \`\${PORT}\`, exports the same port as \`TEMPO_PORT\`, and sets \`TEMPO=true\`. |
15521
15563
  | \`scripts.stop\` | string? | Matching teardown for a daemonized/external host. Tempo substitutes \`\${PORT}\` and runs this before signalling the launcher group. Required when \`scripts.start\` exits successfully while the host continues in the background. |
15522
- | \`urls.tempoHost\` | URL string? | Public \`/tempo-host\` URL to probe when stdout cannot describe the reachable boundary (containers, tunnels, remotes). May contain \`\${PORT}\`; e.g. \`http://127.0.0.1:\${PORT}/tempo-host\`. |
15523
- | \`apps\` | array? | \`[{ "appDir"?, "start"?, "url"? }]\` \u2014 one entry per client app; only \`apps[0]\` is active today. \`start\` launches the user's REAL app for route storyboards and may be any foreground command; \`url\` optionally supplies the independent HTTP(S) readiness target and route base. \`\${APP_PORT}\` is an optional allocated-port helper and legacy \`\${PORT}\` remains supported. Legacy \`apps[].appStart\`, \`scripts.appStart\`, and top-level \`appDir\` still read and auto-migrate. |
15564
+ | \`urls.tempoHost\` | URL source? | Public \`/tempo-host\` URL to probe when stdout cannot describe the reachable boundary (containers, tunnels, remotes). Either an HTTP(S) string (which may contain \`\${PORT}\`) or \`{ "command": "..." }\`; the command runs once per launch and must print exactly one HTTP(S) URL. |
15565
+ | \`apps\` | array? | \`[{ "appDir"?, "start"?, "stop"?, "url"? }]\` \u2014 one entry per client app; only \`apps[0]\` is active today. \`start\` launches the user's REAL app for route storyboards. \`url\` is either an HTTP(S) string or \`{ "command": "..." }\`; a command runs once per launch and must print exactly one HTTP(S) URL. \`stop\` supplies teardown authority when \`start\` launches a container, tunnel, daemon, or remote workspace and exits. \`\${APP_PORT}\` is an optional allocated-port helper and legacy \`\${PORT}\` remains supported in all three fields. Legacy \`apps[].appStart\`, \`scripts.appStart\`, and top-level \`appDir\` still read and auto-migrate. |
15524
15566
 
15525
15567
  **Every command in this file runs through the platform shell \u2014 \`cmd.exe\` on
15526
15568
  Windows, not PowerShell.** That applies to \`scripts.install\` and
15527
- \`scripts.start\` exactly as it does to app \`start\`. Write plain, cross-platform
15569
+ \`scripts.start\` exactly as it does to app \`start\`, \`stop\`, and
15570
+ \`url.command\` and \`urls.tempoHost.command\`. Write plain, cross-platform
15528
15571
  invocations; \`&&\` and \`cd\` are fine (cmd supports both). Never use PowerShell
15529
15572
  syntax \u2014 \`if (Test-Path \u2026) { \u2026 } else { \u2026 }\`, \`Test-Path\`, \`$env:FOO\` \u2014 because
15530
15573
  cmd.exe cannot parse it and aborts with "X was unexpected at this time" BEFORE
@@ -15665,7 +15708,7 @@ When Tempo starts the devserver (via the \`scripts.start\` command), it sets:
15665
15708
  - \`NODE_ENV=development\` \u2014 ensures React dev mode (needed for fiber-based element selection)
15666
15709
  - \`TEMPO=true\` \u2014 activates Tempo's framework-specific annotations: \`tempoVitePlugin()\` for Vite, \`tempoNextjsPlugin()\` for Next.js, and \`tempoExpoBabelPlugin\` for Expo web
15667
15710
 
15668
- The host URL is detected from local URLs printed by the framework server; \`urls.tempoHost\`, when configured, is probed alongside the printed URL and wins whenever the printed URL is not reachable from Tempo (containers, tunnels, remotes). A literal \`urls.tempoHost\` (no \`\${PORT}\` token) is additionally probed without waiting for any stdout URL at all. Vite, Next.js, and Expo web can all use dynamic ports; agents should not hard-code a port into Expo setup unless the user explicitly asks for one.
15711
+ The host URL is detected from local URLs printed by the framework server; \`urls.tempoHost\`, when configured, is probed alongside the printed URL and wins whenever the printed URL is not reachable from Tempo (containers, tunnels, remotes). A literal \`urls.tempoHost\` (no \`\${PORT}\` token) is additionally probed without waiting for any stdout URL at all. A command-backed \`urls.tempoHost\` executes exactly once with \`TEMPO_PORT\` and \`PORT\` after spawn; when \`scripts.stop\` is configured it waits for the launcher to exit cleanly first, then the resolved URL owns liveness and stop owns teardown. Vite, Next.js, and Expo web can all use dynamic ports; agents should not hard-code a port into Expo setup unless the user explicitly asks for one.
15669
15712
 
15670
15713
  #### Customizing the launcher \u2014 decision ladder
15671
15714
 
@@ -15682,6 +15725,8 @@ Work down and stop at the FIRST line that fits; change nothing beyond it:
15682
15725
  with the reachable \`/tempo-host\` URL (use \`\${PORT}\` in it unless the
15683
15726
  boundary fixes the port), and forward \`TEMPO\` and \`TEMPO_PORT\` across the
15684
15727
  boundary.
15728
+ If the boundary chooses its URL dynamically, use
15729
+ \`{ "command": "..." }\`; the command's entire stdout must be that one URL.
15685
15730
  4. **Launcher exits after starting the real server** (e.g.
15686
15731
  \`docker compose up --detach\`)? Add \`scripts.stop\` as the matched
15687
15732
  teardown \u2014 without it the start fails with
@@ -15696,7 +15741,9 @@ Work down and stop at the FIRST line that fits; change nothing beyond it:
15696
15741
  attempt \u2014 unless \`urls.tempoHost\` spells a literal port (no \`\${PORT}\`
15697
15742
  token), which pins every attempt to that port. Either way it substitutes
15698
15743
  every literal \`\${PORT}\` in \`scripts.start\`, \`scripts.stop\`, and
15699
- \`urls.tempoHost\`, and always exports the chosen number as \`TEMPO_PORT\`.
15744
+ a string \`urls.tempoHost\` or \`urls.tempoHost.command\`, and always exports
15745
+ the chosen number as \`TEMPO_PORT\` (and as \`PORT\` when an explicit URL
15746
+ source participates in launch).
15700
15747
  Custom launchers should read \`TEMPO_PORT\` or use the token; do not assume
15701
15748
  Tempo will overwrite a generic ambient \`PORT\` variable.
15702
15749
  - \`scripts.start\` should normally stay in the foreground. Tempo then owns the
@@ -15920,7 +15967,7 @@ export default function AuthComponentsCanvas() {
15920
15967
 
15921
15968
  Canvases created before the React format use object exports instead of JSX \u2014 \`export const Login: TempoStoryboard = { render: () => <LoginForm />, layout: {\u2026} }\` with a \`TempoCanvasConfig\` default export. Tempo migrates these to the React format automatically when they are opened in the Design tab. If you encounter one, run \`canvas_migrate_to_latest\` on it BEFORE making any edit \u2014 never hand-write legacy declarations, and never mix the two formats in one file.
15922
15969
 
15923
- ### Route Storyboards & the app dev config (\`apps[0].start\` / \`url\`)
15970
+ ### Route Storyboards & the app dev config (\`apps[0].start\` / \`stop\` / \`url\`)
15924
15971
 
15925
15972
  Route storyboards (\`<RouteStoryboard>\`) load **the user's real app** in the iframe, NOT the canvas sidecar. They need a second supervised process \u2014 the user's actual app dev server \u2014 which Tempo manages from the app dev command in \`tempo.config.json\`, stored canonically on the \`apps[]\` list (one entry per client app; only \`apps[0]\` is active today):
15926
15973
 
@@ -15933,14 +15980,34 @@ Route storyboards (\`<RouteStoryboard>\`) load **the user's real app** in the if
15933
15980
  "apps": [
15934
15981
  {
15935
15982
  "appDir": "client", // app package dir, relative to repo root (omit for autodetect)
15936
- "start": "npm exec vite --port=\${APP_PORT}", // arbitrary foreground command
15937
- "url": "http://127.0.0.1:\${APP_PORT}" // optional HTTP(S) liveness target
15983
+ "start": "make remote-workspace", // foreground server OR short-lived launcher
15984
+ "stop": "make stop-remote-workspace", // required when start exits after handoff
15985
+ "url": { "command": "make --silent show-workspace-url" } // or a literal HTTP(S) URL
15938
15986
  }
15939
15987
  ]
15940
15988
  }
15941
15989
  \`\`\`
15942
15990
 
15943
- (Files written by older Tempo versions may still carry \`apps[].appStart\`, \`scripts.appStart\`, or top-level \`appDir\`. They read normally and migrate to canonical \`apps[].start\` on the next config write.)
15991
+ (Files written by older Tempo versions may still carry \`apps[].appStart\`, \`scripts.appStart\`, or top-level \`appDir\`. They read normally and migrate to \`apps[].start\` / \`apps[].appDir\` on the next config write.)
15992
+
15993
+ The three app commands share one launch context: Tempo runs them from
15994
+ \`appDir\` and exports the selected port as \`APP_PORT\`, \`PORT\`, and
15995
+ \`TEMPO_APP_PORT\` (literal \`\${APP_PORT}\` and legacy \`\${PORT}\` tokens are
15996
+ also substituted). A command-backed \`url\` runs exactly once after spawn; in
15997
+ the \`stop + url.command\` handoff shape, it waits for \`start\` to exit cleanly
15998
+ first so a URL getter cannot race remote provisioning. It is bounded and
15999
+ abortable, and its entire trimmed stdout must be one HTTP(S) URL; Tempo never
16000
+ guesses whether a string is executable. Readiness is always HTTP-probed at the
16001
+ resolved URL.
16002
+
16003
+ For an ordinary foreground dev server, the child process owns liveness and
16004
+ \`stop\` is optional. If \`start\` exits successfully before readiness, Tempo
16005
+ accepts the handoff only when \`stop\` is configured; the resolved URL then
16006
+ owns liveness, repeated probe failures demote the server, and \`stop\` owns
16007
+ teardown. Tempo persists the exact stop command, cwd, port, and resolved URL
16008
+ so the next session can reconcile a remote/container runtime after a hard
16009
+ crash instead of launching a duplicate. A failed URL command or readiness
16010
+ timeout after handoff also runs \`stop\` immediately.
15944
16011
 
15945
16012
  **The app's own bundler config MUST include the Tempo annotation plugin.** Route storyboards are served by the user's app dev server (NOT the \`tempo/\` sidecar), so the canvas can only select/edit their elements if THAT server injects the \`data-tempo-*\` annotations \u2014 i.e. the **app's own** framework config carries the Tempo plugin, active under \`TEMPO=true\`. Without it the app still renders in the iframe, but every click collapses to one unmappable root and the user reports *"route storyboard elements are not clickable."* This is separate from the \`tempo/\` sidecar's plugin (\xA74.2) \u2014 both configs need it, for different servers. Add the plugin to the app package (and add \`tempo-sdk\` to the app's deps) per framework:
15946
16013
 
@@ -15998,8 +16065,8 @@ The plugin no-ops unless \`TEMPO=true\` (which the supervisor sets when it runs
15998
16065
  Mechanics:
15999
16066
 
16000
16067
  - The supervisor always allocates a free local TCP port per workspace and persists it at \`<workspaceRoot>/.tempo/app-devserver.json\` (gitignored, per-worktree).
16001
- - A \`url\` containing \`\${APP_PORT}\` (or legacy \`\${PORT}\`) uses that allocated helper value. A literal URL is an opaque HTTP(S) readiness target and route base: its hostname and port never control local allocation, so it may point through a container, tunnel, or remote machine.
16002
- - Every literal \`\${APP_PORT}\` or legacy \`\${PORT}\` in app \`start\` and \`url\` is substituted before spawn/probe. The renderer resolves route storyboard paths against the resulting URL.
16068
+ - A string \`url\` containing \`\${APP_PORT}\` (or legacy \`\${PORT}\`) uses that allocated helper value. A literal URL is an opaque HTTP(S) readiness target and route base: its hostname and port never control local allocation, so it may point through a container, tunnel, or remote machine. A \`{ command }\` URL resolves the same target dynamically once per launch.
16069
+ - Every literal \`\${APP_PORT}\` or legacy \`\${PORT}\` in app \`start\`, \`stop\`, and \`url.command\` (or a string \`url\`) is substituted before execution/probing. The renderer resolves route storyboard paths against the resulting URL.
16003
16070
  - Lifecycle: warm with a 5-minute idle stop \u2014 the supervisor stays alive while a canvas with route storyboards is open and stops 5 minutes after the last canvas closes.
16004
16071
  - While starting, an overlay reads "Starting your app dev server\u2026" on every route storyboard.
16005
16072
 
@@ -16016,7 +16083,7 @@ Mechanics:
16016
16083
 
16017
16084
  **When using the allocated helper port, invoke the bundler binary directly \u2014 do NOT route the port flag through \`<pm> run dev -- --port=\${APP_PORT}\`.** When the \`dev\` script is a bare \`vite\` (the common case) and the project uses **pnpm**, that form can forward the literal \`--\` separator through to Vite, which treats it as a positional arg and ignores \`--port\`. The direct-exec form (\`pnpm exec vite \u2026\` / \`npx vite \u2026\`) is correct under every package manager. (\`--strictPort\` makes a mismatch fail loudly.)
16018
16085
 
16019
- The command is otherwise arbitrary. The allocated helper token is optional, and a configured \`url\` is always the liveness target Tempo probes.
16086
+ The command is otherwise arbitrary. The allocated helper token is optional. A configured \`url\` is always the readiness target Tempo probes and becomes the ongoing liveness authority after a clean launcher handoff.
16020
16087
 
16021
16088
  **Independent readiness URL.** Use a literal \`url\` whenever the URL Tempo can reach differs from the locally allocated port, including a container, tunnel, or remote machine:
16022
16089
 
@@ -16031,9 +16098,14 @@ The command is otherwise arbitrary. The allocated helper token is optional, and
16031
16098
 
16032
16099
  The supervisor probes \`url\` directly rather than waiting for stdout URL discovery and uses it as the public route base after it responds. The local helper port remains independently allocated and available to \`start\` through \`APP_PORT\`, \`PORT\`, and \`TEMPO_APP_PORT\`, even when the literal URL spells a completely different host or explicit port.
16033
16100
 
16034
- **Keep the command plain and cross-platform \u2014 one invocation, no shell conditionals.** Tempo spawns it through the platform shell (**cmd.exe on Windows**, not PowerShell) and substitutes \`\${APP_PORT}\` (or legacy \`\${PORT}\`) before spawning. A leading \`NAME=value\` prefix \u2014 for example \`PORT=\${APP_PORT} npm run dev\` \u2014 is also safe on every OS: the supervisor lifts it into the spawn environment instead of handing it to the shell, and it always sets \`APP_PORT\`, \`PORT\`, and \`TEMPO_APP_PORT\` in the environment regardless. Never use PowerShell syntax \u2014 \`if (Test-Path \u2026) { \u2026 } else { \u2026 }\`, \`Test-Path\`, \`$env:FOO\`, \`Get-\`/\`Set-\` cmdlets \u2014 cmd.exe can't parse it and aborts before running anything. If the app lives in a subdirectory, set \`appDir\` instead of changing directories inside the command.
16101
+ Use \`url: { command: "..." }\` when that target can only be discovered after
16102
+ \`start\`, such as a newly allocated remote workspace or tunnel. The explicit
16103
+ object is the execution boundary: plain URL strings are never treated as shell
16104
+ commands.
16105
+
16106
+ **Keep every command plain and cross-platform \u2014 one invocation, no shell conditionals.** Tempo spawns \`start\`, \`stop\`, and \`url.command\` through the platform shell (**cmd.exe on Windows**, not PowerShell) and substitutes \`\${APP_PORT}\` (or legacy \`\${PORT}\`) before spawning. A leading \`NAME=value\` prefix \u2014 for example \`PORT=\${APP_PORT} npm run dev\` \u2014 is also safe on every OS for \`start\`: the supervisor lifts it into the spawn environment instead of handing it to the shell, and it always sets \`APP_PORT\`, \`PORT\`, and \`TEMPO_APP_PORT\` in the environment regardless. Never use PowerShell syntax \u2014 \`if (Test-Path \u2026) { \u2026 } else { \u2026 }\`, \`Test-Path\`, \`$env:FOO\`, \`Get-\`/\`Set-\` cmdlets \u2014 cmd.exe can't parse it and aborts before running anything. If the app lives in a subdirectory, set \`appDir\` instead of changing directories inside the command.
16035
16107
 
16036
- **Driving the lifecycle directly.** Three sibling tools mirror the canvas-sidecar trio: \`check_app_dev_server\` / \`start_app_dev_server\` / \`stop_app_dev_server\` (vs \`check_canvas_devserver\` / \`start_canvas_devserver\` / \`stop_canvas_devserver\`). \`check_app_dev_server\` returns \`{configured, running, phase, port, url, error}\` \u2014 call it first when a route storyboard isn't loading, to disambiguate: \`configured: false\` \u2192 \`set_app_dev_command\`; \`configured: true, running: false\` \u2192 read \`error\`, then \`start_app_dev_server\`; \`running: true\` \u2192 the route itself is the problem, not the supervisor. After editing the app dev command, \`stop_app_dev_server\` then \`start_app_dev_server\` to pick up the new command.
16108
+ **Driving the lifecycle directly.** Three sibling tools mirror the canvas-sidecar trio: \`check_app_dev_server\` / \`start_app_dev_server\` / \`stop_app_dev_server\` (vs \`check_canvas_devserver\` / \`start_canvas_devserver\` / \`stop_canvas_devserver\`). \`check_app_dev_server\` returns \`{configured, running, phase, port, url, livenessSource, error}\` \u2014 call it first when a route storyboard isn't loading, to disambiguate: \`configured: false\` \u2192 \`set_app_dev_command\`; \`configured: true, running: false\` \u2192 read \`error\`, then \`start_app_dev_server\`; \`running: true\` \u2192 the route itself is the problem, not the supervisor. \`livenessSource: "probe"\` identifies an external-runtime handoff; \`"child"\` identifies a foreground app process. After editing the app dev command, \`stop_app_dev_server\` then \`start_app_dev_server\` to pick up the new command.
16037
16109
 
16038
16110
  **Pre-existing canvases with blank route iframes** on a project without an app dev command are a config gap, not a bug \u2014 call \`set_app_dev_command\` and the iframes light up.
16039
16111
 
@@ -24391,6 +24463,66 @@ async function atomicWriteText(filePath, content) {
24391
24463
  }
24392
24464
  }
24393
24465
 
24466
+ // ../tempo-devserver/http-url-source.ts
24467
+ var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
24468
+ async function resolveHttpUrlSource(options) {
24469
+ if (typeof options.source === "string") {
24470
+ return options.substitutePort(options.source, options.port);
24471
+ }
24472
+ const controller = new AbortController();
24473
+ const onAbort = () => controller.abort();
24474
+ options.signal?.addEventListener("abort", onAbort, { once: true });
24475
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
24476
+ let result;
24477
+ try {
24478
+ result = await runAbortableShellCommand({
24479
+ command: options.substitutePort(options.source.command, options.port),
24480
+ cwd: options.cwd,
24481
+ env: options.env,
24482
+ signal: controller.signal,
24483
+ maxOutputBytes: 4e3
24484
+ });
24485
+ } finally {
24486
+ clearTimeout(timeout);
24487
+ options.signal?.removeEventListener("abort", onAbort);
24488
+ }
24489
+ if (result.aborted) {
24490
+ if (options.signal?.aborted) {
24491
+ throw new Error(`${options.label}.command was aborted`);
24492
+ }
24493
+ throw new Error(
24494
+ `${options.label}.command timed out after ${options.timeoutMs}ms`
24495
+ );
24496
+ }
24497
+ if (result.spawnError) {
24498
+ throw new Error(
24499
+ `${options.label}.command could not start: ${result.spawnError.message}`
24500
+ );
24501
+ }
24502
+ if (result.exitCode !== 0) {
24503
+ const detail = result.stderr.trim();
24504
+ throw new Error(
24505
+ `${options.label}.command exited with code ${result.exitCode ?? "unknown"}${detail ? `: ${detail}` : ""}`
24506
+ );
24507
+ }
24508
+ const output = result.stdout.replace(ANSI_ESCAPE_RE2, "").trim();
24509
+ let parsed;
24510
+ try {
24511
+ parsed = new URL(output);
24512
+ } catch {
24513
+ throw invalidOutputError(options.label);
24514
+ }
24515
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || output.includes("\n") || output.includes("\r")) {
24516
+ throw invalidOutputError(options.label);
24517
+ }
24518
+ return output;
24519
+ }
24520
+ function invalidOutputError(label) {
24521
+ return new Error(
24522
+ `${label}.command must print exactly one valid HTTP(S) URL to stdout`
24523
+ );
24524
+ }
24525
+
24394
24526
  // ../tempo-devserver/app-devserver.ts
24395
24527
  var APP_DEVSERVER_STATE_FILENAME = "app-devserver.json";
24396
24528
  var APP_DEVSERVER_LOG_FILENAME = "app-devserver.log";
@@ -24401,8 +24533,8 @@ var DEFAULT_HTTP_READY_TIMEOUT_MS = 18e4;
24401
24533
  var HTTP_PROBE_ATTEMPT_TIMEOUT_MS = 5e3;
24402
24534
  var HTTP_PROBE_ABSOLUTE_CAP_MS = 6e5;
24403
24535
  var PUBLIC_HOSTNAME = "127.0.0.1";
24404
- var PERSISTED_STATE_VERSION = 5;
24405
- var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
24536
+ var PERSISTED_STATE_VERSION = 6;
24537
+ var ANSI_ESCAPE_RE3 = /\x1b\[[0-9;]*m/g;
24406
24538
  var URL_RE = /https?:\/\/[^\s"'`<>]+/gi;
24407
24539
  var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set([
24408
24540
  "127.0.0.1",
@@ -24421,6 +24553,12 @@ var AppDevServer = class _AppDevServer {
24421
24553
  error: null
24422
24554
  };
24423
24555
  this.child = null;
24556
+ this.launcherFinished = false;
24557
+ this.commandEnv = null;
24558
+ this.urlHealthProbeTimer = null;
24559
+ this.urlHealthProbeUrl = null;
24560
+ this.urlHealthProbeConsecutiveFailures = 0;
24561
+ this.urlHealthProbeInFlight = false;
24424
24562
  /** Invalidates late stdout/stderr/exit events from an earlier start cycle. */
24425
24563
  this.outputEpoch = 0;
24426
24564
  this.recentOutput = [];
@@ -24469,11 +24607,15 @@ var AppDevServer = class _AppDevServer {
24469
24607
  this.hasParentDeathWatchdog = false;
24470
24608
  this.options = {
24471
24609
  command: options.command,
24610
+ stop: options.stop,
24472
24611
  url: options.url,
24473
24612
  cwd: path12.resolve(options.cwd),
24474
24613
  workspaceRoot: path12.resolve(options.workspaceRoot),
24475
24614
  urlScrapeTimeoutMs: resolveUrlScrapeTimeout(options.urlScrapeTimeoutMs),
24476
24615
  httpReadyTimeoutMs: resolveHttpReadyTimeout(options.httpReadyTimeoutMs),
24616
+ urlCommandTimeoutMs: options.urlCommandTimeoutMs ?? 3e4,
24617
+ urlHealthProbeIntervalMs: options.urlHealthProbeIntervalMs ?? 5e3,
24618
+ urlHealthFailuresToDemote: options.urlHealthFailuresToDemote ?? 3,
24477
24619
  portRange: options.portRange ?? DEFAULT_PORT_RANGE,
24478
24620
  ownership: options.ownership
24479
24621
  };
@@ -24491,6 +24633,7 @@ var AppDevServer = class _AppDevServer {
24491
24633
  getStatus() {
24492
24634
  return {
24493
24635
  ...this.status,
24636
+ livenessSource: this.status.phase === "running" ? this.launcherFinished ? "probe" : "child" : null,
24494
24637
  recentOutputTail: this.recentOutput.join("").slice(-2e3)
24495
24638
  };
24496
24639
  }
@@ -24531,6 +24674,11 @@ var AppDevServer = class _AppDevServer {
24531
24674
  if (ownership !== "reapable") {
24532
24675
  return ownership;
24533
24676
  }
24677
+ let externalRuntimeReaped = false;
24678
+ if (persisted.externalRuntime) {
24679
+ externalRuntimeReaped = await this.cleanupPersistedExternalRuntime(persisted);
24680
+ if (!externalRuntimeReaped) return "orphan_cleanup_failed";
24681
+ }
24534
24682
  const identities = [
24535
24683
  [persisted.pid, persisted.pidStartedAt, "shell"],
24536
24684
  [persisted.serverPid, persisted.serverPidStartedAt, "server"]
@@ -24538,7 +24686,9 @@ var AppDevServer = class _AppDevServer {
24538
24686
  const hadProcessIdentity = identities.some(
24539
24687
  ([pid, startedAt]) => pid != null && startedAt != null
24540
24688
  );
24541
- if (!hadProcessIdentity) return "nothing_to_reconcile";
24689
+ if (!hadProcessIdentity) {
24690
+ return externalRuntimeReaped ? "orphan_reaped" : "nothing_to_reconcile";
24691
+ }
24542
24692
  if (persisted.pid != null && await pidStartIdentityMatches(persisted.pid, persisted.pidStartedAt)) {
24543
24693
  const outcome = await reapOwnedProcessGroup(persisted.pid);
24544
24694
  devLog("app-devserver:registry-orphan-group-reap", {
@@ -24581,6 +24731,27 @@ var AppDevServer = class _AppDevServer {
24581
24731
  await this.persistServerPid(null);
24582
24732
  return "orphan_reaped";
24583
24733
  }
24734
+ async cleanupPersistedExternalRuntime(persisted) {
24735
+ const external = persisted.externalRuntime;
24736
+ if (!external) return true;
24737
+ const result = await this.runBoundedCommand(
24738
+ external.stopCommand,
24739
+ persisted.port,
24740
+ 1e4,
24741
+ void 0,
24742
+ external.cwd
24743
+ );
24744
+ devLog("app-devserver:persisted-external-stop", {
24745
+ command: external.stopCommand,
24746
+ exitCode: result.exitCode,
24747
+ aborted: result.aborted,
24748
+ spawnError: result.spawnError?.message ?? null,
24749
+ workspaceRoot: this.options.workspaceRoot
24750
+ });
24751
+ const stopped = external.url ? await waitForAppUrlUnreachable(external.url, 5e3) : describeShellCommandFailure(result) === null;
24752
+ if (stopped) await this.persistExternalRuntime(null);
24753
+ return stopped;
24754
+ }
24584
24755
  onStatusChange(listener) {
24585
24756
  this.listeners.push(listener);
24586
24757
  return () => {
@@ -24643,6 +24814,7 @@ var AppDevServer = class _AppDevServer {
24643
24814
  */
24644
24815
  forceKill() {
24645
24816
  this.intentionalStop = true;
24817
+ this.stopUrlHealthProbe();
24646
24818
  this.outputEpoch += 1;
24647
24819
  this.clearOutputBroadcastTimer();
24648
24820
  const child = this.child;
@@ -24726,8 +24898,12 @@ var AppDevServer = class _AppDevServer {
24726
24898
  const outputEpoch = ++this.outputEpoch;
24727
24899
  this.setStatus({ phase: "starting", error: null });
24728
24900
  this.intentionalStop = false;
24901
+ this.launcherFinished = false;
24902
+ this.commandEnv = null;
24903
+ this.stopUrlHealthProbe();
24729
24904
  this.detectedBaseUrl = null;
24730
24905
  this.recentOutput = [];
24906
+ let startSpawned = false;
24731
24907
  const dialectIssue = findPowerShellSyntax(this.options.command);
24732
24908
  if (dialectIssue) {
24733
24909
  const error = `The app dev command was rejected before spawn: it contains PowerShell-only syntax (${dialectIssue}), which the shell Tempo runs commands with (cmd.exe on Windows) cannot parse \u2014 nothing was started. Rewrite "apps[0].start" in tempo.config.json as a single plain cross-platform command \u2014 use \${APP_PORT} only if it needs Tempo's allocated helper port \u2014 and if the app lives in a subdirectory set "appDir" instead of cd/pushd-ing inside the command (chaining with \`&&\` is fine; cmd supports it).`;
@@ -24738,6 +24914,19 @@ var AppDevServer = class _AppDevServer {
24738
24914
  this.setStatus({ phase: "error", error, url: null });
24739
24915
  return { ok: false, url: null, error };
24740
24916
  }
24917
+ const stopDialectIssue = this.options.stop ? findPowerShellSyntax(this.options.stop) : null;
24918
+ if (stopDialectIssue) {
24919
+ const error = `The app stop command was rejected before spawn: it contains PowerShell-only syntax (${stopDialectIssue}). Rewrite "apps[0].stop" as one plain cross-platform command.`;
24920
+ this.setStatus({ phase: "error", error, url: null });
24921
+ return { ok: false, url: null, error };
24922
+ }
24923
+ const urlCommand = typeof this.options.url === "object" ? this.options.url.command : null;
24924
+ const urlDialectIssue = urlCommand ? findPowerShellSyntax(urlCommand) : null;
24925
+ if (urlDialectIssue) {
24926
+ const error = `The app URL command was rejected before spawn: it contains PowerShell-only syntax (${urlDialectIssue}). Rewrite "apps[0].url.command" as one plain cross-platform command.`;
24927
+ this.setStatus({ phase: "error", error, url: null });
24928
+ return { ok: false, url: null, error };
24929
+ }
24741
24930
  await this.truncateLogFile();
24742
24931
  throwIfAppLaunchAborted(signal);
24743
24932
  let removeAbortListener = () => {
@@ -24760,29 +24949,9 @@ var AppDevServer = class _AppDevServer {
24760
24949
  this.substitutePort(this.options.command, port)
24761
24950
  );
24762
24951
  const command = lifted.command;
24763
- const env = {
24764
- ...process.env,
24765
- APP_PORT: String(port),
24766
- PORT: String(port),
24767
- TEMPO_APP_PORT: String(port),
24768
- // Activate the Tempo annotation plugin (tempoVitePlugin /
24769
- // tempoNextjsPlugin / Expo babel plugin), which no-ops unless
24770
- // TEMPO=true. Without it the app server serves route storyboards with
24771
- // no data-tempo-* source annotations, so their elements can't be
24772
- // mapped back to source and aren't selectable on the canvas ("route
24773
- // storyboard elements not clickable"). The Next.js/Expo command shapes
24774
- // already prefix TEMPO=true, but the Vite shape does not, so the
24775
- // supervisor owns this for every framework. Same signal the canvas
24776
- // sidecar sets via buildManagedCommandEnv (TempoHostProcess).
24777
- TEMPO: "true"
24778
- };
24779
- for (const key of Object.keys(env)) {
24780
- const k2 = key.toLowerCase();
24781
- if (k2.startsWith("npm_") || k2.startsWith("pnpm_") || key === "INIT_CWD" || key === "NODE_PATH") {
24782
- delete env[key];
24783
- }
24784
- }
24952
+ const env = buildAppCommandEnv(port);
24785
24953
  Object.assign(env, lifted.env);
24954
+ this.commandEnv = env;
24786
24955
  devLog("app-devserver:spawn", {
24787
24956
  command,
24788
24957
  configuredUrl: this.options.url ?? null,
@@ -24817,6 +24986,7 @@ var AppDevServer = class _AppDevServer {
24817
24986
  windowsHide: true,
24818
24987
  env: commandEnv
24819
24988
  });
24989
+ startSpawned = true;
24820
24990
  this.wireProcessEvents(outputEpoch);
24821
24991
  const onAbort = () => {
24822
24992
  void this.killChild();
@@ -24837,19 +25007,39 @@ var AppDevServer = class _AppDevServer {
24837
25007
  };
24838
25008
  return await this.runStartCycle(attempt + 1, signal);
24839
25009
  }
25010
+ const startError = this.status.error;
25011
+ const cleanupError2 = await this.cleanupFailedLaunch(
25012
+ port,
25013
+ startSpawned
25014
+ );
25015
+ const finalError2 = cleanupError2 ? `${startError ?? "App dev server failed to start."}
25016
+
25017
+ Cleanup also failed: ${cleanupError2}` : startError;
25018
+ this.setStatus({ phase: "error", error: finalError2, url: null });
24840
25019
  return {
24841
25020
  ok: false,
24842
25021
  url: null,
24843
- error: this.status.error
25022
+ error: finalError2
24844
25023
  };
24845
25024
  }
24846
25025
  const error = "App dev server did not become reachable in time.";
24847
- await this.killChild();
24848
- this.setStatus({ phase: "error", error, url: null });
24849
- return { ok: false, url: null, error };
25026
+ const cleanupError = await this.cleanupFailedLaunch(port, startSpawned);
25027
+ const finalError = cleanupError ? `${error}
25028
+
25029
+ Cleanup also failed: ${cleanupError}` : error;
25030
+ this.setStatus({ phase: "error", error: finalError, url: null });
25031
+ return { ok: false, url: null, error: finalError };
24850
25032
  }
24851
25033
  throwIfAppLaunchAborted(signal);
24852
25034
  this.setStatus({ phase: "running", url, error: null });
25035
+ if (this.launcherFinished && this.options.stop) {
25036
+ await this.persistExternalRuntime({
25037
+ stopCommand: this.options.stop,
25038
+ cwd: this.options.cwd,
25039
+ url
25040
+ });
25041
+ }
25042
+ if (this.launcherFinished) this.startUrlHealthProbe(url);
24853
25043
  const serverPid = await findPortListenerPid(port);
24854
25044
  throwIfAppLaunchAborted(signal);
24855
25045
  const persisted = await this.readPersistedState();
@@ -24867,17 +25057,97 @@ var AppDevServer = class _AppDevServer {
24867
25057
  return { ok: true, url, error: null };
24868
25058
  } catch (error) {
24869
25059
  const message = error instanceof Error ? error.message : String(error);
24870
- await this.killChild();
24871
- this.setStatus({ phase: "error", error: message });
24872
- return { ok: false, url: null, error: message };
25060
+ const cleanupError = await this.cleanupFailedLaunch(
25061
+ this.status.port,
25062
+ startSpawned
25063
+ );
25064
+ const finalError = cleanupError ? `${message}
25065
+
25066
+ Cleanup also failed: ${cleanupError}` : message;
25067
+ this.setStatus({ phase: "error", error: finalError });
25068
+ return { ok: false, url: null, error: finalError };
24873
25069
  } finally {
24874
25070
  removeAbortListener();
24875
25071
  }
24876
25072
  }
25073
+ /**
25074
+ * Undo any launch attempt that failed after `start` was spawned. External
25075
+ * launchers can leave partial resources even on a nonzero exit, and after a
25076
+ * clean handoff killing the child cannot tear down anything at all; `stop`
25077
+ * is the only remaining authority. If cleanup itself fails, retain that
25078
+ * authority durably so the next stop/start/session can retry it.
25079
+ */
25080
+ async cleanupFailedLaunch(port, startWasSpawned) {
25081
+ this.stopUrlHealthProbe();
25082
+ if (!startWasSpawned) {
25083
+ this.commandEnv = null;
25084
+ return null;
25085
+ }
25086
+ let cleanupError = null;
25087
+ if (this.options.stop && port != null) {
25088
+ const stopResult = await this.runBoundedCommand(
25089
+ this.options.stop,
25090
+ port,
25091
+ 1e4
25092
+ );
25093
+ devLog("app-devserver:failed-launch-stop-command", {
25094
+ command: this.options.stop,
25095
+ exitCode: stopResult.exitCode,
25096
+ aborted: stopResult.aborted,
25097
+ spawnError: stopResult.spawnError?.message ?? null,
25098
+ stderr: stopResult.stderr.slice(-1e3),
25099
+ workspaceRoot: this.options.workspaceRoot
25100
+ });
25101
+ cleanupError = describeShellCommandFailure(stopResult);
25102
+ }
25103
+ await this.killChild();
25104
+ if (cleanupError && this.options.stop) {
25105
+ await this.persistExternalRuntime({
25106
+ stopCommand: this.options.stop,
25107
+ cwd: this.options.cwd,
25108
+ url: null
25109
+ });
25110
+ } else {
25111
+ await this.persistExternalRuntime(null);
25112
+ }
25113
+ this.launcherFinished = false;
25114
+ this.commandEnv = null;
25115
+ return cleanupError;
25116
+ }
24877
25117
  async runStopCycle() {
24878
25118
  this.intentionalStop = true;
25119
+ this.stopUrlHealthProbe();
24879
25120
  this.clearOutputBroadcastTimer();
24880
- const port = this.status.port;
25121
+ const persistedBeforeStop = await this.readPersistedState();
25122
+ const persistedExternal = persistedBeforeStop?.externalRuntime ?? null;
25123
+ if (persistedExternal && persistedBeforeStop && await this.classifyPersistedProcessOwnership(persistedBeforeStop) !== "reapable") {
25124
+ const message = "External app server teardown belongs to another live Tempo manager; refusing to run its stop command.";
25125
+ this.setStatus({ phase: "error", url: null, error: message });
25126
+ throw new Error(message);
25127
+ }
25128
+ const port = this.status.port ?? persistedBeforeStop?.port ?? null;
25129
+ const url = this.status.url ?? persistedExternal?.url ?? null;
25130
+ const stopCommand = persistedExternal?.stopCommand ?? this.options.stop;
25131
+ const stopCwd = persistedExternal?.cwd ?? this.options.cwd;
25132
+ let stopCommandError = null;
25133
+ if (stopCommand && port != null) {
25134
+ const stopResult = await this.runBoundedCommand(
25135
+ stopCommand,
25136
+ port,
25137
+ 1e4,
25138
+ void 0,
25139
+ stopCwd
25140
+ );
25141
+ devLog("app-devserver:stop-command", {
25142
+ command: stopCommand,
25143
+ exitCode: stopResult.exitCode,
25144
+ aborted: stopResult.aborted,
25145
+ spawnError: stopResult.spawnError?.message ?? null,
25146
+ stderr: stopResult.stderr.slice(-1e3),
25147
+ workspaceRoot: this.options.workspaceRoot
25148
+ });
25149
+ stopCommandError = describeShellCommandFailure(stopResult);
25150
+ }
24881
25151
  await this.killChild();
24882
25152
  this.outputEpoch += 1;
24883
25153
  await this.outputLog.flush();
@@ -24916,8 +25186,21 @@ var AppDevServer = class _AppDevServer {
24916
25186
  this.setStatus({ phase: "error", url: null, error: message });
24917
25187
  throw new Error(message);
24918
25188
  }
25189
+ if (url && !await waitForAppUrlUnreachable(url, 5e3)) {
25190
+ const message = `App dev server is still reachable at ${url} after stop`;
25191
+ this.setStatus({ phase: "error", url: null, error: message });
25192
+ throw new Error(message);
25193
+ }
25194
+ if (persistedExternal && !url && stopCommandError) {
25195
+ const message = `External app server teardown could not be verified: ${stopCommandError}`;
25196
+ this.setStatus({ phase: "error", url: null, error: message });
25197
+ throw new Error(message);
25198
+ }
24919
25199
  await this.persistChildPid(null);
24920
25200
  await this.persistServerPid(null);
25201
+ await this.persistExternalRuntime(null);
25202
+ this.launcherFinished = false;
25203
+ this.commandEnv = null;
24921
25204
  this.setStatus({
24922
25205
  phase: "stopped",
24923
25206
  url: null,
@@ -25013,6 +25296,25 @@ var AppDevServer = class _AppDevServer {
25013
25296
  if (outputEpoch !== this.outputEpoch || this.child !== child) return;
25014
25297
  this.child = null;
25015
25298
  void this.persistChildPid(null);
25299
+ const cleanLauncherExit = !this.intentionalStop && code === 0 && signal == null;
25300
+ const acceptedLauncherHandoff = cleanLauncherExit && this.options.stop != null && (this.status.phase === "starting" || this.status.phase === "running");
25301
+ if (acceptedLauncherHandoff && this.options.stop) {
25302
+ this.launcherFinished = true;
25303
+ void this.persistExternalRuntime({
25304
+ stopCommand: this.options.stop,
25305
+ cwd: this.options.cwd,
25306
+ url: this.status.phase === "running" ? this.status.url : null
25307
+ });
25308
+ if (this.status.phase === "running" && this.status.url) {
25309
+ this.startUrlHealthProbe(this.status.url);
25310
+ }
25311
+ devLog("app-devserver:launcher-finished", {
25312
+ pid: child.pid ?? null,
25313
+ afterReady: this.status.phase === "running",
25314
+ workspaceRoot: this.options.workspaceRoot
25315
+ });
25316
+ return;
25317
+ }
25016
25318
  if (!this.intentionalStop && this.status.phase !== "stopped") {
25017
25319
  const conflict = this.status.phase === "running" ? parseDevServerLockConflict(this.recentOutput.join("")) : null;
25018
25320
  if (conflict != null) {
@@ -25025,7 +25327,7 @@ var AppDevServer = class _AppDevServer {
25025
25327
  }
25026
25328
  const tail = this.recentOutput.join("").slice(-500);
25027
25329
  const hint = tail ? classifyDevServerOutput(tail) : null;
25028
- const error = `App dev server exited unexpectedly (code ${code ?? "null"}, signal ${signal ?? "null"}).${tail ? `
25330
+ const error = cleanLauncherExit && this.status.phase === "starting" ? 'The app start command exited successfully before the configured URL became ready. Add a matching non-empty "apps[0].stop" command so Tempo has teardown authority for the external server.' : `App dev server exited unexpectedly (code ${code ?? "null"}, signal ${signal ?? "null"}).${tail ? `
25029
25331
 
25030
25332
  ${tail}` : ""}${hint ? `
25031
25333
 
@@ -25047,12 +25349,12 @@ Hint: ${hint.hint}` : ""}`;
25047
25349
  });
25048
25350
  }
25049
25351
  async waitForReady(port, signal) {
25050
- const configuredUrl = this.options.url ? substituteAppPort(this.options.url, port) : null;
25352
+ const configuredUrl = await this.resolveConfiguredUrl(port, signal);
25051
25353
  if (!configuredUrl) {
25052
25354
  const scrapeDeadline = Date.now() + this.options.urlScrapeTimeoutMs;
25053
25355
  while (!this.detectedBaseUrl && Date.now() < scrapeDeadline) {
25054
25356
  throwIfAppLaunchAborted(signal);
25055
- if (!this.child) return null;
25357
+ if (!this.hasLivenessAuthority()) return null;
25056
25358
  await sleepForAppLaunch(120, signal);
25057
25359
  }
25058
25360
  }
@@ -25066,7 +25368,7 @@ Hint: ${hint.hint}` : ""}`;
25066
25368
  let lastProgressAt = start;
25067
25369
  while (true) {
25068
25370
  throwIfAppLaunchAborted(signal);
25069
- if (!this.child) return null;
25371
+ if (!this.hasLivenessAuthority()) return null;
25070
25372
  const outcome = await probeHttpOnce(
25071
25373
  publicUrl,
25072
25374
  HTTP_PROBE_ATTEMPT_TIMEOUT_MS,
@@ -25083,6 +25385,112 @@ Hint: ${hint.hint}` : ""}`;
25083
25385
  await sleepForAppLaunch(httpProbeInterval(now - start), signal);
25084
25386
  }
25085
25387
  }
25388
+ hasLivenessAuthority() {
25389
+ return this.launcherFinished || this.child !== null;
25390
+ }
25391
+ async resolveConfiguredUrl(port, signal) {
25392
+ const source = this.options.url;
25393
+ if (!source) return null;
25394
+ if (typeof source === "object" && this.options.stop) {
25395
+ await this.waitForLauncherHandoff(signal);
25396
+ }
25397
+ try {
25398
+ return await resolveHttpUrlSource({
25399
+ source,
25400
+ label: "apps[0].url",
25401
+ cwd: this.options.cwd,
25402
+ env: this.commandEnv ?? buildAppCommandEnv(port),
25403
+ port,
25404
+ timeoutMs: this.options.urlCommandTimeoutMs,
25405
+ signal,
25406
+ substitutePort: substituteAppPort
25407
+ });
25408
+ } catch (error) {
25409
+ throwIfAppLaunchAborted(signal);
25410
+ throw error;
25411
+ }
25412
+ }
25413
+ async waitForLauncherHandoff(signal) {
25414
+ const deadline = Date.now() + HTTP_PROBE_ABSOLUTE_CAP_MS;
25415
+ while (!this.launcherFinished) {
25416
+ throwIfAppLaunchAborted(signal);
25417
+ if (!this.child) {
25418
+ throw new Error(
25419
+ this.status.error ?? "The app start command stopped before URL resolution could run."
25420
+ );
25421
+ }
25422
+ if (Date.now() >= deadline) {
25423
+ throw new Error(
25424
+ "Timed out waiting for the app start command to hand off before running apps[0].url.command"
25425
+ );
25426
+ }
25427
+ await sleepForAppLaunch(120, signal);
25428
+ }
25429
+ }
25430
+ async runBoundedCommand(command, port, timeoutMs, signal, cwd = this.options.cwd) {
25431
+ const controller = new AbortController();
25432
+ const onAbort = () => controller.abort();
25433
+ signal?.addEventListener("abort", onAbort, { once: true });
25434
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
25435
+ try {
25436
+ return await runAbortableShellCommand({
25437
+ command: substituteAppPort(command, port),
25438
+ cwd,
25439
+ env: this.commandEnv ?? buildAppCommandEnv(port),
25440
+ signal: controller.signal,
25441
+ maxOutputBytes: 4e3
25442
+ });
25443
+ } finally {
25444
+ clearTimeout(timeout);
25445
+ signal?.removeEventListener("abort", onAbort);
25446
+ }
25447
+ }
25448
+ startUrlHealthProbe(url) {
25449
+ this.stopUrlHealthProbe();
25450
+ this.urlHealthProbeUrl = url;
25451
+ this.urlHealthProbeTimer = setInterval(() => {
25452
+ void this.runUrlHealthProbeTick();
25453
+ }, this.options.urlHealthProbeIntervalMs);
25454
+ this.urlHealthProbeTimer.unref?.();
25455
+ }
25456
+ stopUrlHealthProbe() {
25457
+ if (this.urlHealthProbeTimer) clearInterval(this.urlHealthProbeTimer);
25458
+ this.urlHealthProbeTimer = null;
25459
+ this.urlHealthProbeUrl = null;
25460
+ this.urlHealthProbeConsecutiveFailures = 0;
25461
+ this.urlHealthProbeInFlight = false;
25462
+ }
25463
+ async runUrlHealthProbeTick() {
25464
+ if (this.urlHealthProbeInFlight) return;
25465
+ const url = this.urlHealthProbeUrl;
25466
+ if (!url || this.intentionalStop) return;
25467
+ this.urlHealthProbeInFlight = true;
25468
+ try {
25469
+ const outcome = await probeHttpOnce(url, 1500);
25470
+ if (this.urlHealthProbeUrl !== url || this.intentionalStop) return;
25471
+ if (outcome === "reachable" || outcome === "held") {
25472
+ this.urlHealthProbeConsecutiveFailures = 0;
25473
+ return;
25474
+ }
25475
+ if (!this.launcherFinished) return;
25476
+ this.urlHealthProbeConsecutiveFailures += 1;
25477
+ if (this.urlHealthProbeConsecutiveFailures < this.options.urlHealthFailuresToDemote) {
25478
+ return;
25479
+ }
25480
+ const error = `App dev server URL became unreachable after launcher handoff: ${url}`;
25481
+ this.launcherFinished = false;
25482
+ this.stopUrlHealthProbe();
25483
+ this.setStatus({ phase: "error", url: null, error });
25484
+ for (const listener of [...this.exitListeners]) {
25485
+ try {
25486
+ listener({ code: null, signal: null, error });
25487
+ } catch {
25488
+ }
25489
+ }
25490
+ } finally {
25491
+ this.urlHealthProbeInFlight = false;
25492
+ }
25493
+ }
25086
25494
  async killChild() {
25087
25495
  const child = this.child;
25088
25496
  if (!child) return;
@@ -25172,6 +25580,19 @@ Hint: ${hint.hint}` : ""}`;
25172
25580
  async allocatePort() {
25173
25581
  const persisted = await this.readPersistedState();
25174
25582
  if (persisted) {
25583
+ if (persisted.externalRuntime) {
25584
+ const ownership = await this.classifyPersistedProcessOwnership(persisted);
25585
+ if (ownership !== "reapable") {
25586
+ throw new Error(
25587
+ "An external app server is still owned by another Tempo manager; refusing to start a duplicate."
25588
+ );
25589
+ }
25590
+ if (!await this.cleanupPersistedExternalRuntime(persisted)) {
25591
+ throw new Error(
25592
+ "The previously launched external app server could not be stopped; refusing to start a duplicate."
25593
+ );
25594
+ }
25595
+ }
25175
25596
  if (await isPortFree(persisted.port)) {
25176
25597
  return persisted.port;
25177
25598
  }
@@ -25227,6 +25648,7 @@ Hint: ${hint.hint}` : ""}`;
25227
25648
  pidStartedAt: null,
25228
25649
  serverPid: null,
25229
25650
  serverPidStartedAt: null,
25651
+ externalRuntime: null,
25230
25652
  ...this.persistedOwnership()
25231
25653
  });
25232
25654
  return fresh;
@@ -25257,6 +25679,7 @@ Hint: ${hint.hint}` : ""}`;
25257
25679
  pidStartedAt,
25258
25680
  serverPid: persisted.serverPid,
25259
25681
  serverPidStartedAt: persisted.serverPidStartedAt,
25682
+ externalRuntime: persisted.externalRuntime,
25260
25683
  ...this.persistedOwnership(persisted)
25261
25684
  });
25262
25685
  });
@@ -25286,6 +25709,19 @@ Hint: ${hint.hint}` : ""}`;
25286
25709
  pidStartedAt: persisted.pidStartedAt,
25287
25710
  serverPid,
25288
25711
  serverPidStartedAt,
25712
+ externalRuntime: persisted.externalRuntime,
25713
+ ...this.persistedOwnership(persisted)
25714
+ });
25715
+ });
25716
+ }
25717
+ async persistExternalRuntime(externalRuntime) {
25718
+ await this.runExclusiveStateFileOp(async () => {
25719
+ const persisted = await this.readPersistedState();
25720
+ if (!persisted) return;
25721
+ await this.writePersistedStateUnlocked({
25722
+ ...persisted,
25723
+ version: PERSISTED_STATE_VERSION,
25724
+ externalRuntime,
25289
25725
  ...this.persistedOwnership(persisted)
25290
25726
  });
25291
25727
  });
@@ -25341,7 +25777,12 @@ Hint: ${hint.hint}` : ""}`;
25341
25777
  serverPidStartedAt: typeof parsed.serverPidStartedAt === "string" ? parsed.serverPidStartedAt : null,
25342
25778
  ownerId: typeof parsed.ownerId === "string" ? parsed.ownerId : null,
25343
25779
  managerPid: typeof parsed.managerPid === "number" && Number.isInteger(parsed.managerPid) ? parsed.managerPid : null,
25344
- managerStartedAt: typeof parsed.managerStartedAt === "string" ? parsed.managerStartedAt : null
25780
+ managerStartedAt: typeof parsed.managerStartedAt === "string" ? parsed.managerStartedAt : null,
25781
+ externalRuntime: parsed.externalRuntime && typeof parsed.externalRuntime === "object" && typeof parsed.externalRuntime.stopCommand === "string" && typeof parsed.externalRuntime.cwd === "string" && (typeof parsed.externalRuntime.url === "string" || parsed.externalRuntime.url === null) ? {
25782
+ stopCommand: parsed.externalRuntime.stopCommand,
25783
+ cwd: parsed.externalRuntime.cwd,
25784
+ url: parsed.externalRuntime.url
25785
+ } : null
25345
25786
  };
25346
25787
  }
25347
25788
  } catch {
@@ -25414,7 +25855,7 @@ Hint: ${hint.hint}` : ""}`;
25414
25855
  }
25415
25856
  };
25416
25857
  function extractServerUrl(text) {
25417
- const stripped = text.replace(ANSI_ESCAPE_RE2, "");
25858
+ const stripped = text.replace(ANSI_ESCAPE_RE3, "");
25418
25859
  const matches = stripped.match(URL_RE);
25419
25860
  if (!matches) return null;
25420
25861
  for (const raw of matches) {
@@ -25532,6 +25973,39 @@ function waitForWatchdogChildPid(watchdog, timeoutMs = 2e3) {
25532
25973
  function substituteAppPort(command, port) {
25533
25974
  return command.replace(/\$\{(?:APP_PORT|PORT)\}/g, String(port));
25534
25975
  }
25976
+ function buildAppCommandEnv(port) {
25977
+ const env = {
25978
+ ...process.env,
25979
+ APP_PORT: String(port),
25980
+ PORT: String(port),
25981
+ TEMPO_APP_PORT: String(port),
25982
+ TEMPO: "true"
25983
+ };
25984
+ for (const key of Object.keys(env)) {
25985
+ const normalized = key.toLowerCase();
25986
+ if (normalized.startsWith("npm_") || normalized.startsWith("pnpm_") || key === "INIT_CWD" || key === "NODE_PATH") {
25987
+ delete env[key];
25988
+ }
25989
+ }
25990
+ return env;
25991
+ }
25992
+ async function waitForAppUrlUnreachable(url, timeoutMs) {
25993
+ const startedAt = Date.now();
25994
+ while (Date.now() - startedAt < timeoutMs) {
25995
+ if (await probeHttpOnce(url, 750) === "refused") return true;
25996
+ await new Promise((resolve2) => setTimeout(resolve2, 100));
25997
+ }
25998
+ return await probeHttpOnce(url, 750) === "refused";
25999
+ }
26000
+ function describeShellCommandFailure(result) {
26001
+ if (result.aborted) return "the app stop command timed out";
26002
+ if (result.spawnError) {
26003
+ return `the app stop command could not start: ${result.spawnError.message}`;
26004
+ }
26005
+ if (result.exitCode === 0) return null;
26006
+ const detail = result.stderr.trim();
26007
+ return `the app stop command exited with code ${result.exitCode ?? "unknown"}${detail ? `: ${detail}` : ""}`;
26008
+ }
25535
26009
  async function findFreePort(range) {
25536
26010
  const maxAttempts = 50;
25537
26011
  for (let i = 0; i < maxAttempts; i++) {
@@ -26343,7 +26817,7 @@ async function parcelEventToFileEvent(event, rootInfo) {
26343
26817
  // ../tempo-sdk/package.json
26344
26818
  var package_default = {
26345
26819
  name: "tempo-sdk",
26346
- version: "0.0.33",
26820
+ version: "0.0.34",
26347
26821
  type: "module",
26348
26822
  description: "Tempo SDK \u2014 Vite, Next.js, and Expo plugins for JSX annotation and shared page/storyboard types",
26349
26823
  repository: {
@@ -28062,6 +28536,9 @@ var TempoHostProcess = class _TempoHostProcess {
28062
28536
  this.lastHint = null;
28063
28537
  this.hintListeners = [];
28064
28538
  this.explicitTempoHostUrl = options.explicitTempoHostUrl;
28539
+ this.tempoHostUrlSource = options.tempoHostUrlSource;
28540
+ this.injectedPort = options.injectedPort;
28541
+ this.launchSignal = options.launchSignal;
28065
28542
  this.tempoHostUrlIsHardcoded = options.tempoHostUrlIsHardcoded ?? false;
28066
28543
  this.expectedProjectRoot = options.expectedProjectRoot;
28067
28544
  this.expectedIdentity = options.expectedIdentity ?? null;
@@ -28069,6 +28546,7 @@ var TempoHostProcess = class _TempoHostProcess {
28069
28546
  this.missingRootDepsProjectRoot = options.missingRootDepsProjectRoot ?? null;
28070
28547
  this.commandCwd = options.cwd;
28071
28548
  this.stopCommand = options.stopCommand ?? null;
28549
+ this.onLauncherHandoff = options.onLauncherHandoff ?? null;
28072
28550
  this.hasParentDeathWatchdog = options.watchdogScriptPath != null;
28073
28551
  const lifted = extractLeadingEnvAssignments(options.command);
28074
28552
  devLog("process:spawn", {
@@ -28169,10 +28647,11 @@ ${output}` : "";
28169
28647
  this.rejectReady = null;
28170
28648
  }
28171
28649
  });
28172
- this.child.on("exit", (code, signal) => {
28650
+ this.child.on("exit", async (code, signal) => {
28173
28651
  this.stopped = true;
28174
28652
  const cleanLauncherExit = !this.intentionalStop && !this.readinessConfirmed && code === 0 && signal == null;
28175
28653
  if (cleanLauncherExit && this.stopCommand) {
28654
+ await this.onLauncherHandoff?.();
28176
28655
  this.launcherFinished = true;
28177
28656
  this.lastExitCrashed = false;
28178
28657
  devLog("process:launcher_finished", {
@@ -28340,7 +28819,21 @@ Hint: ${this.lastHint.hint}` : "";
28340
28819
  };
28341
28820
  }
28342
28821
  try {
28343
- if (!this.tempoHostUrlIsHardcoded) {
28822
+ if (typeof this.tempoHostUrlSource === "object" && this.tempoHostUrlSource !== null) {
28823
+ if (this.stopCommand) {
28824
+ await this.waitForLauncherHandoff(timeoutMs);
28825
+ }
28826
+ this.explicitTempoHostUrl = await resolveHttpUrlSource({
28827
+ source: this.tempoHostUrlSource,
28828
+ label: "urls.tempoHost",
28829
+ cwd: this.commandCwd,
28830
+ env: this.commandEnv,
28831
+ port: this.injectedPort,
28832
+ timeoutMs: 3e4,
28833
+ signal: this.launchSignal,
28834
+ substitutePort: substituteAppPort
28835
+ });
28836
+ } else if (!this.tempoHostUrlIsHardcoded) {
28344
28837
  await withTimeout(
28345
28838
  this.readySignal,
28346
28839
  timeoutMs,
@@ -28348,6 +28841,7 @@ Hint: ${this.lastHint.hint}` : "";
28348
28841
  );
28349
28842
  }
28350
28843
  } catch (error) {
28844
+ throwIfLaunchAborted(this.launchSignal);
28351
28845
  const contractError = error instanceof DaemonizedStartRequiresStopError;
28352
28846
  const crashed = this.lastExitCrashed;
28353
28847
  return {
@@ -28446,6 +28940,25 @@ Hint: ${this.lastHint.hint}` : "";
28446
28940
  pid: this.child.pid ?? null
28447
28941
  };
28448
28942
  }
28943
+ async waitForLauncherHandoff(timeoutMs) {
28944
+ const deadline = Date.now() + Math.max(timeoutMs, 3e4);
28945
+ while (!this.launcherFinished) {
28946
+ throwIfLaunchAborted(this.launchSignal);
28947
+ if (this.stopped) {
28948
+ await this.readySignal;
28949
+ if (this.launcherFinished) return;
28950
+ throw new Error(
28951
+ "The canvas start command stopped before urls.tempoHost.command could run."
28952
+ );
28953
+ }
28954
+ if (Date.now() >= deadline) {
28955
+ throw new Error(
28956
+ "Timed out waiting for the canvas start command to hand off before running urls.tempoHost.command"
28957
+ );
28958
+ }
28959
+ await waitForLaunchDelay(120, this.launchSignal);
28960
+ }
28961
+ }
28449
28962
  async terminate() {
28450
28963
  this.intentionalStop = true;
28451
28964
  if (this.terminationPromise) return await this.terminationPromise;
@@ -28545,6 +29058,7 @@ function parsePersistedDevserverOwner(value) {
28545
29058
  portWasTempoAllocated: record.portWasTempoAllocated === true,
28546
29059
  launchPid: typeof record.launchPid === "number" && Number.isInteger(record.launchPid) && record.launchPid > 1 ? record.launchPid : null,
28547
29060
  launchProcessStartedAt: typeof record.launchProcessStartedAt === "string" ? record.launchProcessStartedAt : null,
29061
+ launchPort: typeof record.launchPort === "number" && Number.isInteger(record.launchPort) && record.launchPort > 0 && record.launchPort < 65536 ? record.launchPort : null,
28548
29062
  stopCommand: typeof record.stopCommand === "string" && record.stopCommand.length > 0 ? record.stopCommand : null
28549
29063
  };
28550
29064
  }
@@ -28583,8 +29097,11 @@ var TempoDevServer = class _TempoDevServer {
28583
29097
  this.devserverReadyAt = null;
28584
29098
  this.devserverLaunchPid = null;
28585
29099
  this.devserverLaunchProcessStartedAt = null;
29100
+ this.devserverLaunchPort = null;
28586
29101
  this.devserverStopCommand = null;
28587
29102
  this.devserverPortWasTempoAllocated = false;
29103
+ /** Stop authority persisted after handoff but before URL readiness. */
29104
+ this.devserverExternalRuntimePending = false;
28588
29105
  // File watcher on managed files (Section 11.6). On any relevant change/delete
28589
29106
  // a debounced repair() call fires. `@modules/file-watcher` wraps
28590
29107
  // @parcel/watcher with recursive watching, batching, coalescing, and
@@ -28875,6 +29392,15 @@ var TempoDevServer = class _TempoDevServer {
28875
29392
  describePowerShellRejection(startDialectIssue)
28876
29393
  );
28877
29394
  }
29395
+ const tempoHostUrlCommand = this.config.tempoHostUrl !== null && typeof this.config.tempoHostUrl === "object" ? this.config.tempoHostUrl.command : null;
29396
+ const tempoHostUrlDialectIssue = tempoHostUrlCommand ? findPowerShellSyntax(tempoHostUrlCommand) : null;
29397
+ if (tempoHostUrlCommand && tempoHostUrlDialectIssue) {
29398
+ t.done({ ok: false, reason: "tempo_host_url_shell_dialect_mismatch" });
29399
+ throw new StartShellDialectMismatchError(
29400
+ tempoHostUrlCommand,
29401
+ `urls.tempoHost.command contains PowerShell-only syntax (${tempoHostUrlDialectIssue}); use one plain cross-platform command`
29402
+ );
29403
+ }
28878
29404
  this.idleProcessStatus = "starting";
28879
29405
  let process2;
28880
29406
  let missingRootDepsProjectRoot = null;
@@ -28912,6 +29438,7 @@ var TempoDevServer = class _TempoDevServer {
28912
29438
  if (process2 !== activeProcess) {
28913
29439
  return;
28914
29440
  }
29441
+ this.devserverExternalRuntimePending = false;
28915
29442
  this.state.devserverUrl = null;
28916
29443
  this.state.urlUnreachableSince = null;
28917
29444
  this.idleProcessStatus = "exited";
@@ -28937,11 +29464,19 @@ var TempoDevServer = class _TempoDevServer {
28937
29464
  throwIfLaunchAborted(options?.signal);
28938
29465
  const expectedIdentity = await this.readExpectedHostIdentity();
28939
29466
  const stopCommand = this.config.scripts.stop ? substituteAppPort(this.config.scripts.stop, startPlan.injectedPort) : null;
28940
- const activeProcess = new TempoHostProcess({
29467
+ this.devserverStopCommand = stopCommand;
29468
+ this.devserverLaunchPort = startPlan.injectedPort;
29469
+ this.devserverPortWasTempoAllocated = startPlan.portWasTempoAllocated;
29470
+ this.devserverExternalRuntimePending = false;
29471
+ let activeProcess;
29472
+ activeProcess = new TempoHostProcess({
28941
29473
  cwd: this.commandCwd,
28942
29474
  command: startPlan.command,
28943
29475
  explicitTempoHostUrl: startPlan.tempoHostUrl,
28944
- tempoHostUrlIsHardcoded: this.config.tempoHostUrl !== null && !this.config.tempoHostUrl.includes("${PORT}"),
29476
+ tempoHostUrlSource: this.config.tempoHostUrl,
29477
+ injectedPort: startPlan.injectedPort,
29478
+ launchSignal: options?.signal,
29479
+ tempoHostUrlIsHardcoded: typeof this.config.tempoHostUrl === "string" && !this.config.tempoHostUrl.includes("${PORT}"),
28945
29480
  expectedProjectRoot: this.projectRoot,
28946
29481
  expectedIdentity,
28947
29482
  identityFailureMessage: sdkUpgradeResult ? new CanvasHostIdentityError({
@@ -28953,6 +29488,11 @@ var TempoDevServer = class _TempoDevServer {
28953
29488
  }).message : null,
28954
29489
  missingRootDepsProjectRoot,
28955
29490
  stopCommand,
29491
+ onLauncherHandoff: async () => {
29492
+ if (this.activeHostProcess !== activeProcess) return;
29493
+ this.devserverExternalRuntimePending = true;
29494
+ await this.persistStatus();
29495
+ },
28956
29496
  // The tempo-sdk Next plugin applies this documented Next config field.
28957
29497
  // It keeps Tempo's host cache isolated from the user's own `next dev`.
28958
29498
  extraEnv: {
@@ -28968,8 +29508,6 @@ var TempoDevServer = class _TempoDevServer {
28968
29508
  this.activeHostProcess = activeProcess;
28969
29509
  this.devserverLaunchPid = activeProcess.getPid();
28970
29510
  this.devserverLaunchProcessStartedAt = this.devserverLaunchPid ? await readProcessStartedAt(this.devserverLaunchPid) : null;
28971
- this.devserverStopCommand = stopCommand;
28972
- this.devserverPortWasTempoAllocated = startPlan.portWasTempoAllocated;
28973
29511
  attachProcessListeners(activeProcess);
28974
29512
  await this.writeProcessMarker(activeProcess.getPid()).catch(() => {
28975
29513
  });
@@ -29151,6 +29689,7 @@ var TempoDevServer = class _TempoDevServer {
29151
29689
  self.state.devserverUrl = result.url;
29152
29690
  self.state.urlUnreachableSince = null;
29153
29691
  self.devserverReadyAt = Date.now();
29692
+ self.devserverExternalRuntimePending = false;
29154
29693
  const shellPid = process2.getPid();
29155
29694
  if (shellPid) {
29156
29695
  const workerPids = new Set(
@@ -30414,7 +30953,12 @@ var TempoDevServer = class _TempoDevServer {
30414
30953
  // branch `state.devserverUrl` in THIS instance is left untouched: a
30415
30954
  // persisted URL is never resurrected into live state.
30416
30955
  async reconcilePersistedDevserverUrl(staleUrl, staleOwner) {
30417
- if (!staleUrl) return "nothing_to_reconcile";
30956
+ if (!staleUrl) {
30957
+ if (!staleOwner) return "nothing_to_reconcile";
30958
+ if (isPidAlive(staleOwner.managerPid)) return "live_sibling";
30959
+ const markedOurs2 = supervisorAppInstanceId !== null && staleOwner.appInstanceId === supervisorAppInstanceId;
30960
+ return markedOurs2 ? await this.purgeOwnedOrphanDevserver(null, staleOwner) : "foreign_process";
30961
+ }
30418
30962
  if (staleUrl === this.state.devserverUrl) return "current_runtime";
30419
30963
  const markedOurs = staleOwner !== null && supervisorAppInstanceId !== null && staleOwner.appInstanceId === supervisorAppInstanceId;
30420
30964
  if (markedOurs) {
@@ -30455,8 +30999,9 @@ var TempoDevServer = class _TempoDevServer {
30455
30999
  // listener is signalled only when its pid and birth identity match the
30456
31000
  // launch record; a fresh port lookup alone is diagnostic, never proof.
30457
31001
  async purgeOwnedOrphanDevserver(staleUrl, owner) {
30458
- const port = portFromReadyUrl(staleUrl);
31002
+ const port = owner.launchPort ?? (staleUrl ? portFromReadyUrl(staleUrl) : null);
30459
31003
  let stopCommandRan = false;
31004
+ let stopCommandSucceeded = false;
30460
31005
  if (owner.stopCommand) {
30461
31006
  const stopResult = await runBoundedShellCommand({
30462
31007
  command: owner.stopCommand,
@@ -30468,6 +31013,7 @@ var TempoDevServer = class _TempoDevServer {
30468
31013
  timeoutMs: 1e4
30469
31014
  });
30470
31015
  stopCommandRan = true;
31016
+ stopCommandSucceeded = stopResult.ok;
30471
31017
  devLog("supervisor:owned_orphan_stop_command", {
30472
31018
  url: staleUrl,
30473
31019
  command: owner.stopCommand,
@@ -30484,14 +31030,14 @@ var TempoDevServer = class _TempoDevServer {
30484
31030
  if (maySignalListener && listenerPid !== null) {
30485
31031
  await terminateProcessTreeByPid(listenerPid, 3e3, 2e3);
30486
31032
  }
30487
- const finalOutcome = await probeTempoHost(
31033
+ const finalOutcome = staleUrl ? await probeTempoHost(
30488
31034
  staleUrl,
30489
31035
  1500,
30490
31036
  this.projectRoot,
30491
31037
  await this.readExpectedHostIdentity()
30492
- );
31038
+ ) : null;
30493
31039
  const launchIdentityStillAlive = owner.launchPid !== null && owner.launchProcessStartedAt !== null && await readProcessStartedAt(owner.launchPid) === owner.launchProcessStartedAt;
30494
- const reconciled = finalOutcome !== "ok" && !launchIdentityStillAlive;
31040
+ const reconciled = !launchIdentityStillAlive && (staleUrl ? finalOutcome !== "ok" : stopCommandSucceeded);
30495
31041
  devLog("supervisor:owned_orphan_purge_result", {
30496
31042
  url: staleUrl,
30497
31043
  markerPid: owner.managerPid,
@@ -30514,6 +31060,8 @@ var TempoDevServer = class _TempoDevServer {
30514
31060
  };
30515
31061
  if (this.state.devserverUrl) {
30516
31062
  nextStatus.devserverUrl = this.state.devserverUrl;
31063
+ }
31064
+ if (this.state.devserverUrl || this.devserverExternalRuntimePending) {
30517
31065
  nextStatus.devserverOwner = {
30518
31066
  appInstanceId: supervisorAppInstanceId,
30519
31067
  managerPid: process.pid,
@@ -30521,6 +31069,7 @@ var TempoDevServer = class _TempoDevServer {
30521
31069
  portWasTempoAllocated: this.devserverPortWasTempoAllocated,
30522
31070
  launchPid: this.devserverLaunchPid,
30523
31071
  launchProcessStartedAt: this.devserverLaunchProcessStartedAt,
31072
+ launchPort: this.devserverLaunchPort,
30524
31073
  stopCommand: this.devserverStopCommand
30525
31074
  };
30526
31075
  }
@@ -30957,7 +31506,8 @@ async function getAvailableLocalPort() {
30957
31506
  });
30958
31507
  }
30959
31508
  function portFromConfiguredTempoHostUrl(tempoHostUrl) {
30960
- if (!tempoHostUrl || tempoHostUrl.includes("${PORT}")) return null;
31509
+ if (!tempoHostUrl || typeof tempoHostUrl !== "string" || tempoHostUrl.includes("${PORT}"))
31510
+ return null;
30961
31511
  try {
30962
31512
  const parsed = new URL(tempoHostUrl);
30963
31513
  if (parsed.port) return Number(parsed.port);
@@ -30968,10 +31518,10 @@ function portFromConfiguredTempoHostUrl(tempoHostUrl) {
30968
31518
  function buildManagedStartPlan(options) {
30969
31519
  const commandUsesPortToken = options.command.includes("${PORT}");
30970
31520
  const configuredPort = portFromConfiguredTempoHostUrl(options.tempoHostUrl);
30971
- const urlUsesPortToken = options.tempoHostUrl?.includes("${PORT}") ?? false;
31521
+ const urlUsesPortToken = typeof options.tempoHostUrl === "string" && options.tempoHostUrl.includes("${PORT}");
30972
31522
  const recognizedCommand = canInjectDevServerPort(options.command);
30973
31523
  const command = commandUsesPortToken ? substituteAppPort(options.command, options.port) : recognizedCommand ? injectDevServerPort(options.command, options.port, options.framework) : options.command;
30974
- const tempoHostUrl = options.tempoHostUrl ? substituteAppPort(options.tempoHostUrl, options.port) : null;
31524
+ const tempoHostUrl = typeof options.tempoHostUrl === "string" ? substituteAppPort(options.tempoHostUrl, options.port) : null;
30975
31525
  const shouldExportGenericPort = commandUsesPortToken || urlUsesPortToken || recognizedCommand || options.tempoHostUrl !== null;
30976
31526
  return {
30977
31527
  command,
@@ -32319,13 +32869,7 @@ function validateAppStart(value, label = "scripts.appStart") {
32319
32869
  const trimmed2 = value.trim();
32320
32870
  return trimmed2;
32321
32871
  }
32322
- function validateAppUrl(value, label) {
32323
- if (value === void 0 || value === null) return void 0;
32324
- if (typeof value !== "string" || value.trim().length === 0) {
32325
- throw new Error(
32326
- `tempo.config.json field "${label}" must be a non-empty string when set`
32327
- );
32328
- }
32872
+ function validateHttpUrlString(value, label) {
32329
32873
  const trimmed2 = value.trim();
32330
32874
  let parsed;
32331
32875
  try {
@@ -32340,6 +32884,29 @@ function validateAppUrl(value, label) {
32340
32884
  }
32341
32885
  return trimmed2;
32342
32886
  }
32887
+ function validateHttpUrlSource(value, label) {
32888
+ if (value === void 0 || value === null) return void 0;
32889
+ if (typeof value === "string") {
32890
+ if (value.trim().length === 0) {
32891
+ throw new Error(
32892
+ `tempo.config.json field "${label}" must be a non-empty string when set`
32893
+ );
32894
+ }
32895
+ return validateHttpUrlString(value, label);
32896
+ }
32897
+ if (!isObjectRecord(value)) {
32898
+ throw new Error(
32899
+ `tempo.config.json field "${label}" must be an HTTP(S) URL string or an object with a non-empty "command"`
32900
+ );
32901
+ }
32902
+ const keys = Object.keys(value);
32903
+ if (keys.length !== 1 || keys[0] !== "command" || typeof value.command !== "string" || value.command.trim().length === 0) {
32904
+ throw new Error(
32905
+ `tempo.config.json field "${label}" command source must be exactly { "command": "<non-empty command>" }`
32906
+ );
32907
+ }
32908
+ return { command: value.command.trim() };
32909
+ }
32343
32910
  function validateScriptsStop(value) {
32344
32911
  if (value === void 0 || value === null) return void 0;
32345
32912
  if (typeof value !== "string" || value.trim().length === 0) {
@@ -32356,20 +32923,11 @@ function validateUrls(value) {
32356
32923
  }
32357
32924
  const raw = value;
32358
32925
  if (raw.tempoHost !== void 0) {
32359
- if (typeof raw.tempoHost !== "string" || raw.tempoHost.trim().length === 0) {
32360
- throw new Error(
32361
- 'tempo.config.json field "urls.tempoHost" must be a non-empty string'
32362
- );
32363
- }
32364
- const tempoHost = raw.tempoHost.trim();
32365
- try {
32366
- new URL(tempoHost.replace(/\$\{PORT\}/g, "5173"));
32367
- } catch {
32368
- throw new Error(
32369
- 'tempo.config.json field "urls.tempoHost" must be a valid URL'
32370
- );
32371
- }
32372
- return { tempoHost };
32926
+ const tempoHost = validateHttpUrlSource(
32927
+ raw.tempoHost,
32928
+ "urls.tempoHost"
32929
+ );
32930
+ return tempoHost === void 0 ? void 0 : { tempoHost };
32373
32931
  }
32374
32932
  return void 0;
32375
32933
  }
@@ -32444,8 +33002,9 @@ function validateApps(value) {
32444
33002
  }
32445
33003
  seenAppDirs.add(appDir);
32446
33004
  }
32447
- const url = validateAppUrl(raw.url, `apps[${i}].url`);
33005
+ const url = validateHttpUrlSource(raw.url, `apps[${i}].url`);
32448
33006
  const start = validateAppStart(raw.start, `apps[${i}].start`);
33007
+ const stop = validateAppStart(raw.stop, `apps[${i}].stop`);
32449
33008
  const legacyAppStart = validateAppStart(
32450
33009
  raw.appStart,
32451
33010
  `apps[${i}].appStart`
@@ -32461,18 +33020,24 @@ function validateApps(value) {
32461
33020
  `tempo.config.json field "apps[${i}].url" requires "apps[${i}].start"`
32462
33021
  );
32463
33022
  }
33023
+ if (stop !== void 0 && effectiveStart === void 0) {
33024
+ throw new Error(
33025
+ `tempo.config.json field "apps[${i}].stop" requires "apps[${i}].start"`
33026
+ );
33027
+ }
32464
33028
  const uiFramework = validateUiFramework(
32465
33029
  raw.uiFramework,
32466
33030
  `apps[${i}].uiFramework`
32467
33031
  );
32468
- if (appDir === void 0 && effectiveStart === void 0 && url === void 0 && uiFramework === void 0) {
33032
+ if (appDir === void 0 && effectiveStart === void 0 && stop === void 0 && url === void 0 && uiFramework === void 0) {
32469
33033
  throw new Error(
32470
- `tempo.config.json field "apps[${i}]" must set "appDir", "start", "url", and/or "uiFramework" \u2014 an empty entry encodes nothing`
33034
+ `tempo.config.json field "apps[${i}]" must set "appDir", "start", "stop", "url", and/or "uiFramework" \u2014 an empty entry encodes nothing`
32471
33035
  );
32472
33036
  }
32473
33037
  return {
32474
33038
  ...appDir !== void 0 ? { appDir } : {},
32475
33039
  ...effectiveStart !== void 0 ? { start: effectiveStart } : {},
33040
+ ...stop !== void 0 ? { stop } : {},
32476
33041
  ...url !== void 0 ? { url } : {},
32477
33042
  ...uiFramework !== void 0 ? { uiFramework } : {}
32478
33043
  };
@@ -32761,7 +33326,12 @@ async function writeTempoConfigFile(configDir, config) {
32761
33326
  ...normalized.scripts.stop ? { stop: normalized.scripts.stop } : {}
32762
33327
  };
32763
33328
  if (normalized.apps) {
32764
- rendered.apps = normalized.apps;
33329
+ rendered.apps = normalized.apps.map(
33330
+ ({ appStart: _legacyAppStart, ...app }) => {
33331
+ void _legacyAppStart;
33332
+ return app;
33333
+ }
33334
+ );
32765
33335
  }
32766
33336
  if (normalized.setupScript) {
32767
33337
  rendered.setupScript = normalized.setupScript;
@@ -32847,6 +33417,7 @@ async function resolveTempoConfig(projectRoot) {
32847
33417
  apps = config.apps.map((app) => ({
32848
33418
  appDir: app.appDir,
32849
33419
  start: app.start ?? null,
33420
+ stop: app.stop ?? null,
32850
33421
  url: app.url ?? null,
32851
33422
  appStart: app.start ?? null,
32852
33423
  ...app.uiFramework ? { uiFramework: app.uiFramework } : {}
@@ -32857,6 +33428,7 @@ async function resolveTempoConfig(projectRoot) {
32857
33428
  {
32858
33429
  ...config.appDir !== void 0 ? { appDir: config.appDir } : {},
32859
33430
  start: legacyStart,
33431
+ stop: null,
32860
33432
  url: null,
32861
33433
  appStart: legacyStart
32862
33434
  }
@@ -32880,7 +33452,8 @@ async function resolveTempoConfig(projectRoot) {
32880
33452
  install: config.scripts.install,
32881
33453
  start: config.scripts.start,
32882
33454
  stop: config.scripts.stop ?? null,
32883
- appStart: activeApp.appStart
33455
+ appStart: activeApp.appStart,
33456
+ appStop: activeApp.stop
32884
33457
  },
32885
33458
  apps
32886
33459
  };
@@ -41961,7 +42534,7 @@ async function analyzeProject(projectPath) {
41961
42534
 
41962
42535
  // ../tempo-project/tempo-project.ts
41963
42536
  function configsEqual(left, right) {
41964
- return left.projectRoot === right.projectRoot && left.tempoConfigPath === right.tempoConfigPath && left.rootRel === right.rootRel && left.tempoRoot === right.tempoRoot && left.pagesDir === right.pagesDir && left.packageJsonPath === right.packageJsonPath && left.viteConfigPath === right.viteConfigPath && left.tsconfigPath === right.tsconfigPath && left.tempoHostUrl === right.tempoHostUrl && left.uiFramework === right.uiFramework && left.scripts.install === right.scripts.install && left.scripts.start === right.scripts.start && left.scripts.stop === right.scripts.stop;
42537
+ return left.projectRoot === right.projectRoot && left.tempoConfigPath === right.tempoConfigPath && left.rootRel === right.rootRel && left.tempoRoot === right.tempoRoot && left.pagesDir === right.pagesDir && left.packageJsonPath === right.packageJsonPath && left.viteConfigPath === right.viteConfigPath && left.tsconfigPath === right.tsconfigPath && JSON.stringify(left.tempoHostUrl) === JSON.stringify(right.tempoHostUrl) && left.uiFramework === right.uiFramework && left.scripts.install === right.scripts.install && left.scripts.start === right.scripts.start && left.scripts.stop === right.scripts.stop;
41965
42538
  }
41966
42539
  function usesTempoManagedSidecarPackage(config, framework) {
41967
42540
  if (!looksLikeGeneratedInstallScript(config.scripts.install)) return false;
@@ -44474,28 +45047,33 @@ function registerTools(server, context) {
44474
45047
  );
44475
45048
  server.tool(
44476
45049
  "set_app_dev_command",
44477
- "Configure the user's REAL app dev server in tempo.config.json, stored canonically as `apps[0].start` plus optional `apps[0].url`. Legacy `apps[0].appStart` and `scripts.appStart` still read and migrate to `start` on write. Required for route storyboards. `start` may be any foreground command. `url`, when set, is only the HTTP(S) readiness target and route base: it is probed as written, never controls local allocation, and may point through a container, tunnel, or remote machine. Tempo exposes its independently allocated helper port through `${APP_PORT}` and still accepts legacy `${PORT}` in app start/URL fields. Invoke commands directly, keep them cross-platform, and use appDir for subdirectory apps. At least one of command/url must be provided; null clears that field. Idempotent.",
45050
+ "Configure the user's REAL app dev server in tempo.config.json as `apps[0].start` plus optional `stop` and `url`. `url` is either an HTTP(S) string or `{ command }`; a URL command runs only during launch and must exit 0 with exactly one HTTP(S) URL on stdout. A clean early `start` exit is accepted only with `stop`, after which URL probes own liveness and `stop` owns teardown. Tempo exposes `${APP_PORT}` (legacy `${PORT}` also works) to every command. Legacy appStart spellings still read and migrate. At least one field must be provided; null clears it. Idempotent.",
44478
45051
  {
44479
45052
  command: z23.string().nullish().describe(
44480
45053
  "The canonical apps[0].start foreground command. It may be arbitrary; use `${APP_PORT}` when the command should consume Tempo's allocated helper port. Legacy `${PORT}` remains supported. Omit to leave unchanged; pass null/empty to clear the app server configuration."
44481
45054
  ),
44482
- url: z23.string().nullish().describe(
44483
- "Optional canonical apps[0].url. This is the HTTP(S) readiness target and route base only. It may contain `${APP_PORT}` (or legacy `${PORT}`) or be any literal reachable HTTP(S) URL, including a container, tunnel, or remote host; it never controls local port allocation. Omit to leave unchanged; pass null/empty to clear only this field."
45055
+ url: z23.union([z23.string(), z23.object({ command: z23.string().min(1) }).strict()]).nullish().describe(
45056
+ 'Optional canonical apps[0].url: either a reachable HTTP(S) URL string or `{ command: "..." }`. The command runs once per launch from appDir (after start exits in the stop-backed handoff shape), receives APP_PORT/PORT/TEMPO_APP_PORT, and must print exactly one HTTP(S) URL. Omit to leave unchanged; pass null/empty to clear.'
45057
+ ),
45058
+ stop: z23.string().nullish().describe(
45059
+ "Optional canonical apps[0].stop teardown command. Required when start exits successfully while the app remains reachable externally. Runs on normal stop and stale-runtime reconciliation. Omit to leave unchanged; pass null/empty to clear."
44484
45060
  ),
44485
45061
  ...appDevServerTargetArgs
44486
45062
  },
44487
- async ({ command, url, repo_root, app_dir }) => {
45063
+ async ({ command, stop, url, repo_root, app_dir }) => {
44488
45064
  if (!context.appDevServer)
44489
45065
  return unsupportedEnvironmentResult("appDevServer");
44490
- if (command === void 0 && url === void 0) {
44491
- return errorResult9("Provide command and/or url to update.");
45066
+ if (command === void 0 && stop === void 0 && url === void 0) {
45067
+ return errorResult9("Provide command, stop, and/or url to update.");
44492
45068
  }
44493
45069
  const startValue = command === void 0 ? void 0 : typeof command === "string" && command.trim().length > 0 ? command.trim() : null;
44494
- const urlValue = url === void 0 ? void 0 : typeof url === "string" && url.trim().length > 0 ? url.trim() : null;
45070
+ const urlValue = url === void 0 ? void 0 : typeof url === "string" ? url.trim().length > 0 ? url.trim() : null : url === null ? null : { command: url.command.trim() };
45071
+ const stopValue = stop === void 0 ? void 0 : typeof stop === "string" && stop.trim().length > 0 ? stop.trim() : null;
44495
45072
  try {
44496
45073
  await context.appDevServer.setConfig(
44497
45074
  {
44498
45075
  ...startValue !== void 0 ? { start: startValue } : {},
45076
+ ...stopValue !== void 0 ? { stop: stopValue } : {},
44499
45077
  ...urlValue !== void 0 ? { url: urlValue } : {}
44500
45078
  },
44501
45079
  toAppDevServerTarget({ repo_root, app_dir })
@@ -44507,11 +45085,13 @@ function registerTools(server, context) {
44507
45085
  // tool output instead of only the tool arguments.
44508
45086
  ...startValue !== void 0 ? { app_dev_command: startValue } : {},
44509
45087
  ...urlValue !== void 0 ? { app_dev_url: urlValue } : {},
45088
+ ...stopValue !== void 0 ? { app_dev_stop: stopValue } : {},
44510
45089
  app_dev_config_update: {
44511
45090
  ...startValue !== void 0 ? { start: startValue } : {},
45091
+ ...stopValue !== void 0 ? { stop: stopValue } : {},
44512
45092
  ...urlValue !== void 0 ? { url: urlValue } : {}
44513
45093
  },
44514
- note: startValue === null ? "Cleared." : "Saved. The supervisor allocates the optional local `${APP_PORT}` helper independently (legacy `${PORT}` remains supported); a configured URL is probed exactly as the readiness target and route base. Before declaring setup complete, verify the app depends on tempo-sdk and its own bundler config carries Tempo instrumentation. For standalone Svelte/Vite, add @sveltejs/vite-plugin-svelte only if missing, preserve compatible configured versions, and use tempoVitePlugin() plus tempoSveltePreprocess(...) first in the app's preprocess chain; tempo/ only configures the sidecar."
45094
+ note: startValue === null ? "Cleared." : "Saved. The supervisor allocates `${APP_PORT}` independently; static or command-resolved URLs are the readiness target and route base. If start hands off to an external server, stop is required and URL probes become liveness authority. Before declaring setup complete, verify the app depends on tempo-sdk and its own bundler config carries Tempo instrumentation. For standalone Svelte/Vite, add @sveltejs/vite-plugin-svelte only if missing, preserve compatible configured versions, and use tempoVitePlugin() plus tempoSveltePreprocess(...) first in the app's preprocess chain; tempo/ only configures the sidecar."
44515
45095
  },
44516
45096
  null,
44517
45097
  2
@@ -44526,7 +45106,7 @@ function registerTools(server, context) {
44526
45106
  );
44527
45107
  server.tool(
44528
45108
  "check_app_dev_server",
44529
- "Check the per-workspace app dev server configured by canonical `apps[0].start`/`url` (legacy appStart spellings also read). Returns `{configured, running, phase, port, url, error}`. Use this when a route storyboard isn't rendering. `configured: false` means start is unset; `phase: 'error'` carries the supervisor failure.",
45109
+ "Check the per-workspace app dev server configured by canonical `apps[0].start`/`stop`/`url` (legacy appStart spellings also read). Returns `{configured, running, phase, port, url, livenessSource, error}`; liveness is `child` for foreground servers and `probe` after external handoff. Use this when a route storyboard isn't rendering. `configured: false` means start is unset; `phase: 'error'` carries the supervisor failure.",
44530
45110
  { ...appDevServerTargetArgs },
44531
45111
  async ({ repo_root, app_dir }) => {
44532
45112
  if (!context.appDevServer)
@@ -44545,7 +45125,7 @@ function registerTools(server, context) {
44545
45125
  );
44546
45126
  server.tool(
44547
45127
  "start_app_dev_server",
44548
- "Start the per-workspace app dev server if it isn't already running. Spawns canonical `apps[0].start`, substitutes `${APP_PORT}` (or legacy `${PORT}`) in start/url, persists the independently selected local helper port under `<workspaceRoot>/.tempo/app-devserver.json`, and HTTP-gates readiness against the configured URL or detected local URL. A literal URL is probed as written and does not affect local allocation. No-op if already running. When start is unset, returns `{configured: false}`; call `set_app_dev_command` first.",
45128
+ "Start the per-workspace app dev server if it isn't already running. Spawns canonical `apps[0].start`, substitutes `${APP_PORT}` (or legacy `${PORT}`) in start/stop/url commands, persists the independently selected local helper port under `<workspaceRoot>/.tempo/app-devserver.json`, resolves `url.command` once when configured, and HTTP-gates readiness against the resolved/static URL or detected local URL. A clean early start exit requires `apps[0].stop`; then URL probes own liveness and stop owns teardown. A literal URL is probed as written and does not affect local allocation. No-op if already running. When start is unset, returns `{configured: false}`; call `set_app_dev_command` first.",
44549
45129
  { ...appDevServerTargetArgs },
44550
45130
  async ({ repo_root, app_dir }) => {
44551
45131
  if (!context.appDevServer)
@@ -44762,7 +45342,7 @@ function registerTools(server, context) {
44762
45342
  // unpushed/dirty branch before any capture), captures a whole-canvas
44763
45343
  // composite screenshot + each storyboard's HTML, saves them as the link's
44764
45344
  // preview snapshot, then mints the share URL.
44765
- "Create a shareable link to a canvas and get back a screenshot of the whole canvas. **The canvas MUST be fully committed and pushed to a remote (clean git state) before sharing** \u2014 exactly like link_items \u2014 otherwise this returns a 'branch_not_pushed' error and you must commit + push the canvas's branch first so the recipient can access the code. It captures a composite screenshot of every storyboard, saves it as the link's preview, then mints the link. Returns the URL plus the whole-canvas screenshot (auto-displayed inline). By default the link is member-only (the recipient opens it as an org member); pass public: true to mint an anyone-with-the-link token instead. Args: canvas_slug (folder name, or root-relative path when the name isn't unique), public (optional, default false).",
45345
+ "Create a shareable link to a canvas and get back a screenshot of the whole canvas. **The canvas MUST be fully committed and pushed to a remote (clean git state) before sharing** \u2014 exactly like link_items \u2014 otherwise this returns a 'branch_not_pushed' error and you must commit + push the canvas's branch first so the recipient can access the code. Cache-first: when a published preview already exists for the branch's exact pushed commit, the link returns immediately with no re-capture; otherwise it captures a composite screenshot of every storyboard, publishes it as the branch's preview, then mints the link. Share links are BRANCH-LIVE: an existing link (and its public token) keeps its URL and automatically shows the newest published preview each time the branch's pushed canvas is re-captured \u2014 no need to re-share after pushing new commits. Returns the URL plus the whole-canvas screenshot (auto-displayed inline). By default the link is member-only (the recipient opens it as an org member); pass public: true to mint an anyone-with-the-link token instead. Args: canvas_slug (folder name, or root-relative path when the name isn't unique), public (optional, default false).",
44766
45346
  {
44767
45347
  canvas_slug: z23.string().describe(
44768
45348
  "Slug of the canvas \u2014 its folder name, or its path under the canvases root (`design-system/web-design-system/actions`). Pass the PATH whenever the folder name may not be unique; a bare name that matches several canvases is rejected with ambiguous_canvas_slug rather than resolved by guess"
@@ -45222,7 +45802,7 @@ function isExternallyExposed(toolName, opts) {
45222
45802
  }
45223
45803
 
45224
45804
  // src/version.ts
45225
- var CLI_VERSION = "0.0.103";
45805
+ var CLI_VERSION = "0.0.104";
45226
45806
 
45227
45807
  // src/canvas-hooks.ts
45228
45808
  var import_pngjs3 = __toESM(require_png(), 1);
@@ -47196,4 +47776,4 @@ export {
47196
47776
  runServe,
47197
47777
  scopeParamsFor
47198
47778
  };
47199
- //# sourceMappingURL=serve-P7M4UFJS.js.map
47779
+ //# sourceMappingURL=serve-FWQ5ZBR7.js.map