@tempo-ai/mcp 0.0.106 → 0.0.107

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.
@@ -5907,14 +5907,15 @@ var TOOL_MANIFEST = [
5907
5907
  "docs_move",
5908
5908
  "docs_delete"
5909
5909
  ]),
5910
- // ── comments (16) ───────────────────────────────────────────────────────
5910
+ // ── comments (17) ───────────────────────────────────────────────────────
5911
5911
  ...read("comments", [
5912
5912
  "comments_list_for_canvas",
5913
5913
  "comments_get_thread",
5914
5914
  "comments_list_unread_notifications",
5915
5915
  "comments_list_activity_for_canvas",
5916
5916
  "comments_list_threads_in_org",
5917
- "comments_search"
5917
+ "comments_search",
5918
+ "comments_get_pr_visual_review"
5918
5919
  ]),
5919
5920
  ...write("comments", [
5920
5921
  "comments_create_thread",
@@ -10752,9 +10753,169 @@ function refineByMessage(message, details) {
10752
10753
  return { code: "internal", message, details };
10753
10754
  }
10754
10755
 
10756
+ // ../canvas-comments-mcp/tools/pr-visual-review.ts
10757
+ var VISUAL_REVIEW_STATUS_CONTEXT = "visual-review";
10758
+ var VISUAL_REVIEW_STICKY_MARKER = "<!-- tempo-visual-review -->";
10759
+ var INTENTIONAL_NO_REPORT = /bypass|not applicable|no visual change/i;
10760
+ var REPORT_KEY = /^[A-Za-z0-9_-]{16,128}$/;
10761
+ var REPORT_KEY_IN_TEXT = /[?&]k=([A-Za-z0-9_-]{16,128})/;
10762
+ var GitHubApiError = class extends Error {
10763
+ constructor(message, options = {}) {
10764
+ super(message);
10765
+ this.name = "GitHubApiError";
10766
+ this.status = options.status;
10767
+ this.unavailable = options.unavailable ?? false;
10768
+ }
10769
+ };
10770
+ function normalizeGitHubRepo(value) {
10771
+ const trimmed2 = value.trim();
10772
+ if (!trimmed2) return null;
10773
+ const candidates = [trimmed2];
10774
+ if (/^github\.com\//i.test(trimmed2)) {
10775
+ candidates.push(`https://${trimmed2}`);
10776
+ } else if (/^[^/:@\s]+\/[^/\s]+$/.test(trimmed2)) {
10777
+ candidates.push(`https://github.com/${trimmed2}`);
10778
+ }
10779
+ for (const candidate of candidates) {
10780
+ const normalized = normalizeRepoUrl(candidate);
10781
+ if (normalized && /^github\.com\/[^/]+\/[^/]+$/.test(normalized)) {
10782
+ return normalized;
10783
+ }
10784
+ }
10785
+ return null;
10786
+ }
10787
+ function parseVisualReviewPrInput(pr, repository) {
10788
+ let prNumber = null;
10789
+ let repo = repository ?? null;
10790
+ if (typeof pr === "number") {
10791
+ prNumber = Number.isInteger(pr) && pr > 0 ? pr : null;
10792
+ } else {
10793
+ const trimmed2 = pr.trim();
10794
+ const numeric = trimmed2.match(/^#?(\d+)$/);
10795
+ if (numeric) {
10796
+ prNumber = Number(numeric[1]);
10797
+ } else {
10798
+ try {
10799
+ const url = new URL(trimmed2);
10800
+ const match = url.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
10801
+ if (url.hostname.toLowerCase() !== "github.com" || !match) return null;
10802
+ repo = `${match[1]}/${match[2]}`;
10803
+ prNumber = Number(match[3]);
10804
+ } catch {
10805
+ return null;
10806
+ }
10807
+ }
10808
+ }
10809
+ const repoKey = repo ? normalizeGitHubRepo(repo) : null;
10810
+ return prNumber && repoKey ? { prNumber, repoKey } : null;
10811
+ }
10812
+ function githubRepoFromKey(repoKey) {
10813
+ const match = /^github\.com\/([^/]+)\/([^/]+)$/.exec(repoKey);
10814
+ return match ? `${match[1]}/${match[2]}` : null;
10815
+ }
10816
+ function parseReportKey(value) {
10817
+ const trimmed2 = value.trim();
10818
+ if (!trimmed2) return null;
10819
+ if (REPORT_KEY.test(trimmed2)) return trimmed2;
10820
+ try {
10821
+ const key = new URL(trimmed2).searchParams.get("k");
10822
+ return key && REPORT_KEY.test(key) ? key : null;
10823
+ } catch {
10824
+ return null;
10825
+ }
10826
+ }
10827
+ function reportKeyInText(text) {
10828
+ const match = text ? REPORT_KEY_IN_TEXT.exec(text) : null;
10829
+ return match ? match[1] : null;
10830
+ }
10831
+ async function resolvePrVisualReviewViaGitHub(githubApi, repository, prNumber) {
10832
+ const pull = await githubApi(`repos/${repository}/pulls/${prNumber}`);
10833
+ const headSha = pull.head?.sha;
10834
+ const pullRequest = {
10835
+ repository,
10836
+ number: prNumber,
10837
+ url: pull.html_url,
10838
+ title: pull.title,
10839
+ state: pull.state,
10840
+ draft: pull.draft,
10841
+ headSha
10842
+ };
10843
+ if (!headSha) {
10844
+ return {
10845
+ pullRequest,
10846
+ status: null,
10847
+ reportKey: null,
10848
+ reportKeySource: null,
10849
+ intentionalNoReport: false
10850
+ };
10851
+ }
10852
+ const combined = await githubApi(
10853
+ `repos/${repository}/commits/${headSha}/status`
10854
+ );
10855
+ const raw = (combined.statuses ?? []).find(
10856
+ (candidate) => candidate.context === VISUAL_REVIEW_STATUS_CONTEXT
10857
+ );
10858
+ const status = raw?.state ? {
10859
+ state: raw.state,
10860
+ description: raw.description ?? void 0,
10861
+ targetUrl: raw.target_url ?? void 0,
10862
+ updatedAt: raw.updated_at
10863
+ } : null;
10864
+ const intentionalNoReport = status?.state === "success" && INTENTIONAL_NO_REPORT.test(status.description ?? "");
10865
+ let reportKey = reportKeyInText(status?.targetUrl);
10866
+ let reportKeySource = reportKey ? "head-status" : null;
10867
+ if (!reportKey && !intentionalNoReport) {
10868
+ for (let page = 1; ; page += 1) {
10869
+ const pageSuffix = page === 1 ? "" : `&page=${page}`;
10870
+ const comments = await githubApi(
10871
+ `repos/${repository}/issues/${prNumber}/comments?per_page=100${pageSuffix}`
10872
+ );
10873
+ const sticky = comments.find(
10874
+ (comment) => typeof comment.body === "string" && comment.body.includes(VISUAL_REVIEW_STICKY_MARKER)
10875
+ );
10876
+ const key = reportKeyInText(sticky?.body);
10877
+ if (key) {
10878
+ reportKey = key;
10879
+ reportKeySource = "sticky-comment";
10880
+ break;
10881
+ }
10882
+ if (comments.length < 100) break;
10883
+ }
10884
+ }
10885
+ return { pullRequest, status, reportKey, reportKeySource, intentionalNoReport };
10886
+ }
10887
+
10755
10888
  // ../canvas-comments-mcp/tools/read.ts
10889
+ function mapGitHubApiError(error) {
10890
+ if (error.unavailable) {
10891
+ return {
10892
+ code: "github_unavailable",
10893
+ message: `${error.message}. Pass \`reportUrl\` \u2014 the PR's \`visual-review\` check link (\u2026?k=\u2026) \u2014 instead.`
10894
+ };
10895
+ }
10896
+ if (error.status === 404) {
10897
+ return {
10898
+ code: "not_found",
10899
+ message: `GitHub returned 404 for that pull request: it does not exist, or your GitHub account cannot see it (${error.message}).`
10900
+ };
10901
+ }
10902
+ if (error.status === 401 || error.status === 403) {
10903
+ return {
10904
+ code: "forbidden",
10905
+ message: `GitHub refused the read as your account (${error.message}).`
10906
+ };
10907
+ }
10908
+ return { code: "github_error", message: error.message };
10909
+ }
10910
+ var EMPTY_COMMENTS = {
10911
+ openThreads: 0,
10912
+ resolvedThreads: 0,
10913
+ returnedThreads: 0,
10914
+ truncated: false,
10915
+ threads: []
10916
+ };
10756
10917
  function registerReadTools2(server, deps) {
10757
- const { convex, resolveCanvasContentPath } = deps;
10918
+ const { convex, resolveCanvasContentPath, getRepoUrl, githubApi } = deps;
10758
10919
  const canvasKey = async (contentPath) => {
10759
10920
  const resolved = resolveCanvasContentPath ? await resolveCanvasContentPath(contentPath) : contentPath;
10760
10921
  return normalizeCanvasContentPath(resolved);
@@ -10897,6 +11058,198 @@ function registerReadTools2(server, deps) {
10897
11058
  }
10898
11059
  }
10899
11060
  );
11061
+ server.tool(
11062
+ "comments_get_pr_visual_review",
11063
+ "Get a GitHub PR's visual review in one read-only call: the current `visual-review` commit status, the Tempo VRT report gate, actionable per-storyboard verdicts/approvals, and the canvas review comment threads. Pass `pr` (a GitHub PR URL is preferred; a PR number / '#123' needs `repository` unless the local Tempo workspace has a GitHub remote) \u2014 the PR's head status and report link are read with YOUR GitHub credentials through the `gh` CLI, so only PRs you can see on GitHub resolve. Without a usable `gh` (hosted / CLI sessions) pass `reportUrl` instead: the PR's `visual-review` check link (\u2026?k=\u2026). GitHub's head status is authoritative (bypass / no-visual-change PRs report correctly); while the current head is still capturing, the newest completed run is returned with report.stale=true so review threads stay visible. Only open threads are returned by default.",
11064
+ {
11065
+ pr: z10.union([z10.number().int().positive(), z10.string().min(1)]).optional().describe(
11066
+ "GitHub PR URL (recommended), or a positive PR number / '#123' (then `repository` or a workspace GitHub remote is required)."
11067
+ ),
11068
+ repository: z10.string().optional().describe(
11069
+ "GitHub repository as owner/repo, an HTTPS/SSH remote, or a Tempo repoKey (github.com/owner/repo). Only needed with a bare PR number."
11070
+ ),
11071
+ reportUrl: z10.string().optional().describe(
11072
+ "The PR's `visual-review` check link / Tempo report URL (\u2026?k=\u2026), or the bare report key. Required when no GitHub CLI is available; may be combined with `pr` to confirm freshness against the current head."
11073
+ ),
11074
+ includeResolved: z10.boolean().default(false).describe("Include resolved review threads; defaults to false."),
11075
+ maxThreads: z10.number().int().min(1).max(100).default(50).describe("Maximum newest matching threads to return (default 50).")
11076
+ },
11077
+ async (args, ctx) => {
11078
+ try {
11079
+ const includeResolved = args.includeResolved ?? false;
11080
+ const maxThreads = Math.min(args.maxThreads ?? 50, 100);
11081
+ const warnings = [];
11082
+ let reportKey = null;
11083
+ if (args.reportUrl !== void 0) {
11084
+ reportKey = parseReportKey(args.reportUrl);
11085
+ if (!reportKey) {
11086
+ return errorResult3({
11087
+ code: "validation_error",
11088
+ message: "`reportUrl` must be a Tempo visual-review link carrying `?k=<key>` (the PR's `visual-review` check link) or a bare report key."
11089
+ });
11090
+ }
11091
+ }
11092
+ let parsedPr = null;
11093
+ if (args.pr !== void 0) {
11094
+ let repository = args.repository;
11095
+ if (!repository && getRepoUrl) {
11096
+ try {
11097
+ repository = await getRepoUrl();
11098
+ } catch {
11099
+ repository = null;
11100
+ }
11101
+ }
11102
+ parsedPr = parseVisualReviewPrInput(args.pr, repository);
11103
+ if (!parsedPr) {
11104
+ return errorResult3({
11105
+ code: "validation_error",
11106
+ message: "Pass a GitHub PR URL, or a PR number with `repository` (owner/repo, an HTTPS/SSH remote, or github.com/owner/repo)."
11107
+ });
11108
+ }
11109
+ }
11110
+ if (!reportKey && !parsedPr) {
11111
+ return errorResult3({
11112
+ code: "validation_error",
11113
+ message: "Pass `pr` (a GitHub PR URL) or `reportUrl` (the PR's `visual-review` check link)."
11114
+ });
11115
+ }
11116
+ let github = null;
11117
+ if (parsedPr) {
11118
+ const repository = githubRepoFromKey(parsedPr.repoKey);
11119
+ if (!repository) {
11120
+ return errorResult3({
11121
+ code: "validation_error",
11122
+ message: "Only GitHub repositories are supported."
11123
+ });
11124
+ }
11125
+ if (githubApi) {
11126
+ try {
11127
+ github = await resolvePrVisualReviewViaGitHub(
11128
+ githubApi,
11129
+ repository,
11130
+ parsedPr.prNumber
11131
+ );
11132
+ } catch (error) {
11133
+ if (!(error instanceof GitHubApiError)) throw error;
11134
+ if (!reportKey) return errorResult3(mapGitHubApiError(error));
11135
+ warnings.push(
11136
+ `GitHub lookup failed (${error.message}); freshness against the PR's current head is unknown.`
11137
+ );
11138
+ }
11139
+ if (github && !reportKey) reportKey = github.reportKey;
11140
+ } else if (!reportKey) {
11141
+ return errorResult3({
11142
+ code: "github_unavailable",
11143
+ message: "Resolving a PR needs an authenticated GitHub CLI (`gh`) on this machine, which this session does not have. Pass `reportUrl` \u2014 the PR's `visual-review` check link (\u2026?k=\u2026) \u2014 instead."
11144
+ });
11145
+ }
11146
+ }
11147
+ let detail = reportKey ? await convex.action(api3.visualReviewGithub.getPrReviewForMcp, {
11148
+ // biome-ignore lint/suspicious/noExplicitAny: cross-package id casting
11149
+ organizationId: ctx.orgId,
11150
+ reportKey,
11151
+ includeResolved,
11152
+ maxThreads
11153
+ }) : null;
11154
+ if (reportKey && !detail) {
11155
+ if (!github) {
11156
+ return errorResult3({
11157
+ code: "not_found",
11158
+ message: "No Tempo visual-review report exists for that key."
11159
+ });
11160
+ }
11161
+ warnings.push(
11162
+ `GitHub links report key ${reportKey}, but Tempo has no such report (it may have been swept).`
11163
+ );
11164
+ }
11165
+ if (detail && parsedPr && (detail.report.run.repoKey !== parsedPr.repoKey || detail.report.run.prNumber !== parsedPr.prNumber)) {
11166
+ warnings.push(
11167
+ `The resolved report belongs to ${detail.report.run.repoKey} PR #${detail.report.run.prNumber ?? "?"}, not the requested PR; it was ignored.`
11168
+ );
11169
+ detail = null;
11170
+ }
11171
+ if (detail && !github && githubApi && detail.report.run.prNumber !== void 0) {
11172
+ const repository = githubRepoFromKey(detail.report.run.repoKey);
11173
+ if (repository) {
11174
+ try {
11175
+ github = await resolvePrVisualReviewViaGitHub(
11176
+ githubApi,
11177
+ repository,
11178
+ detail.report.run.prNumber
11179
+ );
11180
+ if (github.reportKey && github.reportKey !== reportKey) {
11181
+ warnings.push(
11182
+ `GitHub currently links a different report for this PR (key ${github.reportKey}${github.status?.targetUrl ? `: ${github.status.targetUrl}` : ""}); you asked for ${reportKey}.`
11183
+ );
11184
+ }
11185
+ } catch (error) {
11186
+ if (!(error instanceof GitHubApiError)) throw error;
11187
+ warnings.push(
11188
+ `GitHub lookup failed (${error.message}); freshness against the PR's current head is unknown.`
11189
+ );
11190
+ }
11191
+ }
11192
+ }
11193
+ const headSha = github?.pullRequest.headSha;
11194
+ const stale = Boolean(
11195
+ detail && headSha && detail.report.run.headSha !== headSha
11196
+ );
11197
+ if (stale && detail) {
11198
+ warnings.push(
11199
+ `No visual-review run has completed for the current head ${headSha}; report verdicts are from the newest completed run at ${detail.report.run.headSha} (report.stale = true). Review threads are current.`
11200
+ );
11201
+ }
11202
+ const runRepoKey = detail?.report.run.repoKey ?? parsedPr?.repoKey;
11203
+ const pullRequest = github?.pullRequest ?? {
11204
+ repository: (runRepoKey && githubRepoFromKey(runRepoKey)) ?? runRepoKey ?? "",
11205
+ number: detail?.report.run.prNumber ?? parsedPr?.prNumber
11206
+ };
11207
+ const githubStatus = github?.status;
11208
+ const status = githubStatus ? {
11209
+ state: githubStatus.state === "error" ? "failure" : githubStatus.state,
11210
+ description: githubStatus.description ?? "GitHub visual-review status",
11211
+ source: "github",
11212
+ targetUrl: githubStatus.targetUrl,
11213
+ updatedAt: githubStatus.updatedAt
11214
+ } : github ? {
11215
+ // GitHub answered authoritatively for the current head and
11216
+ // the context is absent. Keep that distinct from Tempo's
11217
+ // stored gate, which remains available under `report.gate`.
11218
+ state: "missing",
11219
+ description: stale ? "No visual-review status is present on the current PR head yet." : "No visual-review status is present on the PR head.",
11220
+ source: "none"
11221
+ } : detail && !stale ? {
11222
+ // No GitHub read was possible (for example reportUrl-only
11223
+ // hosted usage), so the stored gate is the best available
11224
+ // status signal rather than an assertion about GitHub.
11225
+ ...detail.derivedStatus,
11226
+ source: "tempo",
11227
+ targetUrl: detail.report.url
11228
+ } : {
11229
+ state: "unavailable",
11230
+ description: "GitHub status could not be read; only stored Tempo data is shown.",
11231
+ source: "none"
11232
+ };
11233
+ if (githubStatus && detail && !stale) {
11234
+ const published = githubStatus.state === "error" ? "failure" : githubStatus.state;
11235
+ if (published !== detail.derivedStatus.state) {
11236
+ warnings.push(
11237
+ `GitHub's published visual-review status is ${githubStatus.state}, but Tempo's current report gate derives ${detail.derivedStatus.state}; inspect report.run.lastPublishError for a stale-status failure.`
11238
+ );
11239
+ }
11240
+ }
11241
+ return jsonResult3({
11242
+ pullRequest,
11243
+ status,
11244
+ report: detail ? { ...detail.report, stale } : null,
11245
+ comments: detail?.comments ?? EMPTY_COMMENTS,
11246
+ warnings: [...warnings, ...detail?.warnings ?? []]
11247
+ });
11248
+ } catch (e) {
11249
+ return errorResult3(mapMcpError(e));
11250
+ }
11251
+ }
11252
+ );
10900
11253
  }
10901
11254
 
10902
11255
  // ../canvas-comments-mcp/tools/write.ts
@@ -11218,10 +11571,15 @@ function registerReadStateTools(server, deps) {
11218
11571
  }
11219
11572
 
11220
11573
  // ../canvas-comments-mcp/server.ts
11574
+ var COMMENTS_MCP_INSTRUCTIONS = [
11575
+ "Tempo canvas comments: review threads pinned to canvases and storyboards, org-scoped and keyed by (orgProjectId, branch, contentPath) where contentPath is the canvas FOLDER path.",
11576
+ "When the user asks for a pull request's visual-review (VRT) status, verdicts, or review feedback, call comments_get_pr_visual_review first: it joins the GitHub `visual-review` check, the Tempo report gate, and the review threads in one read-only call.",
11577
+ "The AI acts as the signed-in user \u2014 every write is attributed to them, never to a separate AI identity."
11578
+ ].join("\n\n");
11221
11579
  function createCommentsMcpServer(context) {
11222
11580
  const server = new McpServer3(
11223
11581
  { name: "tempo-comments-tools", version: "1.0.0" },
11224
- { capabilities: { tools: {} } }
11582
+ { capabilities: { tools: {} }, instructions: COMMENTS_MCP_INSTRUCTIONS }
11225
11583
  );
11226
11584
  const convex = new AuthedConvexClient({
11227
11585
  url: context.convexUrl,
@@ -11245,8 +11603,13 @@ function createCommentsMcpServer(context) {
11245
11603
  context.getCurrentOrgGeneration
11246
11604
  );
11247
11605
  const getWritePermission = context.getWritePermission ?? (() => true);
11248
- const { resolveCanvasContentPath } = context;
11249
- registerReadTools2(instrumented, { convex, resolveCanvasContentPath });
11606
+ const { resolveCanvasContentPath, getRepoUrl, githubApi } = context;
11607
+ registerReadTools2(instrumented, {
11608
+ convex,
11609
+ resolveCanvasContentPath,
11610
+ getRepoUrl,
11611
+ githubApi
11612
+ });
11250
11613
  registerWriteTools2(instrumented, {
11251
11614
  convex,
11252
11615
  getWritePermission,
@@ -24377,6 +24740,19 @@ function classifyDevServerOutput(line) {
24377
24740
  observedAt: Date.now()
24378
24741
  };
24379
24742
  }
24743
+ function buildHostUnresponsiveHint(url, unreachableSinceMs, nowMs = Date.now()) {
24744
+ const unreachableForSeconds = Math.max(
24745
+ 0,
24746
+ Math.round((nowMs - unreachableSinceMs) / 1e3)
24747
+ );
24748
+ return {
24749
+ class: "host-unresponsive",
24750
+ hint: `The canvas dev server is running but has stopped answering requests at ${url} (no response for ${unreachableForSeconds}s). It may be wedged after a dependency change or an interrupted install; retrying restarts it.`,
24751
+ errorCode: "not_reachable",
24752
+ line: `url health probe failed: ${url}`,
24753
+ observedAt: nowMs
24754
+ };
24755
+ }
24380
24756
  function matchClass(line) {
24381
24757
  if (isShellParseAbortOutput(line)) {
24382
24758
  return {
@@ -28016,6 +28392,21 @@ ${output.trim()}`
28016
28392
  this.name = "InstallDependencyConflictError";
28017
28393
  }
28018
28394
  };
28395
+ var InstallInterruptedError = class extends TempoDevServerError {
28396
+ constructor(command, output, evidence) {
28397
+ const explanation = evidence === "concurrent-install" ? "Install command was interrupted \u2014 node_modules changed while it ran, because another install was running in the same folder at the same time" : "Install command could not move a package aside because node_modules is in an inconsistent state (npm ENOTEMPTY) \u2014 left behind when an earlier install was cut short, or by another install running in the same folder";
28398
+ const remedy = evidence === "concurrent-install" ? "Wait for any other install in this folder to finish, then retry the canvas." : "If no other install is running, delete that folder's node_modules and retry the canvas.";
28399
+ super(
28400
+ "install_interrupted",
28401
+ `${explanation}: ${command}
28402
+ ${output.trim()}
28403
+
28404
+ ${remedy}`
28405
+ );
28406
+ this.name = "InstallInterruptedError";
28407
+ this.evidence = evidence;
28408
+ }
28409
+ };
28019
28410
  function shellDialectRewriteHint(field) {
28020
28411
  return `Hint: Tempo runs tempo.config.json's "${field}" through the platform shell, which is cmd.exe on Windows \u2014 it cannot parse PowerShell. Rewrite the command as a plain, cross-platform invocation with no shell conditionals (e.g. "npm install", or "npm install --prefix .. && npm install" \u2014 cmd supports \`&&\`). Avoid PowerShell-only syntax: \`if (...) { ... } else { ... }\`, \`Test-Path\`, \`$env:FOO\`. If you need cmd's own conditional, it is \`if exist package.json (...) else (...)\`.`;
28021
28412
  }
@@ -28383,6 +28774,29 @@ var CanvasRegistry = class {
28383
28774
  }
28384
28775
  };
28385
28776
 
28777
+ // ../tempo-devserver/process-hint-slots.ts
28778
+ var ProcessHintSlots = class {
28779
+ constructor() {
28780
+ this.outputHint = null;
28781
+ this.supervisorHint = null;
28782
+ }
28783
+ current() {
28784
+ return this.supervisorHint ?? this.outputHint;
28785
+ }
28786
+ recordOutput(hint) {
28787
+ this.outputHint = hint;
28788
+ }
28789
+ recordSupervisor(hint) {
28790
+ this.supervisorHint = hint;
28791
+ }
28792
+ /** Withdraw the supervisor hint when it is of `hintClass`; false if it was not. */
28793
+ clearSupervisor(hintClass) {
28794
+ if (this.supervisorHint?.class !== hintClass) return false;
28795
+ this.supervisorHint = null;
28796
+ return true;
28797
+ }
28798
+ };
28799
+
28386
28800
  // ../tempo-devserver/project-node-version.ts
28387
28801
  import { access, readFile as readFile10, readdir as readdir5 } from "fs/promises";
28388
28802
  import path16 from "path";
@@ -28965,10 +29379,11 @@ var TempoHostProcess = class _TempoHostProcess {
28965
29379
  this.outputListeners = [];
28966
29380
  // Collects recent process output so early-exit errors include diagnostic context
28967
29381
  this.recentOutput = [];
28968
- // Most recent user-actionable hint classified from stdout/stderr — drives
28969
- // the renderer's toolbar messaging when the canvas is stuck or errored.
28970
- // See `hint-classifier.ts` for the classification rules.
28971
- this.lastHint = null;
29382
+ // User-actionable hints about this process — classified from stdout/stderr
29383
+ // (see `hint-classifier.ts`) or observed by the supervisor from outside —
29384
+ // drive the renderer's toolbar messaging when the canvas is stuck or
29385
+ // errored. Two slots, because the two sources have different lifetimes.
29386
+ this.hints = new ProcessHintSlots();
28972
29387
  this.hintListeners = [];
28973
29388
  this.explicitTempoHostUrl = options.explicitTempoHostUrl;
28974
29389
  this.tempoHostUrlSource = options.tempoHostUrlSource;
@@ -29029,19 +29444,7 @@ var TempoHostProcess = class _TempoHostProcess {
29029
29444
  hint: new MissingRootDepsError(this.missingRootDepsProjectRoot).message
29030
29445
  } : classifiedHint;
29031
29446
  if (hint) {
29032
- this.lastHint = hint;
29033
- devLog("process:hint", {
29034
- pid: this.child.pid ?? null,
29035
- class: hint.class,
29036
- hint: hint.hint,
29037
- line: text.slice(0, 500)
29038
- });
29039
- for (const listener of this.hintListeners) {
29040
- try {
29041
- listener(hint);
29042
- } catch {
29043
- }
29044
- }
29447
+ this.recordHint(hint);
29045
29448
  }
29046
29449
  const matchedUrl = extractServerUrl2(text);
29047
29450
  if (matchedUrl && !this.detectedBaseUrl) {
@@ -29157,15 +29560,69 @@ ${output}` : "";
29157
29560
  }
29158
29561
  onHint(listener) {
29159
29562
  this.hintListeners.push(listener);
29160
- if (this.lastHint) {
29563
+ const current = this.hints.current();
29564
+ if (current) {
29161
29565
  try {
29162
- listener(this.lastHint);
29566
+ listener(current);
29163
29567
  } catch {
29164
29568
  }
29165
29569
  }
29166
29570
  }
29167
29571
  getLastHint() {
29168
- return this.lastHint;
29572
+ return this.hints.current();
29573
+ }
29574
+ /**
29575
+ * Record a hint classified from this process's stdout/stderr and notify
29576
+ * subscribers with whatever hint is now in effect (a supervisor hint, if one
29577
+ * is active, still takes precedence — see `ProcessHintSlots`).
29578
+ */
29579
+ recordHint(hint) {
29580
+ this.hints.recordOutput(hint);
29581
+ this.logHint(hint);
29582
+ this.notifyHintListeners(this.hints.current());
29583
+ }
29584
+ /**
29585
+ * Record a hint the supervisor observed about this process from outside its
29586
+ * output (a live child whose URL stopped answering — see
29587
+ * `runUrlHealthProbeTick`). It is shown in place of any output hint until
29588
+ * `clearSupervisorHint` withdraws it.
29589
+ */
29590
+ recordSupervisorHint(hint) {
29591
+ this.hints.recordSupervisor(hint);
29592
+ this.logHint(hint);
29593
+ this.notifyHintListeners(hint);
29594
+ }
29595
+ /**
29596
+ * Withdraw the supervisor hint of `hintClass` once the condition it
29597
+ * described has cleared (a URL probe succeeding again). Subscribers receive
29598
+ * the hint now in effect — the output hint that was showing before, or
29599
+ * `null` — so a consumer that mirrors "the current hint" (the Electron
29600
+ * adapter → renderer toolbar detail) stops reporting a recovered host as
29601
+ * stuck without losing a compile diagnostic that is still true.
29602
+ */
29603
+ clearSupervisorHint(hintClass) {
29604
+ if (!this.hints.clearSupervisor(hintClass)) return;
29605
+ devLog("process:hint_cleared", {
29606
+ pid: this.child.pid ?? null,
29607
+ class: hintClass
29608
+ });
29609
+ this.notifyHintListeners(this.hints.current());
29610
+ }
29611
+ logHint(hint) {
29612
+ devLog("process:hint", {
29613
+ pid: this.child.pid ?? null,
29614
+ class: hint.class,
29615
+ hint: hint.hint,
29616
+ line: hint.line
29617
+ });
29618
+ }
29619
+ notifyHintListeners(hint) {
29620
+ for (const listener of this.hintListeners) {
29621
+ try {
29622
+ listener(hint);
29623
+ } catch {
29624
+ }
29625
+ }
29169
29626
  }
29170
29627
  getLivenessSource() {
29171
29628
  return this.launcherFinished ? "probe" : "child";
@@ -29179,9 +29636,10 @@ ${output}` : "";
29179
29636
  * no indication that the command never even parsed. See TEM-436.
29180
29637
  */
29181
29638
  hintSuffix() {
29182
- return this.lastHint ? `
29639
+ const current = this.hints.current();
29640
+ return current ? `
29183
29641
 
29184
- Hint: ${this.lastHint.hint}` : "";
29642
+ Hint: ${current.hint}` : "";
29185
29643
  }
29186
29644
  getPid() {
29187
29645
  return this.child.pid ?? null;
@@ -29588,6 +30046,11 @@ var TempoDevServer = class _TempoDevServer {
29588
30046
  // local `process` var out of `startDevServer`.
29589
30047
  this.activeHostProcess = null;
29590
30048
  this.urlHealthProbeConsecutiveFailures = 0;
30049
+ // Bumped whenever the probe loop is (re)started or stopped, so a probe
30050
+ // that was already in flight for the PREVIOUS loop cannot report into this
30051
+ // one. URL equality is not enough of a fence: a replacement process on the
30052
+ // same URL would inherit the old process's last failure.
30053
+ this.urlHealthProbeGeneration = 0;
29591
30054
  // Tempo-managed Next hosts never share the user's ordinary `.next` cache.
29592
30055
  // This starts at the stable Tempo-owned directory and only receives a suffix
29593
30056
  // after Next reports an observed lock conflict for that directory.
@@ -30788,6 +31251,7 @@ var TempoDevServer = class _TempoDevServer {
30788
31251
  if (this.disposed) return;
30789
31252
  this.urlHealthProbeUrl = url;
30790
31253
  this.urlHealthProbeConsecutiveFailures = 0;
31254
+ this.urlHealthProbeGeneration += 1;
30791
31255
  this.urlHealthProbeTimer = setInterval(() => {
30792
31256
  void this.runUrlHealthProbeTick();
30793
31257
  }, _TempoDevServer.URL_HEALTH_PROBE_INTERVAL_MS);
@@ -30802,9 +31266,11 @@ var TempoDevServer = class _TempoDevServer {
30802
31266
  }
30803
31267
  this.urlHealthProbeUrl = null;
30804
31268
  this.urlHealthProbeConsecutiveFailures = 0;
31269
+ this.urlHealthProbeGeneration += 1;
30805
31270
  }
30806
31271
  async runUrlHealthProbeTick() {
30807
31272
  const probedUrl = this.urlHealthProbeUrl;
31273
+ const generation = this.urlHealthProbeGeneration;
30808
31274
  if (!probedUrl || this.disposed) return;
30809
31275
  const reachable = await probeUrlOnce(
30810
31276
  probedUrl,
@@ -30812,18 +31278,26 @@ var TempoDevServer = class _TempoDevServer {
30812
31278
  this.projectRoot,
30813
31279
  this.activeHostIdentity
30814
31280
  );
30815
- if (this.urlHealthProbeUrl !== probedUrl) return;
31281
+ if (this.urlHealthProbeGeneration !== generation) return;
30816
31282
  if (reachable) {
30817
31283
  this.state.urlUnreachableSince = null;
30818
31284
  this.urlHealthProbeConsecutiveFailures = 0;
31285
+ this.activeHostProcess?.clearSupervisorHint("host-unresponsive");
30819
31286
  return;
30820
31287
  }
30821
31288
  if (this.activeHostProcess?.isAlive()) {
30822
31289
  this.state.urlUnreachableSince ??= Date.now();
31290
+ this.urlHealthProbeConsecutiveFailures += 1;
30823
31291
  devLog("supervisor:url_probe_failed_host_alive", {
30824
31292
  url: probedUrl,
31293
+ consecutiveFailures: this.urlHealthProbeConsecutiveFailures,
30825
31294
  unreachableSince: this.state.urlUnreachableSince
30826
31295
  });
31296
+ if (this.urlHealthProbeConsecutiveFailures === _TempoDevServer.URL_HEALTH_FAILURES_TO_DEMOTE) {
31297
+ this.activeHostProcess.recordSupervisorHint(
31298
+ buildHostUnresponsiveHint(probedUrl, this.state.urlUnreachableSince)
31299
+ );
31300
+ }
30827
31301
  return;
30828
31302
  }
30829
31303
  const probeBacked = this.activeHostLivenessSource() === "probe";
@@ -32063,6 +32537,11 @@ var INSTALL_LOCK_PATTERN = new RegExp(
32063
32537
  `(?:EPERM|EACCES|EBUSY)[\\s\\S]{0,400}?(?:${INSTALL_LOCK_SYSCALLS})|(?:${INSTALL_LOCK_SYSCALLS})[\\s\\S]{0,400}?(?:EPERM|EACCES|EBUSY)|operation not permitted|permission denied|resource busy or locked`,
32064
32538
  "i"
32065
32539
  );
32540
+ function classifyInterruptedInstallOutput(output) {
32541
+ if (/\bTAR_ENTRY_ERROR\b/.test(output)) return "concurrent-install";
32542
+ if (/\bENOTEMPTY\b/.test(output)) return "inconsistent-tree";
32543
+ return null;
32544
+ }
32066
32545
  function classifyInstallFailure(command, output) {
32067
32546
  if (isShellParseAbortOutput(output)) {
32068
32547
  return new InstallShellDialectMismatchError(command, output);
@@ -32070,6 +32549,10 @@ function classifyInstallFailure(command, output) {
32070
32549
  if (/ERESOLVE|unable to resolve dependency tree|could not resolve/i.test(output)) {
32071
32550
  return new InstallDependencyConflictError(command, output);
32072
32551
  }
32552
+ const interruptedEvidence = classifyInterruptedInstallOutput(output);
32553
+ if (interruptedEvidence) {
32554
+ return new InstallInterruptedError(command, output, interruptedEvidence);
32555
+ }
32073
32556
  if (/\bEBUSY\b|resource busy or locked/i.test(output)) {
32074
32557
  return new InstallFileLockedError(command, output);
32075
32558
  }
@@ -46700,7 +47183,7 @@ function buildBackends(runtime, toolsets, workspace) {
46700
47183
  }
46701
47184
 
46702
47185
  // src/version.ts
46703
- var CLI_VERSION = "0.0.106";
47186
+ var CLI_VERSION = "0.0.107";
46704
47187
 
46705
47188
  // src/canvas-hooks.ts
46706
47189
  var import_pngjs4 = __toESM(require_png(), 1);
@@ -49214,4 +49697,4 @@ export {
49214
49697
  runServe,
49215
49698
  scopeParamsFor
49216
49699
  };
49217
- //# sourceMappingURL=serve-7RSR6WI6.js.map
49700
+ //# sourceMappingURL=serve-IXP2WX42.js.map