@aarwitz/tapp 0.17.0-rc.9 → 0.17.1

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.
@@ -9,6 +9,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
  import {
11
11
  CallToolRequestSchema,
12
+ GetPromptRequestSchema,
13
+ ListPromptsRequestSchema,
12
14
  ListToolsRequestSchema,
13
15
  } from "@modelcontextprotocol/sdk/types.js";
14
16
 
@@ -17,7 +19,15 @@ import { existingProjectArtifactPath, projectArtifactDirectory } from "./project
17
19
 
18
20
  const __filename = fileURLToPath(import.meta.url);
19
21
  const __dirname = path.dirname(__filename);
22
+ // `repoRoot` is the installed Tapp package root: scripts and bundled harness assets live here.
23
+ // Repository-facing MCP operations must use `workspaceRoot` instead. In an npm/Claude-plugin
24
+ // installation those are different directories, even though source-repo tests historically made
25
+ // them look identical.
20
26
  const repoRoot = path.resolve(__dirname, "../..");
27
+ const workspaceRoot = (() => {
28
+ try { return fs.realpathSync(process.cwd()); }
29
+ catch { return path.resolve(process.cwd()); }
30
+ })();
21
31
  const scriptsDir = path.join(repoRoot, "scripts");
22
32
  // TAPP_HOME (set by the `tapp` CLI when installed) redirects writable output to a user directory.
23
33
  // The old alias remains a read-only fallback; unset repository development stays local.
@@ -35,8 +45,13 @@ function clampOutput(value, maxChars = MAX_OUTPUT_CHARS) {
35
45
  return value;
36
46
  }
37
47
 
38
- const dropped = value.length - maxChars;
39
- return `${value.slice(0, maxChars)}\n...[truncated ${dropped} chars]`;
48
+ // Compiler/build diagnostics are normally emitted at the end. Preserve both ends so a long
49
+ // dependency build cannot truncate away the one actionable error the user needs.
50
+ const marker = "\n...[output truncated]...\n";
51
+ const retained = Math.max(0, maxChars - marker.length);
52
+ const head = Math.ceil(retained / 2);
53
+ const tail = Math.floor(retained / 2);
54
+ return `${value.slice(0, head)}${marker}${value.slice(value.length - tail)}`;
40
55
  }
41
56
 
42
57
  function asBoolean(value, fallback = false) {
@@ -320,6 +335,9 @@ export function findXcodeContainer(startDir) {
320
335
  }
321
336
 
322
337
  export async function buildAppForSim({ dir, container, scheme, configuration = "Debug" } = {}) {
338
+ const { storagePreflight } = await import("./environment-preflight.js");
339
+ const storage = storagePreflight(path.join(tappHome || os.tmpdir(), "app-builds"));
340
+ if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
323
341
  const target = container || findXcodeContainer(dir || process.cwd());
324
342
  if (!target) return { error: `No Xcode project or workspace found under ${dir || process.cwd()}` };
325
343
  const isWorkspace = target.endsWith(".xcworkspace");
@@ -539,9 +557,59 @@ function treeSnapshot() {
539
557
  screenTitle: t ? t.screenTitle ?? null : null,
540
558
  elementCount: t ? (t.elements || []).length : 0,
541
559
  elements: t ? t.elements || [] : [],
560
+ ...(t?.url ? { url:t.url } : {}),
561
+ };
562
+ }
563
+
564
+ /** Keep the semantic/actionable accessibility surface an agent needs, without sending hundreds
565
+ * of empty native hierarchy containers on every turn. The complete tree remains in the active
566
+ * session for selector resolution; this is only the public MCP projection. */
567
+ export function agentFacingElements(elements, limit = 160) {
568
+ const result = [];
569
+ const seen = new Set();
570
+ for (const element of elements || []) {
571
+ const semanticValues = [
572
+ element?.id, element?.identifier, element?.label, element?.text,
573
+ element?.description, element?.placeholder, element?.value,
574
+ ].map((value) => String(value ?? "").trim()).filter(Boolean);
575
+ const roleAndType = `${element?.role || ""} ${element?.type || ""}`.toLowerCase();
576
+ // Unlabelled input/button controls are still actionable by coordinate. Empty windows,
577
+ // applications, groups, images and generic containers are implementation noise.
578
+ const actionableWithoutText = /button|link|textfield|securetext|textarea|edittext|switch|checkbox/.test(roleAndType);
579
+ if (!semanticValues.length && !actionableWithoutText) continue;
580
+ const frame = element?.frame || {};
581
+ const key = JSON.stringify([
582
+ roleAndType, ...semanticValues,
583
+ element?.x ?? frame.x ?? null, element?.y ?? frame.y ?? null,
584
+ element?.w ?? frame.width ?? null, element?.h ?? frame.height ?? null,
585
+ ]);
586
+ if (seen.has(key)) continue;
587
+ seen.add(key);
588
+ result.push(element);
589
+ if (result.length >= Math.max(1, limit)) break;
590
+ }
591
+ return result;
592
+ }
593
+
594
+ function agentScreenProjection(screen) {
595
+ const all = screen?.elements || [];
596
+ const elements = agentFacingElements(all);
597
+ return {
598
+ screenTitle:screen?.screenTitle ?? null,
599
+ elementCount:elements.length,
600
+ totalElementCount:all.length,
601
+ elementsOmitted:Math.max(0, all.length - elements.length),
602
+ elements,
603
+ ...(screen?.url ? { url:screen.url } : {}),
542
604
  };
543
605
  }
544
606
 
607
+ function focusMetadata(result) {
608
+ if (!result) return null;
609
+ const { elements:_elements, elementCount:_elementCount, screenTitle:_screenTitle, url:_url, ...metadata } = result;
610
+ return metadata;
611
+ }
612
+
545
613
  async function startSession(bundleId, extraEnv = {}) {
546
614
  if (activeSession && !activeSession.ended) {
547
615
  return { error: "A session is already active; call tapp_session_end first.", screen: treeSnapshot() };
@@ -560,7 +628,7 @@ async function startSession(bundleId, extraEnv = {}) {
560
628
  env: { ...process.env, ...extraEnv, OCQA_SESSION_CMD_PATH: cmdPath, OCQA_SESSION_RESULT_PATH: resultPath, OCQA_SESSION_TIMEOUT: "7200" },
561
629
  });
562
630
  activeSession = {
563
- proc, bundleId, seq: 0, cmdPath, resultPath, latestTree: null, treeVersion: 0, buffer: "", ready: false, ended: false,
631
+ platform:"ios", proc, bundleId, seq: 0, cmdPath, resultPath, latestTree: null, treeVersion: 0, buffer: "", ready: false, ended: false,
564
632
  // Always-on recorder: each act appends a Flow step; tapp_flow_save snapshots it to a file.
565
633
  recording: [],
566
634
  creds: { email: extraEnv.OCQA_TEST_EMAIL || "", password: extraEnv.OCQA_TEST_PASSWORD || "" },
@@ -618,24 +686,26 @@ async function webSessionSnapshot(page) {
618
686
  const rect = element.getBoundingClientRect();
619
687
  return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
620
688
  };
621
- const controls = [...document.querySelectorAll("button,a[href],input,textarea,select,[role=button],[role=tab],[role=checkbox],[role=switch]")]
689
+ const controls = [...document.querySelectorAll("button,a[href],input,textarea,select,[role=button],[role=tab],[role=checkbox],[role=switch],[role=status],[role=alert],h2,h3,p,li")]
622
690
  .filter((element) => element instanceof HTMLElement && visible(element))
623
691
  .slice(0, 250)
624
692
  .map((element) => {
625
693
  const rect = element.getBoundingClientRect();
626
694
  const label = String(element.getAttribute("aria-label") || element.labels?.[0]?.textContent || element.textContent || element.getAttribute("placeholder") || element.getAttribute("name") || element.id || "").replace(/\s+/g, " ").trim().slice(0, 160);
695
+ const interactive = element.matches("button,a[href],input,textarea,select,[role=button],[role=tab],[role=checkbox],[role=switch]");
627
696
  return {
628
697
  id: element.getAttribute("data-testid") || element.id || element.getAttribute("name") || "",
629
698
  label,
630
699
  type: element.getAttribute("role") || element.tagName.toLowerCase(),
631
- role: element.getAttribute("role") || (element.matches("button,[role=button]") ? "button" : element.matches("a") ? "link" : element.matches("input,textarea,select") ? "input" : "other"),
700
+ role: element.getAttribute("role") || (element.matches("button,[role=button]") ? "button" : element.matches("a") ? "link" : element.matches("input,textarea,select") ? "input" : "text"),
632
701
  enabled: !(element.disabled || element.getAttribute("aria-disabled") === "true"),
633
- hittable: true,
702
+ hittable: interactive,
634
703
  clickable: element.matches("button,a,[role=button],[role=tab],[role=checkbox],[role=switch]") && !(element.disabled || element.getAttribute("aria-disabled") === "true"),
635
704
  secure: element instanceof HTMLInputElement && element.type === "password",
636
705
  frame: { x:Math.round(rect.x), y:Math.round(rect.y), width:Math.round(rect.width), height:Math.round(rect.height) },
637
706
  };
638
- });
707
+ })
708
+ .filter((element) => element.label);
639
709
  const heading = document.querySelector("h1,[role=heading]")?.textContent?.replace(/\s+/g, " ").trim();
640
710
  return { screenTitle:heading || document.title || location.pathname || "Web application", elements:controls, url:location.href };
641
711
  });
@@ -700,6 +770,28 @@ function templateValue(text) {
700
770
  return text;
701
771
  }
702
772
 
773
+ export function isStableFlowCheckpoint(value) {
774
+ const text = String(value || "").replace(/\s+/g, " ").trim();
775
+ if (!text || /^(loading|fetching|please wait|preparing|connecting|syncing|signing in)(?:[.…!]*|\s.*)$/i.test(text)) return false;
776
+ if (/^(?:mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?|sun)(?:day)?\b/i.test(text)) return false;
777
+ if (/^(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:,\s+\d{4})?$/i.test(text)) return false;
778
+ if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) return false;
779
+ return true;
780
+ }
781
+
782
+ export function semanticTargetAtPoint(elements, x, y) {
783
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return "";
784
+ return (elements || [])
785
+ .filter((element) => {
786
+ const frame = element.frame || {};
787
+ return element.hittable !== false && Number.isFinite(frame.x) && Number.isFinite(frame.y) && Number.isFinite(frame.width) && Number.isFinite(frame.height)
788
+ && x >= frame.x && y >= frame.y && x <= frame.x + frame.width && y <= frame.y + frame.height
789
+ && String(element.id || element.identifier || element.label || "").trim();
790
+ })
791
+ .sort((a, b) => (a.frame.width * a.frame.height) - (b.frame.width * b.frame.height))
792
+ .map((element) => String(element.id || element.identifier || element.label || "").trim())[0] || "";
793
+ }
794
+
703
795
  /** Append a Flow step for an act (record-by-doing). Inserts wait_for on screen change for
704
796
  * deterministic replay. Inspection acts (tree/screenshot/wait) are not recorded. */
705
797
  function recordStep(cmd, result) {
@@ -710,7 +802,7 @@ function recordStep(cmd, result) {
710
802
  case "tap": {
711
803
  const target = cmd.id || cmd.label || (typeof cmd.x === "number" ? `${cmd.x},${cmd.y}` : "");
712
804
  if (target) activeSession.recording.push({ tap: target });
713
- if (changed) activeSession.recording.push({ wait_for: newScreen });
805
+ if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
714
806
  break;
715
807
  }
716
808
  case "type": {
@@ -719,12 +811,16 @@ function recordStep(cmd, result) {
719
811
  activeSession.recording.push({ type: step });
720
812
  break;
721
813
  }
814
+ case "login":
815
+ activeSession.recording.push({ login: { email: "$TEST_EMAIL", password: "$TEST_PASSWORD" } });
816
+ if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
817
+ break;
722
818
  case "swipe":
723
819
  activeSession.recording.push({ swipe: cmd.direction || "up" });
724
820
  break;
725
821
  case "back":
726
822
  activeSession.recording.push({ back: true });
727
- if (changed) activeSession.recording.push({ wait_for: newScreen });
823
+ if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
728
824
  break;
729
825
  default:
730
826
  break; // tree / screenshot / wait are inspection, not test steps
@@ -733,7 +829,17 @@ function recordStep(cmd, result) {
733
829
  }
734
830
 
735
831
  async function sessionAct(cmd) {
736
- if (!activeSession || activeSession.ended) return { error: "No active session. Call tapp_session_start first." };
832
+ const startedAt = Date.now();
833
+ const done = (result) => ({ ...result, durationMs: Date.now() - startedAt });
834
+ if (!activeSession || activeSession.ended) return done({ error: "No active session. Call tapp_session_start first." });
835
+ let coordinateResolvedTarget = "";
836
+ if (cmd.action === "tap" && !cmd.id && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) {
837
+ coordinateResolvedTarget = semanticTargetAtPoint(activeSession.latestTree?.elements, cmd.x, cmd.y);
838
+ if (coordinateResolvedTarget) {
839
+ const { x: _x, y: _y, ...semanticCommand } = cmd;
840
+ cmd = { ...semanticCommand, id: coordinateResolvedTarget };
841
+ }
842
+ }
737
843
  if (activeSession.platform === "web") {
738
844
  const session = activeSession;
739
845
  let status = "ok";
@@ -741,9 +847,13 @@ async function sessionAct(cmd) {
741
847
  let typedInto = null;
742
848
  try {
743
849
  if (cmd.action === "tap") {
744
- const locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "");
745
- if (!locator) { status = "not_found"; detail = "No visible web control matched the semantic target"; }
746
- else await locator.click({ timeout:10_000 });
850
+ const target = cmd.id || cmd.label || "";
851
+ if (!target && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) await session.page.mouse.click(cmd.x, cmd.y);
852
+ else {
853
+ const locator = await firstVisibleWebLocator(session.page, target);
854
+ if (!locator) { status = "not_found"; detail = "No visible web control matched the semantic target"; }
855
+ else await locator.click({ timeout:10_000 });
856
+ }
747
857
  } else if (cmd.action === "type") {
748
858
  const locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "", { input:true });
749
859
  if (!locator) { status = "not_found"; detail = "No visible web field matched the semantic target"; }
@@ -756,6 +866,28 @@ async function sessionAct(cmd) {
756
866
  if (!locator) await session.page.waitForTimeout(120);
757
867
  }
758
868
  if (!locator) { status = "timeout"; detail = `Timed out waiting for ${cmd.id || cmd.text || "target"}`; }
869
+ } else if (cmd.action === "login") {
870
+ const emailValue = cmd.email || session.creds.email || "";
871
+ const passwordValue = cmd.password || session.creds.password || "";
872
+ if (!emailValue || !passwordValue) { status = "missing_credentials"; detail = "Email and password are required"; }
873
+ else {
874
+ const email = await firstVisibleWebLocator(session.page, "Email", { input:true })
875
+ || session.page.locator("input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]").first();
876
+ const password = session.page.locator("input[type=password]").first();
877
+ if (!(await email.isVisible().catch(() => false)) || !(await password.isVisible().catch(() => false))) {
878
+ status = "not_found"; detail = "Could not identify email and password fields";
879
+ } else {
880
+ await email.fill(emailValue);
881
+ await password.fill(passwordValue);
882
+ const submit = session.page.getByRole("button", { name:/sign in|log in|login|continue|submit/i }).first();
883
+ if (!(await submit.isVisible().catch(() => false))) { status = "not_found"; detail = "Could not identify a sign-in control"; }
884
+ else {
885
+ await submit.click();
886
+ await session.page.waitForTimeout(500);
887
+ if (await password.isVisible().catch(() => false)) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
888
+ }
889
+ }
890
+ }
759
891
  } else if (cmd.action === "back") {
760
892
  await session.page.goBack({ waitUntil:"domcontentloaded", timeout:10_000 }).catch(() => {});
761
893
  } else if (cmd.action === "swipe") {
@@ -774,10 +906,11 @@ async function sessionAct(cmd) {
774
906
  }
775
907
  const snapshot = treeSnapshot();
776
908
  if (status === "ok") recordStep(cmd, snapshot);
777
- return { status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "" };
909
+ return done({ status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "", ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
778
910
  }
779
911
  if (activeSession.platform === "android") {
780
912
  const s = activeSession;
913
+ const beforeAction = s.latestTree;
781
914
  let status = "ok";
782
915
  let detail = null;
783
916
  let typedInto = null;
@@ -809,28 +942,28 @@ async function sessionAct(cmd) {
809
942
  status = "not_found"; detail = "Could not identify email and password fields";
810
943
  } else {
811
944
  const er = await s.driver.type(emailField.id || emailField.label, cmd.email || s.creds.email || "", s.latestTree);
812
- s.latestTree = await s.driver.settle();
945
+ s.latestTree = await s.driver.settle(2200, s.latestTree);
813
946
  const pr = await s.driver.type(passwordField.id || passwordField.label, cmd.password || s.creds.password || "", s.latestTree);
814
- s.latestTree = await s.driver.settle();
947
+ s.latestTree = await s.driver.settle(2200, s.latestTree);
815
948
  const submit = s.latestTree.elements.find((e) => e.clickable && /sign in|log in|login|continue/i.test(`${e.text} ${e.label} ${e.id}`));
816
949
  if (er.status !== "ok" || pr.status !== "ok" || !submit) {
817
950
  status = "not_found"; detail = "Could not fill or submit the login form";
818
951
  } else {
819
952
  const before = s.latestTree.screenTitle;
820
953
  await s.driver.tap(submit.id || submit.description || submit.text, s.latestTree);
821
- s.latestTree = await s.driver.settle();
954
+ s.latestTree = await s.driver.settle(2200, s.latestTree);
822
955
  if (s.latestTree.screenTitle === before) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
823
956
  }
824
957
  }
825
958
  }
826
- if (!["wait", "tree", "screenshot", "login"].includes(cmd.action)) s.latestTree = await s.driver.settle();
959
+ if (!["wait", "tree", "screenshot", "login"].includes(cmd.action)) s.latestTree = await s.driver.settle(2200, beforeAction);
827
960
  } catch (error) {
828
961
  status = "error"; detail = error.message || String(error);
829
962
  }
830
963
  s.treeVersion += 1;
831
964
  const snap = treeSnapshot();
832
965
  if (status === "ok") recordStep(cmd, snap);
833
- return { status, typedInto, detail, ...snap, recordedSteps: s.recording.length };
966
+ return done({ status, typedInto, detail, ...snap, recordedSteps: s.recording.length, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
834
967
  }
835
968
  activeSession.seq += 1;
836
969
  const seq = activeSession.seq;
@@ -859,7 +992,7 @@ async function sessionAct(cmd) {
859
992
  while (activeSession.treeVersion === beforeVer && Date.now() < td && !activeSession.ended) await sleep(150);
860
993
  const snap = treeSnapshot();
861
994
  if (status === "ok") recordStep(cmd, snap); // record only successful acts
862
- return { status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0 };
995
+ return done({ status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
863
996
  }
864
997
 
865
998
  async function endSession() {
@@ -904,7 +1037,7 @@ export async function saveInteractiveSessionFlow({ projectDir, name, addFinalAss
904
1037
  if (!projectDir || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error("A valid repository root is required to save a Flow");
905
1038
  const steps = [...(activeSession.recording || [])];
906
1039
  if (steps.length === 0) throw new Error("Nothing recorded yet — perform some live-session actions first.");
907
- if (addFinalAssertion && activeSession.lastScreen) {
1040
+ if (addFinalAssertion && activeSession.lastScreen && isStableFlowCheckpoint(activeSession.lastScreen)) {
908
1041
  const last = steps[steps.length - 1] || {};
909
1042
  if (!("assert_screen" in last)) steps.push({ assert_screen: activeSession.lastScreen });
910
1043
  }
@@ -946,6 +1079,79 @@ export {
946
1079
  endSession as endInteractiveSession,
947
1080
  };
948
1081
 
1082
+ function elementMatchesSemanticTarget(element, value) {
1083
+ const target = String(value || "").trim().toLowerCase();
1084
+ if (!target) return false;
1085
+ return [element?.id, element?.identifier, element?.label, element?.text, element?.description]
1086
+ .map((item) => String(item || "").trim().toLowerCase())
1087
+ .some((item) => item === target || item.includes(target) || target.includes(item));
1088
+ }
1089
+
1090
+ /** Locate a requested surface from owned source, reconcile it with the runtime-observed UI Map,
1091
+ * and execute the shortest replayable route in the active session. Source may identify intent but
1092
+ * never authorizes a tap: only observed/validated map edges are executed. */
1093
+ export async function focusInteractiveSession({ projectDir = process.cwd(), query, platform = "", mapPath = "" } = {}) {
1094
+ const { locateFocusedTarget } = await import("./focused-navigation.js");
1095
+ const sessionPlatform = activeSession?.platform || platform || "";
1096
+ const location = locateFocusedTarget({
1097
+ projectDir, query, platform:sessionPlatform || platform, mapPath,
1098
+ currentScreen:activeSession?.latestTree?.screenTitle || "",
1099
+ });
1100
+ if (!activeSession || activeSession.ended) return { ...location, executed:false, execution:{ status:"not-started", reason:"No active session; start the target app, then focus it." } };
1101
+ if (location.navigation?.status !== "replayable") return { ...location, executed:false, execution:{ status:"blocked", reason:location.navigation?.reason || "No observed route" }, ...treeSnapshot() };
1102
+
1103
+ const executedSteps = [];
1104
+ if (location.navigation.mode === "direct-web-route") {
1105
+ if (activeSession.platform !== "web") return { ...location, executed:false, execution:{ status:"blocked", reason:"A web route cannot be used in a native session" }, ...treeSnapshot() };
1106
+ try {
1107
+ const current = new URL(activeSession.page.url());
1108
+ const destination = new URL(location.navigation.route, current.origin);
1109
+ if (destination.origin !== current.origin) throw new Error("Observed route left the active app origin");
1110
+ await activeSession.page.goto(destination.href, { waitUntil:"domcontentloaded", timeout:15_000 });
1111
+ await activeSession.page.waitForLoadState("networkidle", { timeout:3_000 }).catch(() => {});
1112
+ activeSession.latestTree = await webSessionSnapshot(activeSession.page);
1113
+ activeSession.treeVersion += 1;
1114
+ executedSteps.push({ action:"open", target:location.navigation.route, status:"ok", screenTitle:activeSession.latestTree.screenTitle });
1115
+ } catch (error) {
1116
+ return { ...location, executed:true, execution:{ status:"failed", steps:executedSteps, reason:error.message || String(error) }, ...treeSnapshot() };
1117
+ }
1118
+ } else {
1119
+ for (const step of location.navigation.steps || []) {
1120
+ const action = step.action?.type === "back" ? "back" : "tap";
1121
+ const candidates = [
1122
+ ...(step.action?.selectors || [])
1123
+ .sort((left, right) => {
1124
+ const order = ["testId", "accessibilityId", "resourceId", "cssId", "label"];
1125
+ const rank = (kind) => { const index = order.indexOf(kind); return index < 0 ? order.length : index; };
1126
+ return rank(left.kind) - rank(right.kind);
1127
+ })
1128
+ .map((selector) => selector.value),
1129
+ step.action?.target,
1130
+ ].filter(Boolean);
1131
+ const target = candidates.find((candidate) => (activeSession.latestTree?.elements || []).some((element) => elementMatchesSemanticTarget(element, candidate))) || candidates[0] || "";
1132
+ const result = await sessionAct(action === "back" ? { action } : { action, id:target });
1133
+ executedSteps.push({ action, target, status:result.status, screenTitle:result.screenTitle, durationMs:result.durationMs });
1134
+ if (result.error || result.status !== "ok") return {
1135
+ ...location, executed:true,
1136
+ execution:{ status:"failed", steps:executedSteps, reason:result.error || result.detail || `Observed route step '${target}' did not succeed` },
1137
+ ...treeSnapshot(),
1138
+ };
1139
+ }
1140
+ }
1141
+ const targetControls = location.target?.matchedControls || [];
1142
+ const reachedByTitle = location.target?.name && String(treeSnapshot().screenTitle || "").toLowerCase() === String(location.target.name).toLowerCase();
1143
+ const reachedByControl = targetControls.some((control) => (activeSession.latestTree?.elements || []).some((element) =>
1144
+ [control.id, control.label, control.semanticKey, ...(control.selectors || []).map((selector) => selector.value)].some((value) => elementMatchesSemanticTarget(element, value))
1145
+ ));
1146
+ const reached = reachedByTitle || reachedByControl;
1147
+ return {
1148
+ ...location, executed:true,
1149
+ execution:{ status:reached ? "reached" : "route-completed-unconfirmed", steps:executedSteps,
1150
+ ...(!reached ? { reason:"The observed route completed, but the requested title/control was not visible in the final tree." } : {}) },
1151
+ ...treeSnapshot(),
1152
+ };
1153
+ }
1154
+
949
1155
  export async function captureInteractiveSessionFrame(maxWidth = 900) {
950
1156
  if (!activeSession || activeSession.ended) return { error:"No active interactive session" };
951
1157
  if (activeSession.platform === "android") {
@@ -1229,6 +1435,7 @@ export function explorationEnvFromArgs(args) {
1229
1435
  const env = {};
1230
1436
  if (isNonEmptyString(args.testEmail)) env.OCQA_TEST_EMAIL = args.testEmail;
1231
1437
  if (isNonEmptyString(args.testPassword)) env.OCQA_TEST_PASSWORD = args.testPassword;
1438
+ if (isNonEmptyString(args.testEmail) || isNonEmptyString(args.testPassword)) env.OCQA_CREDENTIALS_EXPLICIT = "1";
1232
1439
  if (Array.isArray(args.appLaunchArgs)) {
1233
1440
  const a = args.appLaunchArgs.filter((s) => typeof s === "string" && s.length > 0);
1234
1441
  if (a.length) env.OCQA_APP_LAUNCH_ARGS_JSON = JSON.stringify(a);
@@ -1252,6 +1459,14 @@ export function explorationEnvFromArgs(args) {
1252
1459
  if (args.prExplorationTarget && typeof args.prExplorationTarget === "object" && !Array.isArray(args.prExplorationTarget)) {
1253
1460
  env.OCQA_PR_TARGET_JSON = JSON.stringify(args.prExplorationTarget);
1254
1461
  }
1462
+ // Local visual clients can reveal their preview at the same foreground/settled boundary used by
1463
+ // native recording. Keep this host handshake in the OS temp directory; it is not app evidence or
1464
+ // a caller-directed repository write.
1465
+ if (isNonEmptyString(args.visualReadyPath)) {
1466
+ const readyPath = path.resolve(args.visualReadyPath.trim());
1467
+ const tempRoot = path.resolve(os.tmpdir());
1468
+ if (readyPath.startsWith(`${tempRoot}${path.sep}`)) env.OCQA_VISUAL_READY_PATH = readyPath;
1469
+ }
1255
1470
  // Explicit login replay: a recorded sequence run before exploration, for custom login UIs the
1256
1471
  // heuristic preamble can't parse — the #1 reason a real app stays invisible. Steps are
1257
1472
  // {action: type|tap|wait, target, value?, timeoutMs?}; $TEST_EMAIL/$TEST_PASSWORD substituted
@@ -1320,9 +1535,9 @@ function fmtDuration(ms) {
1320
1535
  export function qaNextSteps(report, surface = "mcp") {
1321
1536
  if (surface === "cli") {
1322
1537
  const next = [];
1323
- if (report?.findings?.length) next.push("inspect the evidence with `tapp report latest`");
1324
- next.push("re-run with `--baseline <report.json>` to compare a fix (then `tapp ci` to gate it)");
1325
- next.push("replay a committed journey with `tapp flow run <file>`");
1538
+ if (report?.findings?.length) next.push("inspect the evidence with `npx -y @aarwitz/tapp@latest report latest`");
1539
+ next.push("save this run with `--json <report.json>`, then re-run with `--baseline <report.json>` to compare a fix (`tapp ci` gates it)");
1540
+ next.push("replay a committed journey with `npx -y @aarwitz/tapp@latest flow run <file>`");
1326
1541
  return next;
1327
1542
  }
1328
1543
  const next = [];
@@ -1344,6 +1559,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1344
1559
  L.push(`### 🔭 Exploration complete — ${badge} · ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
1345
1560
  L.push("");
1346
1561
  L.push(report.headline);
1562
+ if (report.credentialWarning) L.push("", `> ⚠️ ${report.credentialWarning}`);
1347
1563
  L.push("");
1348
1564
  L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
1349
1565
  if (report.platform === "web") {
@@ -1358,7 +1574,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1358
1574
  L.push("");
1359
1575
  L.push("**Findings**");
1360
1576
  for (const f of report.findings.slice(0, 12)) {
1361
- L.push(`- ${SEV[f.severity] || "•"} \`${f.severity}\` ${f.title}${f.screen ? ` — on *${f.screen}*` : ""}`);
1577
+ L.push(`- ${SEV[f.severity] || "•"} \`${f.severity}\` ${f.title}${f.screen ? ` — on *${f.screen}*` : ""}${f.url ? ` — ${f.url}` : ""}`);
1362
1578
  if (f.aiAnalysis) L.push(` - why: ${String(f.aiAnalysis).slice(0, 200)}`);
1363
1579
  if (f.suggestedFix) L.push(` - fix: ${String(f.suggestedFix).slice(0, 200)}`);
1364
1580
  }
@@ -1375,7 +1591,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1375
1591
  // The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
1376
1592
  L.push("");
1377
1593
  L.push(
1378
- `**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only — run \`tapp ci\` to gate)`
1594
+ `**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only — run \`npx -y @aarwitz/tapp@latest ci\` to gate)`
1379
1595
  );
1380
1596
  }
1381
1597
  if (inputHint) {
@@ -1458,7 +1674,10 @@ export function formatScreen(screenTitle, elements) {
1458
1674
  // `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
1459
1675
  // the surfaces can't drift.)
1460
1676
 
1461
- export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], surface = "mcp", onProgress = () => {} }) {
1677
+ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
1678
+ const { storagePreflight } = await import("./environment-preflight.js");
1679
+ const storage = storagePreflight(capturesDir);
1680
+ if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
1462
1681
  const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
1463
1682
  const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
1464
1683
  const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
@@ -1475,6 +1694,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
1475
1694
  testPassword: isNonEmptyString(testPassword) ? testPassword.trim() : "",
1476
1695
  seedRoutes,
1477
1696
  seedTargets,
1697
+ watch: watch === true,
1478
1698
  onProgress,
1479
1699
  });
1480
1700
  } catch (err) {
@@ -1500,6 +1720,9 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
1500
1720
  }
1501
1721
 
1502
1722
  export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout, testEmail, testPassword, baselineFindings, clearData = true, seedTargets = [], surface = "mcp", onProgress = () => {} }) {
1723
+ const { storagePreflight } = await import("./environment-preflight.js");
1724
+ const storage = storagePreflight(capturesDir);
1725
+ if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
1503
1726
  const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
1504
1727
  const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
1505
1728
  const id = "android-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
@@ -1543,6 +1766,9 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
1543
1766
  }
1544
1767
 
1545
1768
  export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surface = "mcp", onProgress = () => {} }) {
1769
+ const { storagePreflight } = await import("./environment-preflight.js");
1770
+ const storage = storagePreflight(capturesDir);
1771
+ if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
1546
1772
  const captureScript = path.join(scriptsDir, "quick-capture.sh");
1547
1773
  if (!fs.existsSync(captureScript)) return { error: "Capture script not found", details: { captureScript } };
1548
1774
 
@@ -1635,6 +1861,7 @@ export async function runInitExploration({
1635
1861
  timeout,
1636
1862
  testEmail,
1637
1863
  testPassword,
1864
+ watch = false,
1638
1865
  onProgress = () => {},
1639
1866
  onStatus = () => {},
1640
1867
  } = {}) {
@@ -1650,17 +1877,18 @@ export async function runInitExploration({
1650
1877
  let targetResolution = null;
1651
1878
  let qa;
1652
1879
  let managedRuntime = null;
1880
+ if (watch && selected !== "web") return { error: "Watch mode is currently available for web exploration only." };
1653
1881
  if (selected === "web") {
1654
1882
  if (/^https?:\/\//i.test(String(url))) {
1655
1883
  resolvedTarget = String(url).trim();
1656
- qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
1884
+ qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
1657
1885
  } else {
1658
1886
  const started = await startManagedWebTarget({ root, requestedTarget: target, timeout, onStatus });
1659
1887
  if (started.error) return started;
1660
1888
  managedRuntime = started;
1661
1889
  resolvedTarget = started.url;
1662
1890
  try {
1663
- qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
1891
+ qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
1664
1892
  } finally {
1665
1893
  await stopManagedWebTarget(started);
1666
1894
  }
@@ -1772,6 +2000,7 @@ export async function runExploreTarget({
1772
2000
  appLaunchArgs,
1773
2001
  appLaunchEnv,
1774
2002
  baselineFindings,
2003
+ watch = false,
1775
2004
  surface = "cli",
1776
2005
  onProgress = () => {},
1777
2006
  onStatus = () => {},
@@ -1782,7 +2011,7 @@ export async function runExploreTarget({
1782
2011
 
1783
2012
  const modelPath = existingProjectArtifactPath(root, "application-model.json");
1784
2013
  if (!fs.existsSync(modelPath)) {
1785
- return { error: "No application model found — run `tapp init` first, or pass an explicit target (a bundle id, a path/to/App.app, a repo dir, --app-id/--apk, or an http(s) URL)." };
2014
+ return { error: "No application model found — call `tapp_init` or run `npx -y @aarwitz/tapp@latest init` first, or pass an explicit target (a bundle id, a path/to/App.app, a repo dir, --app-id/--apk, or an http(s) URL)." };
1786
2015
  }
1787
2016
  let model;
1788
2017
  try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
@@ -1794,6 +2023,8 @@ export async function runExploreTarget({
1794
2023
  catch (error) { return { error: error.message || String(error) }; }
1795
2024
  const selectedPlatform = selected.platform;
1796
2025
 
2026
+ if (watch && selectedPlatform !== "web") return { error: "Watch mode is currently available for web exploration only." };
2027
+
1797
2028
  if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
1798
2029
  return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
1799
2030
  }
@@ -1802,14 +2033,14 @@ export async function runExploreTarget({
1802
2033
  const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
1803
2034
  if (/^https?:\/\//i.test(ownedUrl)) {
1804
2035
  onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
1805
- return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
2036
+ return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
1806
2037
  }
1807
2038
  // Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
1808
2039
  onStatus(`Preparing the managed web runtime for ${selected.name}…`);
1809
2040
  const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
1810
2041
  if (started.error) return started;
1811
2042
  try {
1812
- return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
2043
+ return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
1813
2044
  } finally {
1814
2045
  await stopManagedWebTarget(started);
1815
2046
  onStatus("Stopped the managed web runtime.");
@@ -1818,7 +2049,7 @@ export async function runExploreTarget({
1818
2049
 
1819
2050
  if (selectedPlatform === "android") {
1820
2051
  const appId = String(selected.runtime?.applicationId || "").trim();
1821
- if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and rerun \`tapp init\`, or pass --app-id.` };
2052
+ if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and call \`tapp_init\` or rerun \`npx -y @aarwitz/tapp@latest init\`, or pass --app-id.` };
1822
2053
  const task = selected.build?.task || "assembleDebug";
1823
2054
  onStatus(`Building the Android APK (${task})…`);
1824
2055
  const built = await buildAndroidApp({
@@ -1856,6 +2087,22 @@ function openLocalPort() {
1856
2087
  });
1857
2088
  }
1858
2089
 
2090
+ function localPortAvailable(port) {
2091
+ return new Promise((resolve) => {
2092
+ const server = net.createServer();
2093
+ server.unref();
2094
+ server.once("error", () => resolve(false));
2095
+ server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
2096
+ });
2097
+ }
2098
+
2099
+ export function managedWebDefaultPort(dependencies = {}) {
2100
+ if (dependencies.vite) return 5173;
2101
+ if (dependencies.next) return 3000;
2102
+ if (dependencies["react-scripts"]) return 3000;
2103
+ return 0;
2104
+ }
2105
+
1859
2106
  function managedInstallSpec(command) {
1860
2107
  const known = {
1861
2108
  "npm ci": ["npm", ["ci"]],
@@ -1940,7 +2187,9 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
1940
2187
  const pkg = (() => { try { return JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")); } catch { return {}; } })();
1941
2188
  const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
1942
2189
  const declaredPort = startMatch ? declaredPortFromStartScript(pkg.scripts?.[startMatch[1]]) : 0;
1943
- const port = declaredPort || await openLocalPort();
2190
+ const frameworkPort = declaredPort ? 0 : managedWebDefaultPort(dependencies);
2191
+ const port = declaredPort || (frameworkPort && await localPortAvailable(frameworkPort) ? frameworkPort : await openLocalPort());
2192
+ const portBasis = declaredPort ? "repository-declared" : port === frameworkPort ? "framework-default" : "available-ephemeral";
1944
2193
  let command = "npm";
1945
2194
  let startArgs;
1946
2195
  let startDir = projectDir;
@@ -1967,13 +2216,13 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
1967
2216
  const append = (chunk) => fs.appendFileSync(logPath, String(chunk));
1968
2217
  child.stdout.on("data", append);
1969
2218
  child.stderr.on("data", append);
1970
- onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} → http://127.0.0.1:${port}`);
2219
+ onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} → http://127.0.0.1:${port} (${portBasis} port)`);
1971
2220
  const ready = await waitForOwnedUrl(`http://127.0.0.1:${port}`, child, Math.min(budgetMs, 60_000), logPath);
1972
2221
  if (ready.error) {
1973
2222
  await stopManagedWebTarget({ child, detached: process.platform !== "win32" });
1974
2223
  return ready;
1975
2224
  }
1976
- return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir } };
2225
+ return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir, portBasis } };
1977
2226
  }
1978
2227
 
1979
2228
  export async function stopManagedWebTarget(runtime) {
@@ -2032,11 +2281,62 @@ const server = new Server(
2032
2281
  },
2033
2282
  {
2034
2283
  capabilities: {
2284
+ prompts: {},
2035
2285
  tools: {},
2036
2286
  },
2037
2287
  }
2038
2288
  );
2039
2289
 
2290
+ const testAppPrompt = {
2291
+ name: "test-app",
2292
+ title: "Test this app with Tapp",
2293
+ description: "Use Tapp's real app surfaces to inspect, drive, or explore this repository and report evidence honestly.",
2294
+ arguments: [
2295
+ {
2296
+ name: "goal",
2297
+ description: "What to verify, such as finding bugs or exercising checkout",
2298
+ required: false,
2299
+ },
2300
+ {
2301
+ name: "target",
2302
+ description: "Optional repo target, bundle/app id, APK path, or owned URL",
2303
+ required: false,
2304
+ },
2305
+ ],
2306
+ };
2307
+
2308
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [testAppPrompt] }));
2309
+
2310
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
2311
+ if (request.params.name !== testAppPrompt.name) {
2312
+ throw new Error(`Unknown prompt: ${request.params.name}`);
2313
+ }
2314
+ const goal = isNonEmptyString(request.params.arguments?.goal)
2315
+ ? request.params.arguments.goal.trim()
2316
+ : "Test the app and find important bugs";
2317
+ const target = isNonEmptyString(request.params.arguments?.target)
2318
+ ? ` Use this target: ${request.params.arguments.target.trim()}.`
2319
+ : "";
2320
+ return {
2321
+ description: testAppPrompt.description,
2322
+ messages: [
2323
+ {
2324
+ role: "user",
2325
+ content: {
2326
+ type: "text",
2327
+ text:
2328
+ `${goal}.${target} Use the connected Tapp tools on the real UI surface. ` +
2329
+ "Use the smallest operation that satisfies the request; initialize/explore the source repo only for a general repository test. " +
2330
+ "For a named screen/control, pass the exact request as session focus or call tapp_focus so Tapp uses source + its observed UI Map instead of wandering. " +
2331
+ "If Tapp returns multiple target choices, ask me to select one instead of guessing. " +
2332
+ "Read visual evidence before describing it. Report findings, coverage, authority, inconclusive state, and checked/not-checked scope; " +
2333
+ "never turn exploration into a score or ship verdict. Do not edit the app unless I ask for a fix.",
2334
+ },
2335
+ },
2336
+ ],
2337
+ };
2338
+ });
2339
+
2040
2340
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
2041
2341
  tools: [
2042
2342
  {
@@ -2208,12 +2508,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2208
2508
  androidSerial: { type: "string", description: "Android: optional adb device serial; defaults to the first authorized device." },
2209
2509
  clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
2210
2510
  url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
2511
+ watch: { type: "boolean", default: false, description: "Web only: open Tapp's controlled Chromium window and show a cursor/HUD for each exploration action. Evidence screenshots exclude the overlay." },
2211
2512
  maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
2212
2513
  timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
2213
2514
  testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
2214
2515
  testPassword: { type: "string", description: "Password for the login preamble" },
2215
2516
  interactive: { type: "boolean", description: "Host-with-a-human only (e.g. the VS Code extension): pause at input screens and wait for values via interactiveResponsePath. Plain agents: omit." },
2216
2517
  interactiveResponsePath: { type: "string", description: "File path the prompting host answers on (requests appear at <path>.request)" },
2518
+ visualReadyPath: { type: "string", description: "Local-client temp path written once the iOS target is foregrounded and settled, so previews exclude build/install footage" },
2217
2519
  inputOverrides: {
2218
2520
  type: "object",
2219
2521
  additionalProperties: { type: "string" },
@@ -2250,7 +2552,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2250
2552
  "Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
2251
2553
  "When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
2252
2554
  "newFindings, resolved} vs. that baseline — an observation, not a gate signal. To gate a merge, " +
2253
- "run `tapp ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
2555
+ "run `npx -y @aarwitz/tapp@latest ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
2254
2556
  "pass/fail/inconclusive outcome.",
2255
2557
  },
2256
2558
  },
@@ -2278,6 +2580,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2278
2580
  timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600 },
2279
2581
  testEmail: { type: "string", description: "Explore: actor/login email; never persisted in the model" },
2280
2582
  testPassword: { type: "string", description: "Explore: actor/login password; never persisted in the model" },
2583
+ watch: { type: "boolean", default: false, description: "Web explore only: show the controlled browser and Tapp's actions" },
2281
2584
  maxContracts: { type: "integer", minimum: 1, maximum: 50, default: 15 },
2282
2585
  outDir: { type: "string", description: "Repo-relative artifact directory; default .tapp" },
2283
2586
  },
@@ -2661,10 +2964,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2661
2964
  name: "tapp_session_start",
2662
2965
  title: "Start interactive session",
2663
2966
  description:
2664
- "Start a PERSISTENT interactive session against an installed iOS or Android app. The app " +
2967
+ "Start a PERSISTENT interactive session against an installed iOS/Android app or a web URL. The app " +
2665
2968
  "launches once and stays up, so you can drive a Playwright-style tap → inspect loop without a cold " +
2666
2969
  "launch per action. Returns the initial screen {screenTitle, elements[]}. Drive it with " +
2667
- "tapp_session_act and finish with tapp_session_end. Only one session at a time. Starts from a " +
2970
+ "tapp_focus for any named destination (source + shortest observed route), then tapp_session_act only for remaining actions; finish with tapp_session_end. " +
2971
+ "For a focused user request, ALWAYS pass it in `focus` so the session reaches that surface before returning. Only one session at a time. Starts from a " +
2668
2972
  "fresh launch. Use appLaunchArgs/appLaunchEnv for apps that need a backend override or login bypass. " +
2669
2973
  "When you reach a screen with input fields and don't have values for them, ASK THE USER what to type " +
2670
2974
  "(offer defaults/skip) before typing — the session does not prompt on its own.",
@@ -2674,6 +2978,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2674
2978
  authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
2675
2979
  appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
2676
2980
  androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
2981
+ url: { type: "string", description: "Owned http(s) web app URL to drive (alternative to appBundleId/androidAppId)" },
2677
2982
  apkPath: { type: "string", description: "Android APK to install before starting" },
2678
2983
  androidSerial: { type: "string", description: "Android adb device serial" },
2679
2984
  clearData: { type: "boolean", default: true, description: "Android: clear app data before launch" },
@@ -2681,6 +2986,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2681
2986
  testPassword: { type: "string", description: "Password available to the app/harness" },
2682
2987
  appLaunchArgs: { type: "array", items: { type: "string" }, description: "Launch arguments, e.g. [\"--uitesting\"]" },
2683
2988
  appLaunchEnv: { type: "object", additionalProperties: { type: "string" }, description: "Launch environment, e.g. {\"UI_TEST_BACKEND\": \"staging\"}" },
2989
+ focus: { type: "string", description: "Exact focused UI goal/control/screen to locate from repository source and reach through the shortest observed UI Map route before returning" },
2990
+ projectDir: { type: "string", description: "Repository root for source-connected focus; defaults to the MCP workspace" },
2991
+ mapPath: { type: "string", description: "Optional repository-relative UI Map for focus; defaults to .tapp/ui-map.json" },
2992
+ },
2993
+ },
2994
+ },
2995
+ {
2996
+ name: "tapp_focus",
2997
+ title: "Locate and reach a requested UI surface",
2998
+ description:
2999
+ "The FAST source-connected path for focused tasks. Locate a requested screen/control from owned repository source, reconcile it with Tapp's runtime-observed UI Map, and—when a session is active—execute the shortest replayable route in one call. Use before step-by-step tapping when the user names a destination such as 'Save storefront settings'. Source identifies intent; Tapp never invents navigation from source, and only observed/validated UI Map edges are executed. Without an active session this returns the grounded location/route plan without acting.",
3000
+ inputSchema: {
3001
+ type: "object",
3002
+ required: ["query"],
3003
+ properties: {
3004
+ authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
3005
+ query: { type: "string", description: "User's requested screen, control, or focused UI task" },
3006
+ projectDir: { type: "string", description: "Repository root; defaults to the MCP workspace" },
3007
+ platform: { type: "string", enum: ["ios", "android", "web"], description: "Optional without an active session; an active session is authoritative" },
3008
+ mapPath: { type: "string", description: "Optional repository-relative UI Map; defaults to .tapp/ui-map.json" },
2684
3009
  },
2685
3010
  },
2686
3011
  },
@@ -2731,45 +3056,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2731
3056
  const { name, arguments: args = {} } = request.params;
2732
3057
 
2733
3058
  if (name === "tapp_health") {
2734
- const checks = [];
2735
-
2736
- checks.push({
2737
- check: "repoRoot",
2738
- ok: fs.existsSync(path.join(repoRoot, "Tapp.xcodeproj")),
2739
- value: repoRoot,
3059
+ const coreChecks = [];
3060
+ coreChecks.push({
3061
+ check: "workspace",
3062
+ ok: fs.existsSync(workspaceRoot) && fs.statSync(workspaceRoot).isDirectory(),
3063
+ value: workspaceRoot,
3064
+ required: true,
2740
3065
  });
2741
3066
 
2742
3067
  const nodeVersion = await runCommand("node", ["-v"]);
2743
- checks.push({
3068
+ coreChecks.push({
2744
3069
  check: "node",
2745
3070
  ok: nodeVersion.code === 0,
2746
3071
  value: nodeVersion.stdout.trim() || nodeVersion.stderr.trim(),
3072
+ required: true,
2747
3073
  });
2748
3074
 
2749
3075
  const xcodebuildVersion = await runCommand("xcodebuild", ["-version"]);
2750
- checks.push({
2751
- check: "xcodebuild",
2752
- ok: xcodebuildVersion.code === 0,
2753
- value: (xcodebuildVersion.stdout || xcodebuildVersion.stderr).trim().split("\n")[0] || "not found",
2754
- });
2755
-
2756
- const simctl = await runCommand("xcrun", ["simctl", "list", "devices", "booted"]);
2757
- checks.push({
2758
- check: "bootedSimulator",
2759
- ok: simctl.code === 0,
2760
- value: (simctl.stdout || simctl.stderr).trim(),
2761
- });
2762
-
2763
- const allOk = checks.every((c) => c.ok);
3076
+ const simctl = xcodebuildVersion.code === 0
3077
+ ? await runCommand("xcrun", ["simctl", "list", "devices", "booted"])
3078
+ : { code: 1, stdout: "", stderr: "Xcode not found" };
2764
3079
  const bootedLine = (simctl.stdout || "").split("\n").find((l) => /\(Booted\)/.test(l));
2765
3080
  const bootedName = bootedLine ? bootedLine.trim().replace(/\s*\(.*$/, "") : null;
2766
- const L = [`### ${allOk ? "🩺 Tapp ready" : "⚠️ Tapp not fully ready"}`, ""];
3081
+ const adb = await runCommand("adb", ["devices"]);
3082
+ const androidDevice = adb.code === 0
3083
+ ? (adb.stdout || "").split("\n").find((line) => /\tdevice\s*$/.test(line))
3084
+ : null;
3085
+ let web = { ok: false, value: "Playwright or Chromium not installed" };
3086
+ try {
3087
+ const { chromium } = await import("playwright");
3088
+ const executable = chromium.executablePath();
3089
+ web = { ok: fs.existsSync(executable), value: fs.existsSync(executable) ? executable : "Chromium not installed" };
3090
+ } catch { /* optional dependency may be intentionally absent */ }
3091
+ const platformChecks = [
3092
+ {
3093
+ check: "iOS",
3094
+ ok: xcodebuildVersion.code === 0 && !!bootedName,
3095
+ value: bootedName ? `${bootedName} booted` : xcodebuildVersion.code === 0 ? "Xcode available; no simulator booted" : "Xcode not found",
3096
+ required: false,
3097
+ },
3098
+ {
3099
+ check: "Android",
3100
+ ok: !!androidDevice,
3101
+ value: androidDevice ? `${androidDevice.split("\t")[0]} connected` : adb.code === 0 ? "adb available; no authorized device" : "adb not found",
3102
+ required: false,
3103
+ },
3104
+ { check: "web", ok: web.ok, value: web.value, required: false },
3105
+ ];
3106
+ const checks = [...coreChecks, ...platformChecks];
3107
+ const ready = coreChecks.every((check) => check.ok) && platformChecks.some((check) => check.ok);
3108
+ const L = [`### ${ready ? "🩺 Tapp ready" : "⚠️ Tapp needs a platform runtime"}`, ""];
2767
3109
  for (const c of checks) {
2768
- L.push(`- ${c.ok ? "✅" : "❌"} **${c.check}** ${String(c.value).split("\n")[0] || ""}`);
3110
+ const icon = c.ok ? "✅" : c.required ? "" : "⚪️";
3111
+ L.push(`- ${icon} **${c.check}** — ${String(c.value).split("\n")[0] || "—"}`);
2769
3112
  }
2770
- L.push("");
2771
- L.push(bootedName ? `📱 Simulator booted: **${bootedName}**` : "📱 No simulator booted run `tapp_boot_simulator` first.");
2772
- return richResult(L.join("\n"), { ok: allOk, checks });
3113
+ if (!ready) L.push("", "Run `npx -y @aarwitz/tapp@latest doctor` in the application repository for exact remediation.");
3114
+ return richResult(L.join("\n"), { ok: ready, workspaceRoot, checks, platforms: Object.fromEntries(platformChecks.map((check) => [check.check.toLowerCase(), check.ok])) });
2773
3115
  }
2774
3116
 
2775
3117
  if (name === "tapp_build") {
@@ -2788,11 +3130,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2788
3130
  if (inst.error) return errorResult(inst.error);
2789
3131
  bundleId = inst.bundleId;
2790
3132
  }
3133
+ let modelRefresh = null;
3134
+ let modelRefreshWarning = "";
3135
+ if (bundleId) {
3136
+ try {
3137
+ const { persistIosBuildValidation } = await import("./application-model.js");
3138
+ modelRefresh = await persistIosBuildValidation({ projectDir: dir, bundleId, container: built.container, scheme: built.scheme, configuration: built.configuration });
3139
+ } catch (error) {
3140
+ modelRefreshWarning = error.message || String(error);
3141
+ }
3142
+ }
2791
3143
  const text =
2792
3144
  `🔨 Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
2793
3145
  (bundleId ? ` — installed on the simulator as \`${bundleId}\`` : "") +
2794
3146
  `\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2795
- return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
3147
+ return richResult(text + (modelRefresh ? `\nApplication model refreshed: \`${modelRefresh.modelPath}\`.` : modelRefreshWarning ? `\n⚠️ Application model refresh failed: ${modelRefreshWarning}` : ""), { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId, modelRefreshed: !!modelRefresh, ...(modelRefreshWarning ? { modelRefreshWarning } : {}) });
2796
3148
  }
2797
3149
 
2798
3150
  if (name === "tapp_capture") {
@@ -2966,6 +3318,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2966
3318
  if (targets !== 1) {
2967
3319
  return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web beta)");
2968
3320
  }
3321
+ if (args.watch === true && !wantsWeb) return errorResult("watch is currently available for web exploration only");
2969
3322
 
2970
3323
  // Both branches call the shared engine (runQaWeb/runQaIos) — the handler only adds
2971
3324
  // MCP concerns: auth, arg validation, and progress notifications.
@@ -2987,6 +3340,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2987
3340
  testEmail: args.testEmail,
2988
3341
  testPassword: args.testPassword,
2989
3342
  baselineFindings: args.baselineFindings,
3343
+ watch: args.watch === true,
2990
3344
  onProgress: notifyProgress("pages reached"),
2991
3345
  });
2992
3346
  if (r.error) return errorResult(r.error, r.details || {});
@@ -3031,8 +3385,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3031
3385
  if (unauthorized) return unauthorized;
3032
3386
  const operation = String(args.operation || "inspect").toLowerCase();
3033
3387
  if (!["inspect", "write", "refresh", "explore"].includes(operation)) return errorResult("operation must be inspect|write|refresh|explore");
3034
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3035
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3388
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3389
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3036
3390
  const maxContracts = asInteger(args.maxContracts, 15);
3037
3391
  if (maxContracts < 1 || maxContracts > 50) return errorResult("maxContracts must be between 1 and 50");
3038
3392
  const { initializeProductProject } = await import("./product-operations.js");
@@ -3042,32 +3396,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3042
3396
  if (!isInsideDir(projectDir, resolvedOut)) return errorResult("outDir must be inside projectDir");
3043
3397
  const selectedPlatform = isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase()
3044
3398
  : isNonEmptyString(args.url) ? "web"
3045
- : isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "ios";
3399
+ : isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "";
3046
3400
  const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
3047
3401
  const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 40)));
3048
3402
  const result = await initializeProductProject({
3049
3403
  projectDir, mode: operation, outDir,
3050
3404
  ownedUrl: isNonEmptyString(args.url) ? args.url.trim() : "",
3051
3405
  platform: isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase() : operation === "explore" ? selectedPlatform : "",
3052
- target: isNonEmptyString(args.target) ? args.target.trim() : projectDir,
3406
+ target: isNonEmptyString(args.target) ? args.target.trim() : "",
3053
3407
  bundleId: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : "",
3054
3408
  appId: isNonEmptyString(args.androidAppId) ? args.androidAppId.trim() : "",
3055
3409
  apkPath: isNonEmptyString(args.apkPath) ? path.resolve(projectDir, args.apkPath.trim()) : undefined,
3056
3410
  serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
3057
3411
  maxActions: args.maxActions, timeout: args.timeout, maxContracts,
3058
3412
  testEmail: args.testEmail, testPassword: args.testPassword,
3413
+ watch: args.watch === true,
3059
3414
  runExploration: runInitExploration,
3060
3415
  onProgress: (progress) => {
3061
3416
  if (progressToken === undefined) return;
3062
3417
  server.notification({ method: "notifications/progress", params: { progressToken, progress: progress.action || 0, total: progress.max || budget, message: `Import exploration · ${progress.states} state(s) reached` } }).catch(() => {});
3063
3418
  },
3064
3419
  });
3065
- const { model, plan, written, exploration } = result;
3066
- const blocking = model.requirements.filter((item) => item.severity === "blocking");
3420
+ const { model, plan, written, exploration, selectedTarget, requirementScope } = result;
3421
+ const blocking = (requirementScope?.active || model.requirements).filter((item) => item.severity === "blocking");
3422
+ const deferredBlocking = (requirementScope?.deferred || []).filter((item) => item.severity === "blocking");
3067
3423
  const pending = plan.items.filter((item) => item.decision === "pending");
3068
- const summary = `🧭 Tapp init — ${model.application.name} · ${model.targets.length} target(s) · UI Map ${model.uiMap.status} (${model.uiMap.nodeCount} states/${model.uiMap.edgeCount} transitions) · ${plan.items.length} plan item(s), ${pending.length} pending · ${blocking.length} blocking requirement(s)${exploration ? ` · real ${exploration.platform} exploration: ${(exploration.findings || []).length} finding(s)${exploration.inconclusive ? " (inconclusive)" : ""}` : ""}`;
3069
- return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
3070
- } catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
3424
+ const selectedLabel = selectedTarget ? ` for ${selectedTarget.platform}:${selectedTarget.name}` : "";
3425
+ const deferredLabel = deferredBlocking.length ? ` · ${deferredBlocking.length} setup gap(s) on unselected target(s)` : "";
3426
+ const summary = `🧭 Tapp init — ${model.application.name} · ${model.targets.length} target(s) · UI Map ${model.uiMap.status} (${model.uiMap.nodeCount} states/${model.uiMap.edgeCount} transitions) · ${plan.items.length} plan item(s), ${pending.length} pending · ${blocking.length} blocking requirement(s)${selectedLabel}${deferredLabel}${exploration ? ` · real ${exploration.platform} exploration: ${(exploration.findings || []).length} finding(s)${exploration.inconclusive ? " (inconclusive)" : ""}` : ""}`;
3427
+ return richResult(summary, { model, plan, selectedTarget, requirementScope, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
3428
+ } catch (error) {
3429
+ const detail = error.message || String(error);
3430
+ const message = error.details?.reason === "target-selection-required"
3431
+ ? detail
3432
+ : "Could not initialize Tapp repository artifacts";
3433
+ return errorResult(message, { detail, ...(error.details || {}) });
3434
+ }
3071
3435
  }
3072
3436
 
3073
3437
  if (name === "tapp_actor_config") {
@@ -3078,8 +3442,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3078
3442
  const allowedArguments = new Set(["authToken", "operation", "projectDir", "name", "role", "session", "provisioning", "credentialBindings", "replace"]);
3079
3443
  const unexpectedArguments = Object.keys(args).filter((key) => !allowedArguments.has(key));
3080
3444
  if (unexpectedArguments.length) return errorResult("Unsupported actor configuration fields; credential values are never accepted", { fields: unexpectedArguments });
3081
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3082
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the repo");
3445
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3446
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
3083
3447
  const { configureActor, readProjectConfig } = await import("./project-config.js");
3084
3448
  if (operation === "read") {
3085
3449
  const loaded = readProjectConfig(projectDir);
@@ -3099,7 +3463,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3099
3463
  credentials,
3100
3464
  replace: asBoolean(args.replace),
3101
3465
  });
3102
- return richResult(`✅ Actor '${args.name.trim()}' configured with ${Object.keys(result.actor.credentials).length} environment binding(s); no credential values were accepted or written`, { path: result.path, actor: result.actor, next: "Run tapp_init refresh to update the application model and release plan." });
3466
+ const { refreshExistingInitArtifacts } = await import("./application-model.js");
3467
+ let refreshed = null;
3468
+ let refreshWarning = "";
3469
+ try { refreshed = await refreshExistingInitArtifacts({ projectDir }); }
3470
+ catch (error) { refreshWarning = error.message || String(error); }
3471
+ return richResult(
3472
+ `✅ Actor '${args.name.trim()}' configured with ${Object.keys(result.actor.credentials).length} environment binding(s); no credential values were accepted or written${refreshed ? " · application model refreshed" : ""}${refreshWarning ? `\n⚠️ Application model refresh failed: ${refreshWarning}` : ""}`,
3473
+ { path: result.path, actor: result.actor, modelRefreshed: !!refreshed, ...(refreshed ? { modelPath: refreshed.modelPath, planPath: refreshed.planPath } : {}), ...(refreshWarning ? { refreshWarning } : {}) },
3474
+ );
3103
3475
  } catch (error) { return errorResult("Actor not configured", { detail: error.message || String(error) }); }
3104
3476
  }
3105
3477
 
@@ -3108,8 +3480,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3108
3480
  if (unauthorized) return unauthorized;
3109
3481
  const operation = String(args.operation || "read").toLowerCase();
3110
3482
  if (!["read", "review", "generate", "validate", "promote"].includes(operation)) return errorResult("operation must be read|review|generate|validate|promote");
3111
- const planPath = isNonEmptyString(args.planPath) ? path.resolve(repoRoot, args.planPath.trim()) : existingProjectArtifactPath(repoRoot, "release-plan.json");
3112
- if (!isInsideDir(repoRoot, planPath)) return errorResult("planPath must be inside the repo");
3483
+ const planPath = isNonEmptyString(args.planPath) ? path.resolve(workspaceRoot, args.planPath.trim()) : existingProjectArtifactPath(workspaceRoot, "release-plan.json");
3484
+ if (!isInsideDir(workspaceRoot, planPath)) return errorResult("planPath must be inside the workspace");
3113
3485
  if (!fs.existsSync(planPath)) return errorResult("Release plan not found", { planPath });
3114
3486
  let plan;
3115
3487
  try { plan = JSON.parse(fs.readFileSync(planPath, "utf8")); }
@@ -3119,11 +3491,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3119
3491
  if (![...decisions.approve, ...decisions.reject, ...decisions.defer].length) return errorResult("review requires at least one approve, reject, or defer item");
3120
3492
  const { reviewProductPlan } = await import("./product-operations.js");
3121
3493
  try {
3122
- plan = reviewProductPlan({ projectDir: repoRoot, planPath, ...decisions }).plan;
3494
+ plan = reviewProductPlan({ projectDir: workspaceRoot, planPath, ...decisions }).plan;
3123
3495
  } catch (error) { return errorResult("Could not review release plan", { detail: error.message || String(error) }); }
3124
3496
  } else if (operation === "generate") {
3125
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3126
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3497
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3498
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3127
3499
  const { generateProductPlan } = await import("./product-operations.js");
3128
3500
  try {
3129
3501
  const result = await generateProductPlan({ projectDir, planPath });
@@ -3131,8 +3503,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3131
3503
  return richResult(`🧩 Proposal drafts — ${result.generatedTasks.length} UI-Map-grounded Task(s) · ${result.generated.length} compile-checked/untrusted contract(s) · ${result.blocked.length} blocked; deterministic real-surface replay remains required`, { plan, planPath, generatedTasks: result.generatedTasks, generated: result.generated, blocked: result.blocked });
3132
3504
  } catch (error) { return errorResult("Could not generate contract drafts", { detail: error.message || String(error) }); }
3133
3505
  } else if (operation === "validate") {
3134
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3135
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3506
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3507
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3136
3508
  let apkPath = "";
3137
3509
  if (isNonEmptyString(args.apkPath)) {
3138
3510
  apkPath = path.resolve(projectDir, args.apkPath.trim());
@@ -3164,8 +3536,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3164
3536
  return richResult(`🔎 Generated contract validation passed — ${result.results.length}/${result.results.length} on ${result.platform}`, { ...result, planPath });
3165
3537
  } catch (error) { return errorResult("Generated contract validation failed", { detail: error.message || String(error), plan, planPath }); }
3166
3538
  } else if (operation === "promote") {
3167
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3168
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3539
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3540
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3169
3541
  const { promoteProductPlan } = await import("./product-operations.js");
3170
3542
  try {
3171
3543
  const result = await promoteProductPlan({ projectDir, planPath, items: Array.isArray(args.items) ? args.items : [] });
@@ -3181,9 +3553,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3181
3553
  if (unauthorized) return unauthorized;
3182
3554
  const operation = String(args.operation || "inspect").toLowerCase();
3183
3555
  if (!["inspect", "install", "baseline"].includes(operation)) return errorResult("operation must be inspect|install|baseline");
3184
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3185
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the repo");
3186
- const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(repoRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
3556
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3557
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
3558
+ const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(workspaceRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
3187
3559
  if (!isInsideDir(projectDir, modelPath) || !fs.existsSync(modelPath)) return errorResult("Application model not found inside projectDir; run tapp_init first", { modelPath });
3188
3560
  let model;
3189
3561
  try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
@@ -3191,8 +3563,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3191
3563
  const { createProductBaseline, installProductCi, prepareProductCi } = await import("./product-operations.js");
3192
3564
  if (operation === "baseline") {
3193
3565
  if (!isNonEmptyString(args.reportPath)) return errorResult("baseline requires reportPath from a successful portable gate");
3194
- const reportPath = path.resolve(repoRoot, args.reportPath.trim());
3195
- if (!isInsideDir(repoRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the repo");
3566
+ const reportPath = path.resolve(workspaceRoot, args.reportPath.trim());
3567
+ if (!isInsideDir(workspaceRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the workspace");
3196
3568
  let report;
3197
3569
  try { report = JSON.parse(fs.readFileSync(reportPath, "utf8")); }
3198
3570
  catch (error) { return errorResult("Gate report is invalid JSON", { detail: error.message || String(error) }); }
@@ -3217,8 +3589,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3217
3589
  if (unauthorized) return unauthorized;
3218
3590
  const operation = String(args.operation || "read").toLowerCase();
3219
3591
  const resolveRepoFile = (value, fallback = "") => {
3220
- const resolved = path.resolve(repoRoot, isNonEmptyString(value) ? value.trim() : fallback);
3221
- return isInsideDir(repoRoot, resolved) ? resolved : null;
3592
+ const resolved = path.resolve(workspaceRoot, isNonEmptyString(value) ? value.trim() : fallback);
3593
+ return isInsideDir(workspaceRoot, resolved) ? resolved : null;
3222
3594
  };
3223
3595
  const capture = isNonEmptyString(args.captureId) ? listCaptureRuns(200).find((run) => run.id === args.captureId.trim()) : null;
3224
3596
  if (isNonEmptyString(args.captureId) && !capture) return errorResult("Capture not found", { captureId: args.captureId });
@@ -3238,18 +3610,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3238
3610
  const markersPath = capture ? path.join(capture.path, "ocqa-markers.txt") : resolveRepoFile(args.markersPath);
3239
3611
  if (!markersPath) return errorResult("markersPath must be inside the repo, or provide captureId");
3240
3612
  if (!fs.existsSync(markersPath)) return errorResult("OCQA markers not found", { markersPath });
3241
- const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(repoRoot, "ui-map.json");
3613
+ const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
3242
3614
  if (!outPath) return errorResult("mapPath must be inside the repo");
3243
3615
  try {
3244
3616
  const observed = buildUiMapFromMarkers({ markersPath, platform: args.platform || "ios", target: args.target || "", runId: capture?.id || "" });
3245
3617
  const map = fs.existsSync(outPath) && args.replace !== true ? mergeUiMaps(JSON.parse(fs.readFileSync(outPath, "utf8")), observed) : observed;
3246
3618
  writeUiMap(outPath, map);
3247
3619
  const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
3248
- return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(repoRoot, outPath)}`, { map, path: outPath });
3620
+ return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(workspaceRoot, outPath)}`, { map, path: outPath });
3249
3621
  } catch (error) { return errorResult("Could not build UI Map", { detail: error.message || String(error) }); }
3250
3622
  }
3251
3623
  if (operation !== "read") return errorResult("operation must be read|build|diff");
3252
- const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(repoRoot, "ui-map.json");
3624
+ const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
3253
3625
  if (!mapPath) return errorResult("mapPath must be inside the repo");
3254
3626
  if (!fs.existsSync(mapPath)) return errorResult("UI Map not found; run QA or operation=build first", { mapPath });
3255
3627
  try {
@@ -3266,8 +3638,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3266
3638
  if (unauthorized) return unauthorized;
3267
3639
  const operation = String(args.operation || "validate").toLowerCase();
3268
3640
  if (!["read", "validate", "compile"].includes(operation)) return errorResult("operation must be read|validate|compile");
3269
- const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(repoRoot, args.taskPath.trim()) : null;
3270
- if (!taskPath || !isInsideDir(repoRoot, taskPath)) return errorResult("taskPath must be inside the repo");
3641
+ const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(workspaceRoot, args.taskPath.trim()) : null;
3642
+ if (!taskPath || !isInsideDir(workspaceRoot, taskPath)) return errorResult("taskPath must be inside the workspace");
3271
3643
  if (!fs.existsSync(taskPath)) return errorResult("Task file not found", { taskPath: args.taskPath });
3272
3644
  const { applyTaskCoverage, compileTaskSteps, loadTaskFile, loadTaskRegistry, validateTaskAgainstUiMap } = await import("./task-runtime.js");
3273
3645
  let task;
@@ -3278,8 +3650,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3278
3650
  let groundingMap = null;
3279
3651
  let groundingMapPath = null;
3280
3652
  if (isNonEmptyString(args.mapPath)) {
3281
- const mapPath = path.resolve(repoRoot, args.mapPath.trim());
3282
- if (!isInsideDir(repoRoot, mapPath)) return errorResult("mapPath must be inside the repo");
3653
+ const mapPath = path.resolve(workspaceRoot, args.mapPath.trim());
3654
+ if (!isInsideDir(workspaceRoot, mapPath)) return errorResult("mapPath must be inside the workspace");
3283
3655
  if (!fs.existsSync(mapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
3284
3656
  groundingMapPath = mapPath;
3285
3657
  try {
@@ -3309,12 +3681,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3309
3681
  const flow = { name: `Task: ${task.name}`, kind: "flow", platform: args.platform || "", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan };
3310
3682
  let outPath = null;
3311
3683
  if (isNonEmptyString(args.outPath)) {
3312
- outPath = path.resolve(repoRoot, args.outPath.trim());
3313
- if (!isInsideDir(repoRoot, outPath)) return errorResult("outPath must be inside the repo");
3684
+ outPath = path.resolve(workspaceRoot, args.outPath.trim());
3685
+ if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
3314
3686
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
3315
3687
  fs.writeFileSync(outPath, JSON.stringify(flow, null, 2) + "\n");
3316
3688
  }
3317
- return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(repoRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
3689
+ return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(workspaceRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
3318
3690
  }
3319
3691
 
3320
3692
  if (name === "tapp_release_contract") {
@@ -3322,8 +3694,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3322
3694
  if (unauthorized) return unauthorized;
3323
3695
  const operation = String(args.operation || "validate").toLowerCase();
3324
3696
  if (!["read", "validate", "compile", "run"].includes(operation)) return errorResult("operation must be read|validate|compile|run");
3325
- const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(repoRoot, args.contractPath.trim()) : null;
3326
- if (!contractPath || !isInsideDir(repoRoot, contractPath)) return errorResult("contractPath must be inside the repo");
3697
+ const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(workspaceRoot, args.contractPath.trim()) : null;
3698
+ if (!contractPath || !isInsideDir(workspaceRoot, contractPath)) return errorResult("contractPath must be inside the workspace");
3327
3699
  const {
3328
3700
  applyReleaseContractCoverage,
3329
3701
  compileReleaseContract,
@@ -3338,8 +3710,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3338
3710
  let groundingMap = null;
3339
3711
  let groundingMapPath = null;
3340
3712
  if (isNonEmptyString(args.mapPath)) {
3341
- groundingMapPath = path.resolve(repoRoot, args.mapPath.trim());
3342
- if (!isInsideDir(repoRoot, groundingMapPath)) return errorResult("mapPath must be inside the repo");
3713
+ groundingMapPath = path.resolve(workspaceRoot, args.mapPath.trim());
3714
+ if (!isInsideDir(workspaceRoot, groundingMapPath)) return errorResult("mapPath must be inside the workspace");
3343
3715
  if (!fs.existsSync(groundingMapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
3344
3716
  try {
3345
3717
  groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
@@ -3360,8 +3732,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3360
3732
  catch (error) { return errorResult("Could not compile Release Contract", { detail: error.message || String(error) }); }
3361
3733
  let outPath = null;
3362
3734
  if (isNonEmptyString(args.outPath)) {
3363
- outPath = path.resolve(repoRoot, args.outPath.trim());
3364
- if (!isInsideDir(repoRoot, outPath)) return errorResult("outPath must be inside the repo");
3735
+ outPath = path.resolve(workspaceRoot, args.outPath.trim());
3736
+ if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
3365
3737
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
3366
3738
  fs.writeFileSync(outPath, JSON.stringify(execution, null, 2) + "\n");
3367
3739
  }
@@ -3408,12 +3780,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3408
3780
  if (unauthorized) return unauthorized;
3409
3781
  const operation = isNonEmptyString(args.operation) ? args.operation.trim().toLowerCase() : "plan";
3410
3782
  if (!['plan', 'adopt'].includes(operation)) return errorResult("operation must be plan|adopt");
3411
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3412
- if (!isInsideDir(repoRoot, projectDir)) return errorResult("projectDir must be inside the repo");
3783
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3784
+ if (!isInsideDir(workspaceRoot, projectDir)) return errorResult("projectDir must be inside the workspace");
3413
3785
  if (operation === "adopt") {
3414
3786
  if (!isNonEmptyString(args.prPlanPath) || !isNonEmptyString(args.item)) return errorResult("adopt requires prPlanPath and item");
3415
3787
  const prPlanPath = path.resolve(projectDir, args.prPlanPath.trim());
3416
- if (!isInsideDir(repoRoot, prPlanPath)) return errorResult("prPlanPath must be inside the repo");
3788
+ if (!isInsideDir(workspaceRoot, prPlanPath)) return errorResult("prPlanPath must be inside the workspace");
3417
3789
  const { adoptPrCoverageProposal } = await import("./pr-selection.js");
3418
3790
  try {
3419
3791
  const adopted = adoptPrCoverageProposal({
@@ -3457,8 +3829,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3457
3829
  fs.writeFileSync(flowFile, JSON.stringify(args.flow));
3458
3830
  parsedFlow = args.flow;
3459
3831
  } else if (isNonEmptyString(args.flowPath)) {
3460
- const p = path.resolve(repoRoot, args.flowPath.trim());
3461
- if (!isInsideDir(repoRoot, p)) return errorResult("flowPath must be inside the repo");
3832
+ const p = path.resolve(workspaceRoot, args.flowPath.trim());
3833
+ if (!isInsideDir(workspaceRoot, p)) return errorResult("flowPath must be inside the workspace");
3462
3834
  if (!fs.existsSync(p)) return errorResult("Flow file not found", { flowPath: args.flowPath });
3463
3835
  flowFile = p;
3464
3836
  } else {
@@ -3477,15 +3849,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3477
3849
  (args.url || parsedFlow.url || /^https?:\/\//i.test(parsedFlow.app || "")) ? "web" : "ios")
3478
3850
  ).toLowerCase();
3479
3851
 
3480
- const flowLog = path.join(os.tmpdir(), `mcp-flow-${Date.now()}.log`);
3481
- const runEnv = { ...process.env, FLOW_LOG: flowLog };
3852
+ const flowToken = Date.now();
3853
+ const flowLog = path.join(os.tmpdir(), `mcp-flow-${flowToken}.log`);
3854
+ const evidenceDir = path.join(capturesDir, `flow-${platform}-${flowToken}`);
3855
+ const runEnv = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
3482
3856
  if (isNonEmptyString(args.testEmail)) runEnv.OCQA_TEST_EMAIL = args.testEmail.trim();
3483
3857
  if (isNonEmptyString(args.testPassword)) runEnv.OCQA_TEST_PASSWORD = args.testPassword.trim();
3484
3858
  let run = { stdout: "", stderr: "", code: 0 };
3485
3859
  if (platform === "web") {
3486
3860
  try {
3487
3861
  const { runWebFlow } = await import("./web-flow.js");
3488
- const evidenceDir = path.join(capturesDir, `flow-web-${Date.now()}`);
3489
3862
  const result = await runWebFlow({
3490
3863
  flow: parsedFlow,
3491
3864
  url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
@@ -3500,10 +3873,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3500
3873
  const cmdArgs = [path.join(scriptsDir, "run-flow.sh"), flowFile];
3501
3874
  if (isNonEmptyString(args.appBundleId)) cmdArgs.push(args.appBundleId.trim());
3502
3875
  run = await runCommand("bash", cmdArgs, { cwd: repoRoot, timeoutMs: 10 * 60 * 1000, env: runEnv });
3876
+ if (fs.existsSync(evidenceDir) && fs.readdirSync(evidenceDir).length > 0) run.evidenceDir = evidenceDir;
3503
3877
  } else if (platform === "android") {
3504
3878
  try {
3505
3879
  const { runAndroidFlow } = await import("./android-flow.js");
3506
- const evidenceDir = path.join(capturesDir, `flow-android-${Date.now()}`);
3507
3880
  const result = await runAndroidFlow({
3508
3881
  flow: parsedFlow,
3509
3882
  appId: isNonEmptyString(args.androidAppId)
@@ -3530,7 +3903,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3530
3903
  try { structured = JSON.parse(jsonRes.stdout.trim()); } catch { /* fall through */ }
3531
3904
  const textRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "report", flowLog], { cwd: repoRoot });
3532
3905
  const text = (textRes.stdout || "").trim() || run.stdout;
3533
- return richResult(text, { ...(structured || { raw: run.stdout }), platform, evidenceDir: run.evidenceDir });
3906
+ const retainedEvidence = run.evidenceDir && fs.existsSync(run.evidenceDir) && fs.readdirSync(run.evidenceDir).length > 0
3907
+ ? run.evidenceDir
3908
+ : undefined;
3909
+ const evidenceText = retainedEvidence
3910
+ ? `\n\nEvidence: \`${retainedEvidence}\``
3911
+ : "\n\n⚠️ Evidence unavailable — the platform runner did not write any artifacts for this Flow run.";
3912
+ return richResult(text + evidenceText, { ...(structured || { raw: run.stdout }), platform, ...(retainedEvidence ? { evidenceDir: retainedEvidence } : {}) });
3534
3913
  }
3535
3914
 
3536
3915
  if (name === "tapp_scenario_run") {
@@ -3540,8 +3919,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3540
3919
  if (args.scenario && typeof args.scenario === "object") {
3541
3920
  scenario = args.scenario;
3542
3921
  } else if (isNonEmptyString(args.scenarioPath)) {
3543
- const scenarioFile = path.resolve(repoRoot, args.scenarioPath.trim());
3544
- if (!isInsideDir(repoRoot, scenarioFile)) return errorResult("scenarioPath must be inside the repo");
3922
+ const scenarioFile = path.resolve(workspaceRoot, args.scenarioPath.trim());
3923
+ if (!isInsideDir(workspaceRoot, scenarioFile)) return errorResult("scenarioPath must be inside the workspace");
3545
3924
  if (!fs.existsSync(scenarioFile)) return errorResult("Scenario file not found", { scenarioPath: args.scenarioPath });
3546
3925
  try {
3547
3926
  const { loadScenarioFile } = await import("./scenario-runtime.js");
@@ -3611,7 +3990,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3611
3990
  const ungrounded = ungroundedScreens(parsed.steps, grounding);
3612
3991
  const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
3613
3992
  const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
3614
- const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
3993
+ const dir = path.join(workspaceRoot, ".tapp", "proposals", "flows");
3615
3994
  fs.mkdirSync(dir, { recursive: true });
3616
3995
  const outPath = path.join(dir, `${slug}.yml`);
3617
3996
  const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
@@ -3619,7 +3998,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3619
3998
  const yaml = (yamlRes.stdout || "").trim();
3620
3999
  if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
3621
4000
  fs.writeFileSync(outPath, yaml + "\n");
3622
- const rel = path.relative(repoRoot, outPath);
4001
+ const rel = path.relative(workspaceRoot, outPath);
3623
4002
 
3624
4003
  const L = [`🤖 Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
3625
4004
  L.push(`⚠️ This is an **untrusted draft**, not a committed test. Grounded in ${grounding.screens.length} observed screen(s). ${ungrounded.length ? `⚠️ references unobserved: ${ungrounded.join(", ")} — review before relying on it.` : "All referenced screens were observed."}`);
@@ -3821,17 +4200,66 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3821
4200
  if (unauthorized) return unauthorized;
3822
4201
  const ios = isNonEmptyString(args.appBundleId);
3823
4202
  const android = isNonEmptyString(args.androidAppId);
3824
- if (ios === android) return errorResult("Provide exactly one of appBundleId or androidAppId");
3825
- const target = ios ? args.appBundleId.trim() : args.androidAppId.trim();
4203
+ const web = isNonEmptyString(args.url);
4204
+ if ([ios, android, web].filter(Boolean).length !== 1) return errorResult("Provide exactly one of appBundleId, androidAppId, or url");
4205
+ const target = ios ? args.appBundleId.trim() : android ? args.androidAppId.trim() : args.url.trim();
4206
+ let focusProjectDir = workspaceRoot;
4207
+ if (isNonEmptyString(args.focus)) {
4208
+ try { focusProjectDir = fs.realpathSync(path.resolve(workspaceRoot, isNonEmptyString(args.projectDir) ? args.projectDir.trim() : ".")); }
4209
+ catch { return errorResult("projectDir must be an existing directory inside the workspace"); }
4210
+ if (!isInsideDir(workspaceRoot, focusProjectDir) || !fs.statSync(focusProjectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
4211
+ }
3826
4212
  const r = ios
3827
4213
  ? await startSession(target, explorationEnvFromArgs(args))
3828
- : await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword });
4214
+ : android
4215
+ ? await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword })
4216
+ : await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword });
3829
4217
  if (r.error) return errorResult(r.error);
3830
- return richResult(
3831
- `🎬 Session started — \`${target}\` (${ios ? "iOS" : "Android"})\n\n` + formatScreen(r.screenTitle, r.elements) +
3832
- `\n\nDrive it with \`tapp_session_act\` (tap · type · swipe · back · wait · tree · screenshot).`,
3833
- r
3834
- );
4218
+ let focused = null;
4219
+ if (isNonEmptyString(args.focus)) {
4220
+ try {
4221
+ focused = await focusInteractiveSession({ projectDir:focusProjectDir, query:args.focus.trim(), platform:ios ? "ios" : android ? "android" : "web", mapPath:isNonEmptyString(args.mapPath) ? args.mapPath.trim() : "" });
4222
+ } catch (error) {
4223
+ await endSession();
4224
+ return errorResult("Could not focus the requested UI surface", { detail:error.message || String(error) });
4225
+ }
4226
+ }
4227
+ const platformLabel = ios ? "iOS" : android ? "Android" : "web";
4228
+ const screen = agentScreenProjection(focused || r);
4229
+ const text = focused
4230
+ ? `🎬 Session started — \`${target}\` (${platformLabel})\n\n${(await import("./focused-navigation.js")).focusedTargetSummary(focused)}\n\n${formatScreen(screen.screenTitle, screen.elements)}`
4231
+ : `🎬 Session started — \`${target}\` (${platformLabel})\n\n${formatScreen(screen.screenTitle, screen.elements)}\n\nDrive it with \`tapp_focus\` for a named destination, or \`tapp_session_act\` for one action.`;
4232
+ // Return the final focused screen once. Previously this duplicated the complete native tree
4233
+ // inside `focus` and at top level, and retained the pre-focus web URL at top level.
4234
+ const structured = focused
4235
+ ? { ok:true, platform:ios ? "ios" : android ? "android" : "web", ...screen, focus:focusMetadata(focused) }
4236
+ : { ...r, ...screen };
4237
+ const result = richResult(text, structured);
4238
+ if (focused?.execution?.status === "failed") result.isError = true;
4239
+ return result;
4240
+ }
4241
+
4242
+ if (name === "tapp_focus") {
4243
+ const unauthorized = ensureAuthorized(args);
4244
+ if (unauthorized) return unauthorized;
4245
+ if (!isNonEmptyString(args.query)) return errorResult("query is required");
4246
+ let projectDir;
4247
+ try { projectDir = fs.realpathSync(path.resolve(workspaceRoot, isNonEmptyString(args.projectDir) ? args.projectDir.trim() : ".")); }
4248
+ catch { return errorResult("projectDir must be an existing directory inside the workspace"); }
4249
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
4250
+ try {
4251
+ const result = await focusInteractiveSession({
4252
+ projectDir, query:args.query.trim(), platform:isNonEmptyString(args.platform) ? args.platform.trim() : "",
4253
+ mapPath:isNonEmptyString(args.mapPath) ? args.mapPath.trim() : "",
4254
+ });
4255
+ const { focusedTargetSummary } = await import("./focused-navigation.js");
4256
+ const execution = result.execution?.status === "reached"
4257
+ ? `\n\n⚡ Reached in ${(result.execution.steps || []).length} route action(s).\n\n${formatScreen(result.screenTitle, agentFacingElements(result.elements))}`
4258
+ : result.execution?.status === "failed" ? `\n\n⚠️ Route execution stopped: ${result.execution.reason}` : "";
4259
+ const response = richResult(focusedTargetSummary(result) + execution, { ...result, ...agentScreenProjection(result) });
4260
+ if (result.execution?.status === "failed") response.isError = true;
4261
+ return response;
4262
+ } catch (error) { return errorResult("Could not focus the requested UI surface", { detail:error.message || String(error) }); }
3835
4263
  }
3836
4264
 
3837
4265
  if (name === "tapp_session_act") {
@@ -3867,14 +4295,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3867
4295
  const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
3868
4296
  const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
3869
4297
  const rec = typeof r.recordedSteps === "number" ? `\n\n🔴 Recording — ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
3870
- return richResult(head + "\n\n" + formatScreen(r.screenTitle, r.elements) + rec, r);
4298
+ const screen = agentScreenProjection(r);
4299
+ const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements) + rec, { ...r, ...screen });
4300
+ if (!ok) result.isError = true;
4301
+ return result;
3871
4302
  }
3872
4303
 
3873
4304
  if (name === "tapp_flow_save") {
3874
4305
  const unauthorized = ensureAuthorized(args);
3875
4306
  if (unauthorized) return unauthorized;
3876
4307
  try {
3877
- const saved = await saveInteractiveSessionFlow({ projectDir:repoRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
4308
+ const saved = await saveInteractiveSessionFlow({ projectDir:workspaceRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
3878
4309
  const text = `💾 Saved flow **${saved.flow.name}** → \`${saved.path}\` (${saved.flow.steps.length} steps)\n\n\`\`\`yaml\n${saved.yaml}\n\`\`\`\n\nReplay it anytime: \`tapp_flow_run\` with \`flowPath: "${saved.path}"\`.`;
3879
4310
  return richResult(text, { path:saved.path, flow:saved.flow });
3880
4311
  } catch (error) {