copilot-tap-extension 2.0.7 → 2.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +4 -1
  2. package/SOUL.md +51 -0
  3. package/bin/install.mjs +7 -1
  4. package/dist/copilot-instructions.md +15 -0
  5. package/dist/extension.mjs +823 -29
  6. package/dist/skills/tap-goal/SKILL.md +13 -2
  7. package/dist/skills/tap-loop/SKILL.md +6 -0
  8. package/dist/skills/tap-monitor/SKILL.md +19 -3
  9. package/dist/skills/tap-orchestrate/SKILL.md +81 -0
  10. package/dist/version.json +1 -1
  11. package/docs/adr/0001-persistent-config-default-ownership.md +33 -0
  12. package/docs/adr/0002-local-provider-gateway-runtime-security.md +36 -0
  13. package/docs/adr/0003-emitter-delivery-lifecycle.md +68 -0
  14. package/docs/adr/0004-persistent-config-canonical-streams.md +86 -0
  15. package/docs/adr/0005-provider-sdk-push-and-dynamic-tools.md +48 -0
  16. package/docs/adr/0006-command-emitter-cwd-workspace-boundary.md +46 -0
  17. package/docs/adr/0007-runtime-session-workspace-context.md +62 -0
  18. package/docs/evals.md +41 -0
  19. package/docs/evolution-of-tap-icon.html +989 -0
  20. package/docs/providers.md +242 -0
  21. package/docs/recipes/adaptive-agent.md +303 -0
  22. package/docs/recipes/agent-brainstorm/100-extension-ideas.md +288 -0
  23. package/docs/recipes/agent-brainstorm/deep-ideas.md +216 -0
  24. package/docs/recipes/ambient-guardian.md +314 -0
  25. package/docs/recipes/browser-bridge.md +162 -0
  26. package/docs/recipes/codex-goals-for-tap-goal.md +136 -0
  27. package/docs/recipes/copilot-sdk-canvas.md +147 -0
  28. package/docs/recipes/deferred-cognition.md +310 -0
  29. package/docs/recipes/provider-integration-patterns.md +93 -0
  30. package/docs/recipes/provider-interface-advanced.md +1364 -0
  31. package/docs/recipes/provider-interface-core-profile.md +568 -0
  32. package/docs/recipes/tap-control-plane-roadmap.md +60 -0
  33. package/docs/recipes/universal-tool-gateway.md +202 -0
  34. package/docs/reference.md +229 -0
  35. package/docs/use-cases.md +348 -0
  36. package/package.json +4 -1
  37. package/providers/detour/README.md +84 -0
  38. package/providers/detour/bridge.js +219 -0
  39. package/providers/detour/index.mjs +322 -0
  40. package/providers/detour/package-lock.json +577 -0
  41. package/providers/detour/package.json +19 -0
  42. package/providers/detour/scripts/build.mjs +31 -0
  43. package/providers/detour/src/bridge.js +256 -0
  44. package/providers/detour/src/contracts.js +40 -0
  45. package/providers/detour/src/inspector.js +260 -0
  46. package/providers/detour/src/inspector.test.mjs +53 -0
  47. package/providers/detour/src/panel.js +465 -0
  48. package/providers/detour/src/provider-core.js +233 -0
  49. package/providers/detour/src/provider-core.test.mjs +185 -0
  50. package/providers/detour/src/react-context-core.js +143 -0
  51. package/providers/detour/src/react-context.js +44 -0
  52. package/providers/detour/src/react-context.test.mjs +41 -0
  53. package/providers/templates/README.md +23 -0
  54. package/providers/templates/ci-review-provider.mjs +46 -0
  55. package/providers/templates/detour-workflow-provider.mjs +41 -0
  56. package/providers/templates/jira-github-provider.mjs +42 -0
  57. package/providers/templates/provider-utils.mjs +45 -0
  58. package/providers/templates/sast-triage-provider.mjs +51 -0
@@ -0,0 +1,233 @@
1
+ import { MESSAGE_TYPES } from "./contracts.js";
2
+
3
+ export const MAX_LOG_BUFFER = 500;
4
+ export const BRIDGE_TOKEN_PLACEHOLDER = "__DET0UR_WS_URL__";
5
+ export const AUTH_HEADER = "x-detour-token";
6
+ export const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
7
+
8
+ export function firstClientIdFrom(clients) {
9
+ const first = clients.keys().next();
10
+ return first.done ? null : first.value;
11
+ }
12
+
13
+ export function clientLabelFrom(clients, id) {
14
+ const client = clients.get(id);
15
+ return client ? `${client.title} (${client.url})` : id;
16
+ }
17
+
18
+ export function listBrowserClients(clients) {
19
+ return [...clients].map(([id, client]) => ({
20
+ id,
21
+ url: client.url,
22
+ title: client.title,
23
+ connectedAt: client.connectedAt,
24
+ }));
25
+ }
26
+
27
+ export function selectConsoleLogs(consoleLogs, args = {}, max = MAX_LOG_BUFFER) {
28
+ let logs = [...consoleLogs];
29
+ if (args.client_id) logs = logs.filter((log) => log.clientId === args.client_id);
30
+ if (args.level) logs = logs.filter((log) => log.level === args.level);
31
+ return logs.slice(-(Math.min(args.limit || 50, max)));
32
+ }
33
+
34
+ export function selectPageMessages(pageMessages, args = {}) {
35
+ return pageMessages.slice(-(args.limit || 20));
36
+ }
37
+
38
+ export function buildMessagesResponse(pageMessages, requestUrl) {
39
+ const url = new URL(requestUrl, "http://localhost");
40
+ const ack = parseInt(url.searchParams.get("ack") || "0", 10);
41
+ return {
42
+ total: pageMessages.length,
43
+ messages: pageMessages.slice(ack),
44
+ };
45
+ }
46
+
47
+ export function routeHttpRequest(method, requestUrl) {
48
+ const { pathname } = new URL(requestUrl || "/", "http://localhost");
49
+ if (method === "OPTIONS") return "options";
50
+ if ((pathname === "/bridge.js" || pathname === "/") && method === "GET") return "bridge";
51
+ if (pathname === "/eval" && method === "POST") return "eval";
52
+ if (pathname === "/clients" && method === "GET") return "clients";
53
+ if (pathname === "/logs" && method === "GET") return "logs";
54
+ if (pathname === "/messages" && method === "GET") return "messages";
55
+ if (pathname === "/reply" && method === "POST") return "reply";
56
+ return "notFound";
57
+ }
58
+
59
+ export function renderBridgeScript(template, browserPort, token) {
60
+ const wsUrl = `ws://127.0.0.1:${browserPort}?token=${encodeURIComponent(token)}`;
61
+ return template.replaceAll(BRIDGE_TOKEN_PLACEHOLDER, wsUrl);
62
+ }
63
+
64
+ export function getRequestToken(req) {
65
+ const url = new URL(req.url || "/", "http://localhost");
66
+ const queryToken = url.searchParams.get("token");
67
+ if (queryToken) return queryToken;
68
+
69
+ const headerToken = req.headers?.[AUTH_HEADER];
70
+ if (Array.isArray(headerToken)) return headerToken[0] || "";
71
+ if (typeof headerToken === "string") return headerToken;
72
+
73
+ const authorization = req.headers?.authorization;
74
+ const value = Array.isArray(authorization) ? authorization[0] : authorization;
75
+ const match = typeof value === "string" ? /^Bearer\s+(.+)$/i.exec(value) : null;
76
+ return match ? match[1] : "";
77
+ }
78
+
79
+ export function isAuthorizedRequest(req, expectedToken) {
80
+ return Boolean(expectedToken) && getRequestToken(req) === expectedToken;
81
+ }
82
+
83
+ export function isLoopbackHostHeader(hostHeader) {
84
+ if (typeof hostHeader !== "string" || !hostHeader) return false;
85
+ const lowerHost = hostHeader.toLowerCase();
86
+ if (lowerHost === "[::1]" || lowerHost.startsWith("[::1]:")) return true;
87
+ const host = lowerHost.split(":")[0];
88
+ return LOOPBACK_HOSTS.has(host);
89
+ }
90
+
91
+ export function isLoopbackAddress(address) {
92
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
93
+ }
94
+
95
+ export function isAllowedCorsOrigin(origin) {
96
+ return typeof origin === "string" && origin.startsWith("chrome-extension://");
97
+ }
98
+
99
+ export function applyCorsHeaders(req, res) {
100
+ const origin = req.headers?.origin;
101
+ if (!isAllowedCorsOrigin(origin)) return;
102
+ res.setHeader("Access-Control-Allow-Origin", origin);
103
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
104
+ res.setHeader("Access-Control-Allow-Headers", `Content-Type, Authorization, ${AUTH_HEADER}`);
105
+ res.setHeader("Vary", "Origin");
106
+ }
107
+
108
+ export function planBrowserMessage(raw, { clientId, from, nowIso }) {
109
+ let msg;
110
+ try {
111
+ msg = JSON.parse(raw);
112
+ } catch {
113
+ return { kind: "ignore" };
114
+ }
115
+
116
+ switch (msg.type) {
117
+ case MESSAGE_TYPES.IDENTIFY:
118
+ return {
119
+ kind: "identify",
120
+ url: msg.url || "unknown",
121
+ title: msg.title || "unknown",
122
+ logText: ` → ${msg.title} (${msg.url})`,
123
+ };
124
+
125
+ case MESSAGE_TYPES.CONSOLE:
126
+ return {
127
+ kind: "console",
128
+ entry: {
129
+ clientId,
130
+ level: msg.level || "log",
131
+ args: msg.args || [],
132
+ timestamp: msg.timestamp || nowIso(),
133
+ },
134
+ };
135
+
136
+ case MESSAGE_TYPES.EVAL_RESULT:
137
+ return {
138
+ kind: "evalResult",
139
+ id: msg.id,
140
+ error: msg.error,
141
+ value: msg.value,
142
+ };
143
+
144
+ case MESSAGE_TYPES.PAGE_MESSAGE:
145
+ return {
146
+ kind: "pageMessage",
147
+ logText: `📨 [${from}] ${msg.message}`,
148
+ pageMessage: { clientId, from, message: msg.message, timestamp: nowIso() },
149
+ pushText: `[${from}] ${msg.message}`,
150
+ };
151
+
152
+ case MESSAGE_TYPES.PAGE_ASK:
153
+ return {
154
+ kind: "pageAsk",
155
+ askId: msg.id,
156
+ logText: `❓ [ASK from ${from}] ${msg.message}`,
157
+ pendingAsk: { clientId, message: msg.message, from, timestamp: nowIso() },
158
+ pageMessage: { clientId, from, message: msg.message, type: "ask", askId: msg.id, timestamp: nowIso() },
159
+ pushText: `[ASK from ${from}] ${msg.message} (reply with reply_to_page tool, askId: "${msg.id}")`,
160
+ };
161
+
162
+ case MESSAGE_TYPES.PAGE_CONTEXT: {
163
+ const chatMsg = msg.message ? ` — "${msg.message}"` : "";
164
+ const annotationCount = msg.annotations?.length || 0;
165
+ return {
166
+ kind: "pageContext",
167
+ logText: `📋 [CONTEXT from ${from}] ${annotationCount} annotations${chatMsg}`,
168
+ pageMessage: {
169
+ clientId,
170
+ from,
171
+ message: msg.markdown || `${annotationCount} annotations from ${from}${chatMsg}`,
172
+ type: "context",
173
+ timestamp: nowIso(),
174
+ },
175
+ pushText: `[CONTEXT from ${from}]${chatMsg}\n${msg.markdown || ""}`,
176
+ };
177
+ }
178
+
179
+ case MESSAGE_TYPES.PAGE_ANNOTATE: {
180
+ const ann = msg.annotation || {};
181
+ return {
182
+ kind: "pageAnnotate",
183
+ logText: `📌 [${from}] ${ann.context?.displayName || "element"}: ${ann.intent} — ${ann.comment || "(no comment)"}`,
184
+ };
185
+ }
186
+
187
+ default:
188
+ return { kind: "ignore" };
189
+ }
190
+ }
191
+
192
+ export function planToolCall(toolName, args = {}, state) {
193
+ switch (toolName) {
194
+ case "inject_js": {
195
+ const clientId = args.client_id || state.firstClientId;
196
+ if (!clientId) return { kind: "result", value: { error: "No browser clients connected." } };
197
+ return {
198
+ kind: "eval",
199
+ clientId,
200
+ code: args.code,
201
+ timeoutMs: args.timeout_ms || 15000,
202
+ };
203
+ }
204
+
205
+ case "get_console_logs":
206
+ return {
207
+ kind: "consoleLogs",
208
+ logs: selectConsoleLogs(state.consoleLogs, args),
209
+ clear: args.clear,
210
+ };
211
+
212
+ case "list_browser_clients":
213
+ return { kind: "result", value: listBrowserClients(state.clients) };
214
+
215
+ case "get_page_messages":
216
+ return { kind: "result", value: selectPageMessages(state.pageMessages, args) };
217
+
218
+ case "reply_to_page": {
219
+ const pending = state.pendingPageAsks.get(args.ask_id);
220
+ if (!pending) return { kind: "result", value: { error: "No pending ask with that ID" } };
221
+ return {
222
+ kind: "replyToPage",
223
+ askId: args.ask_id,
224
+ pending,
225
+ payload: { type: MESSAGE_TYPES.ASK_REPLY, id: args.ask_id, reply: args.reply },
226
+ result: { ok: true, repliedTo: pending.from },
227
+ };
228
+ }
229
+
230
+ default:
231
+ return { kind: "unknown", toolName };
232
+ }
233
+ }
@@ -0,0 +1,185 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { MESSAGE_TYPES } from "./contracts.js";
5
+ import {
6
+ AUTH_HEADER,
7
+ BRIDGE_TOKEN_PLACEHOLDER,
8
+ MAX_LOG_BUFFER,
9
+ applyCorsHeaders,
10
+ buildMessagesResponse,
11
+ clientLabelFrom,
12
+ firstClientIdFrom,
13
+ getRequestToken,
14
+ isAllowedCorsOrigin,
15
+ isAuthorizedRequest,
16
+ isLoopbackAddress,
17
+ isLoopbackHostHeader,
18
+ listBrowserClients,
19
+ planBrowserMessage,
20
+ planToolCall,
21
+ renderBridgeScript,
22
+ routeHttpRequest,
23
+ selectConsoleLogs,
24
+ selectPageMessages,
25
+ } from "./provider-core.js";
26
+
27
+ test("client helpers preserve map ordering and labels", () => {
28
+ const clients = new Map([
29
+ ["tab-1", { title: "Cart", url: "https://example.test/cart", connectedAt: "t1" }],
30
+ ["tab-2", { title: "Checkout", url: "https://example.test/checkout", connectedAt: "t2" }],
31
+ ]);
32
+
33
+ assert.equal(MAX_LOG_BUFFER, 500);
34
+ assert.equal(firstClientIdFrom(clients), "tab-1");
35
+ assert.equal(clientLabelFrom(clients, "tab-2"), "Checkout (https://example.test/checkout)");
36
+ assert.equal(clientLabelFrom(clients, "missing"), "missing");
37
+ assert.deepEqual(listBrowserClients(clients), [
38
+ { id: "tab-1", title: "Cart", url: "https://example.test/cart", connectedAt: "t1" },
39
+ { id: "tab-2", title: "Checkout", url: "https://example.test/checkout", connectedAt: "t2" },
40
+ ]);
41
+ });
42
+
43
+ test("selectors match Detour buffer read behavior", () => {
44
+ const logs = [
45
+ { clientId: "tab-1", level: "log", message: "first" },
46
+ { clientId: "tab-2", level: "warn", message: "second" },
47
+ { clientId: "tab-1", level: "warn", message: "third" },
48
+ ];
49
+ assert.deepEqual(selectConsoleLogs(logs, { client_id: "tab-1", level: "warn", limit: 5 }), [
50
+ { clientId: "tab-1", level: "warn", message: "third" },
51
+ ]);
52
+ assert.deepEqual(selectPageMessages(["a", "b", "c"], { limit: 2 }), ["b", "c"]);
53
+ });
54
+
55
+ test("HTTP request helpers keep route and message response semantics", () => {
56
+ assert.equal(routeHttpRequest("OPTIONS", "/anything"), "options");
57
+ assert.equal(routeHttpRequest("GET", "/bridge.js?token=t"), "bridge");
58
+ assert.equal(routeHttpRequest("POST", "/eval?token=t"), "eval");
59
+ assert.equal(routeHttpRequest("GET", "/clients?token=t"), "clients");
60
+ assert.equal(routeHttpRequest("GET", "/logs?level=warn"), "logs");
61
+ assert.equal(routeHttpRequest("GET", "/messages?ack=1"), "messages");
62
+ assert.equal(routeHttpRequest("POST", "/reply?token=t"), "reply");
63
+ assert.equal(routeHttpRequest("GET", "/missing"), "notFound");
64
+
65
+ assert.deepEqual(buildMessagesResponse(["zero", "one", "two"], "/messages?ack=1"), {
66
+ total: 3,
67
+ messages: ["one", "two"],
68
+ });
69
+ });
70
+
71
+ test("bridge auth helpers require loopback and explicit token", () => {
72
+ const reqFromQuery = { url: "/bridge.js?token=secret", headers: { host: "127.0.0.1:9401" } };
73
+ const reqFromHeader = { url: "/messages", headers: { host: "localhost:9401", [AUTH_HEADER]: "secret" } };
74
+ const reqFromBearer = { url: "/messages", headers: { host: "[::1]:9401", authorization: "Bearer secret" } };
75
+
76
+ assert.equal(getRequestToken(reqFromQuery), "secret");
77
+ assert.equal(getRequestToken(reqFromHeader), "secret");
78
+ assert.equal(getRequestToken(reqFromBearer), "secret");
79
+ assert.equal(isAuthorizedRequest(reqFromQuery, "secret"), true);
80
+ assert.equal(isAuthorizedRequest({ url: "/bridge.js", headers: {} }, "secret"), false);
81
+ assert.equal(isLoopbackHostHeader("127.0.0.1:9401"), true);
82
+ assert.equal(isLoopbackHostHeader("localhost:9401"), true);
83
+ assert.equal(isLoopbackHostHeader("[::1]:9401"), true);
84
+ assert.equal(isLoopbackHostHeader("evil.test:9401"), false);
85
+ assert.equal(isLoopbackAddress("127.0.0.1"), true);
86
+ assert.equal(isLoopbackAddress("::ffff:127.0.0.1"), true);
87
+ assert.equal(isLoopbackAddress("10.0.0.5"), false);
88
+ });
89
+
90
+ test("bridge script rendering injects an unexposed loopback websocket token", () => {
91
+ const script = `var WS_URL = "${BRIDGE_TOKEN_PLACEHOLDER}";`;
92
+ assert.equal(
93
+ renderBridgeScript(script, 9401, "token with spaces"),
94
+ 'var WS_URL = "ws://127.0.0.1:9401?token=token%20with%20spaces";',
95
+ );
96
+ });
97
+
98
+ test("CORS helper only reflects chrome-extension origins", () => {
99
+ assert.equal(isAllowedCorsOrigin("chrome-extension://abc"), true);
100
+ assert.equal(isAllowedCorsOrigin("https://evil.test"), false);
101
+
102
+ const headers = new Map();
103
+ const res = { setHeader: (name, value) => headers.set(name, value) };
104
+ applyCorsHeaders({ headers: { origin: "chrome-extension://abc" } }, res);
105
+ assert.equal(headers.get("Access-Control-Allow-Origin"), "chrome-extension://abc");
106
+
107
+ headers.clear();
108
+ applyCorsHeaders({ headers: { origin: "https://evil.test" } }, res);
109
+ assert.equal(headers.has("Access-Control-Allow-Origin"), false);
110
+ });
111
+
112
+ test("browser message planner preserves Detour push and log text", () => {
113
+ const timestamps = ["pending-time", "message-time"];
114
+ const plan = planBrowserMessage(JSON.stringify({
115
+ type: MESSAGE_TYPES.PAGE_ASK,
116
+ id: "ask-1",
117
+ message: "Can you review this button?",
118
+ }), {
119
+ clientId: "tab-1",
120
+ from: "Checkout (https://example.test)",
121
+ nowIso: () => timestamps.shift(),
122
+ });
123
+
124
+ assert.deepEqual(plan, {
125
+ kind: "pageAsk",
126
+ askId: "ask-1",
127
+ logText: "❓ [ASK from Checkout (https://example.test)] Can you review this button?",
128
+ pendingAsk: {
129
+ clientId: "tab-1",
130
+ message: "Can you review this button?",
131
+ from: "Checkout (https://example.test)",
132
+ timestamp: "pending-time",
133
+ },
134
+ pageMessage: {
135
+ clientId: "tab-1",
136
+ from: "Checkout (https://example.test)",
137
+ message: "Can you review this button?",
138
+ type: "ask",
139
+ askId: "ask-1",
140
+ timestamp: "message-time",
141
+ },
142
+ pushText: "[ASK from Checkout (https://example.test)] Can you review this button? (reply with reply_to_page tool, askId: \"ask-1\")",
143
+ });
144
+ });
145
+
146
+ test("tool-call planner keeps public tool responses side-effect free", () => {
147
+ const clients = new Map([
148
+ ["tab-1", { title: "Cart", url: "https://example.test/cart", connectedAt: "t1" }],
149
+ ]);
150
+ const pending = { from: "Cart (https://example.test/cart)", ws: { readyState: 1 } };
151
+ const state = {
152
+ firstClientId: "tab-1",
153
+ clients,
154
+ consoleLogs: [{ clientId: "tab-1", level: "warn", message: "careful" }],
155
+ pageMessages: ["older", "latest"],
156
+ pendingPageAsks: new Map([["ask-1", pending]]),
157
+ };
158
+
159
+ assert.deepEqual(planToolCall("inject_js", { code: "1 + 1", timeout_ms: 0 }, state), {
160
+ kind: "eval",
161
+ clientId: "tab-1",
162
+ code: "1 + 1",
163
+ timeoutMs: 15000,
164
+ });
165
+ assert.deepEqual(planToolCall("get_console_logs", { level: "warn", clear: true }, state), {
166
+ kind: "consoleLogs",
167
+ logs: [{ clientId: "tab-1", level: "warn", message: "careful" }],
168
+ clear: true,
169
+ });
170
+ assert.deepEqual(planToolCall("get_page_messages", { limit: 1 }, state), {
171
+ kind: "result",
172
+ value: ["latest"],
173
+ });
174
+ assert.deepEqual(planToolCall("reply_to_page", { ask_id: "ask-1", reply: "Looks good" }, state), {
175
+ kind: "replyToPage",
176
+ askId: "ask-1",
177
+ pending,
178
+ payload: { type: MESSAGE_TYPES.ASK_REPLY, id: "ask-1", reply: "Looks good" },
179
+ result: { ok: true, repliedTo: "Cart (https://example.test/cart)" },
180
+ });
181
+ assert.deepEqual(planToolCall("missing_tool", {}, state), {
182
+ kind: "unknown",
183
+ toolName: "missing_tool",
184
+ });
185
+ });
@@ -0,0 +1,143 @@
1
+ // Internal React component names to skip
2
+ const INTERNAL_NAMES = new Set([
3
+ "Fragment", "Suspense", "StrictMode", "Profiler", "Portal",
4
+ "Provider", "Consumer", "Context", "ForwardRef", "Memo",
5
+ "InnerLayoutRouter", "ErrorBoundary", "AppRouter", "RenderFromTemplateContext",
6
+ "ScrollAndFocusHandler", "RedirectBoundary", "NotFoundBoundary",
7
+ "HotReload", "Router", "RouterContext",
8
+ ]);
9
+
10
+ const LIBRARY_PREFIXES = [
11
+ "motion.", "styled.", "chakra.", "ark.", "radix.",
12
+ "Transition", "AnimatePresence",
13
+ ];
14
+
15
+ function isUsefulComponentName(name) {
16
+ if (!name || typeof name !== "string") return false;
17
+ if (INTERNAL_NAMES.has(name)) return false;
18
+ if (LIBRARY_PREFIXES.some((prefix) => name.startsWith(prefix))) return false;
19
+ if (name.length <= 1) return false;
20
+ return true;
21
+ }
22
+
23
+ /**
24
+ * Check if React is present on the page.
25
+ */
26
+ export function isReactDetectedWithBippy(bippy, documentRef) {
27
+ if (!bippy) return false;
28
+ // Check for React root markers
29
+ const roots = documentRef.querySelectorAll("[data-reactroot], #__next, #root, #app");
30
+ for (const root of roots) {
31
+ const keys = Object.keys(root);
32
+ if (keys.some((key) => key.startsWith("__reactFiber") || key.startsWith("__reactInternalInstance"))) {
33
+ return true;
34
+ }
35
+ }
36
+ // Broader check — any element with fiber
37
+ try {
38
+ const fiber = bippy.getFiberFromHostInstance(documentRef.body);
39
+ return fiber != null;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get React Fiber from a DOM element.
47
+ */
48
+ function getFiber(bippy, element) {
49
+ if (!bippy || !element) return null;
50
+ try {
51
+ return bippy.getFiberFromHostInstance(element);
52
+ } catch {
53
+ // Manual fallback: check __reactFiber$ keys
54
+ const keys = Object.keys(element);
55
+ const fiberKey = keys.find((key) => key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$"));
56
+ return fiberKey ? element[fiberKey] : null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Get the display name of a fiber.
62
+ */
63
+ function getFiberName(fiber) {
64
+ if (!fiber || !fiber.type) return null;
65
+ if (typeof fiber.type === "string") return null; // HTML element, not component
66
+ return fiber.type.displayName || fiber.type.name || null;
67
+ }
68
+
69
+ /**
70
+ * Walk up the fiber tree to find useful component names.
71
+ */
72
+ function getComponentHierarchy(bippy, element, maxDepth = 10) {
73
+ const fiber = getFiber(bippy, element);
74
+ if (!fiber) return [];
75
+
76
+ const components = [];
77
+ let current = fiber;
78
+ let depth = 0;
79
+
80
+ while (current && depth < maxDepth) {
81
+ const name = getFiberName(current);
82
+ if (isUsefulComponentName(name)) {
83
+ components.push(name);
84
+ }
85
+ current = current.return;
86
+ depth++;
87
+ }
88
+
89
+ return components;
90
+ }
91
+
92
+ /**
93
+ * Get the nearest useful React component name for an element.
94
+ */
95
+ export function getNearestComponentNameWithBippy(bippy, element) {
96
+ const hierarchy = getComponentHierarchy(bippy, element, 20);
97
+ return hierarchy.length > 0 ? hierarchy[0] : null;
98
+ }
99
+
100
+ /**
101
+ * Get source file location from React fiber debug info.
102
+ * Only available in development builds.
103
+ */
104
+ function getSourceLocation(bippy, element) {
105
+ const fiber = getFiber(bippy, element);
106
+ if (!fiber) return null;
107
+
108
+ let current = fiber;
109
+ let depth = 0;
110
+ while (current && depth < 20) {
111
+ const source = current._debugSource;
112
+ if (source && source.fileName) {
113
+ return {
114
+ fileName: source.fileName,
115
+ lineNumber: source.lineNumber || null,
116
+ columnNumber: source.columnNumber || null,
117
+ };
118
+ }
119
+ current = current.return;
120
+ depth++;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Get full React context for an element — component hierarchy, source, props summary.
127
+ */
128
+ export function getReactContextWithBippy(bippy, element) {
129
+ if (!bippy && !getFiber(bippy, element)) return null;
130
+
131
+ const hierarchy = getComponentHierarchy(bippy, element, 15);
132
+ const source = getSourceLocation(bippy, element);
133
+ const nearest = hierarchy.length > 0 ? hierarchy[0] : null;
134
+
135
+ if (!nearest && !source) return null;
136
+
137
+ return {
138
+ component: nearest,
139
+ hierarchy: hierarchy.length > 0 ? hierarchy : undefined,
140
+ source: source || undefined,
141
+ reactDetected: true,
142
+ };
143
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * React Context Extraction — best-effort React component info from DOM elements.
3
+ *
4
+ * Uses bippy for React Fiber traversal when React is detected on the page.
5
+ * Gracefully degrades to null when React is not present or fibers are inaccessible.
6
+ */
7
+
8
+ import {
9
+ getNearestComponentNameWithBippy,
10
+ getReactContextWithBippy,
11
+ isReactDetectedWithBippy,
12
+ } from "./react-context-core.js";
13
+
14
+ let bippy = null;
15
+
16
+ // bippy is bundled at build time via esbuild
17
+ try {
18
+ // Dynamic require at bundle time — esbuild will resolve this
19
+ bippy = require("bippy");
20
+ } catch {
21
+ // bippy not available — React extraction will be skipped
22
+ }
23
+
24
+ /**
25
+ * Check if React is present on the page.
26
+ */
27
+ export function isReactDetected() {
28
+ if (!bippy) return false;
29
+ return isReactDetectedWithBippy(bippy, document);
30
+ }
31
+
32
+ /**
33
+ * Get the nearest useful React component name for an element.
34
+ */
35
+ export function getNearestComponentName(element) {
36
+ return getNearestComponentNameWithBippy(bippy, element);
37
+ }
38
+
39
+ /**
40
+ * Get full React context for an element — component hierarchy, source, props summary.
41
+ */
42
+ export function getReactContext(element) {
43
+ return getReactContextWithBippy(bippy, element);
44
+ }
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { getNearestComponentName, getReactContext, isReactDetected } from "./react-context.js";
5
+
6
+ function reactFiberLikeElement() {
7
+ const appFiber = {
8
+ type: { name: "CheckoutPage" },
9
+ _debugSource: { fileName: "/src/CheckoutPage.jsx", lineNumber: 12, columnNumber: 4 },
10
+ return: null,
11
+ };
12
+ const buttonFiber = {
13
+ type: { displayName: "PayButton" },
14
+ return: appFiber,
15
+ };
16
+ return {
17
+ "__reactFiber$detour": {
18
+ type: "button",
19
+ return: buttonFiber,
20
+ },
21
+ };
22
+ }
23
+
24
+ test("React detection degrades to false without bundled bippy", () => {
25
+ const root = reactFiberLikeElement();
26
+ globalThis.document = {
27
+ querySelectorAll: () => [root],
28
+ body: root,
29
+ };
30
+
31
+ // In Node ESM, react-context.js intentionally imports with bippy unavailable;
32
+ // these browserless tests lock the public graceful-degradation behavior.
33
+ assert.equal(isReactDetected(), false);
34
+ });
35
+
36
+ test("React context exports return null for fiber-like elements without bippy", () => {
37
+ const element = reactFiberLikeElement();
38
+
39
+ assert.equal(getNearestComponentName(element), null);
40
+ assert.equal(getReactContext(element), null);
41
+ });
@@ -0,0 +1,23 @@
1
+ # Tap provider templates
2
+
3
+ These templates are intentionally dependency-free starting points for external
4
+ providers. They normalize events and tool shapes; real credentials and API calls
5
+ belong in environment-specific adapters.
6
+
7
+ Each template follows the same contract:
8
+
9
+ 1. Read provider token from `TAP_PROVIDER_TOKEN` or the tap token file.
10
+ 2. Connect to `ws://127.0.0.1:9400`.
11
+ 3. Authenticate and declare tools.
12
+ 4. Emit normalized event JSON suitable for EventFilter rules.
13
+
14
+ Templates:
15
+
16
+ - `ci-review-provider.mjs` — structured code review findings.
17
+ - `jira-github-provider.mjs` — Jira issue and GitHub PR handoff shape.
18
+ - `sast-triage-provider.mjs` — security finding triage and fingerprinting.
19
+ - `detour-workflow-provider.mjs` — browser/Detour event normalization.
20
+
21
+ These are not production integrations by themselves. They are safe scaffolds for
22
+ turning CI, issue trackers, SAST scanners, and browser instrumentation into tap
23
+ provider workflows.