gsd-pi 2.22.0 → 2.24.0

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 (228) hide show
  1. package/README.md +25 -1
  2. package/dist/cli.js +74 -7
  3. package/dist/headless.d.ts +25 -0
  4. package/dist/headless.js +454 -0
  5. package/dist/help-text.js +47 -0
  6. package/dist/mcp-server.d.ts +20 -3
  7. package/dist/mcp-server.js +21 -1
  8. package/dist/models-resolver.d.ts +32 -0
  9. package/dist/models-resolver.js +50 -0
  10. package/dist/resource-loader.js +64 -9
  11. package/dist/resources/extensions/bg-shell/output-formatter.ts +36 -16
  12. package/dist/resources/extensions/bg-shell/process-manager.ts +6 -4
  13. package/dist/resources/extensions/bg-shell/types.ts +33 -1
  14. package/dist/resources/extensions/browser-tools/capture.ts +18 -16
  15. package/dist/resources/extensions/browser-tools/index.ts +20 -0
  16. package/dist/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  17. package/dist/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  18. package/dist/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  19. package/dist/resources/extensions/browser-tools/tools/device.ts +183 -0
  20. package/dist/resources/extensions/browser-tools/tools/extract.ts +229 -0
  21. package/dist/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  22. package/dist/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  23. package/dist/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  24. package/dist/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  25. package/dist/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  26. package/dist/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  27. package/dist/resources/extensions/gsd/auto-dashboard.ts +2 -0
  28. package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
  29. package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
  30. package/dist/resources/extensions/gsd/auto-recovery.ts +51 -2
  31. package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
  32. package/dist/resources/extensions/gsd/auto.ts +560 -52
  33. package/dist/resources/extensions/gsd/captures.ts +49 -0
  34. package/dist/resources/extensions/gsd/commands.ts +194 -11
  35. package/dist/resources/extensions/gsd/complexity.ts +1 -0
  36. package/dist/resources/extensions/gsd/dashboard-overlay.ts +54 -2
  37. package/dist/resources/extensions/gsd/diff-context.ts +73 -80
  38. package/dist/resources/extensions/gsd/doctor.ts +76 -12
  39. package/dist/resources/extensions/gsd/exit-command.ts +2 -2
  40. package/dist/resources/extensions/gsd/forensics.ts +95 -52
  41. package/dist/resources/extensions/gsd/gitignore.ts +1 -0
  42. package/dist/resources/extensions/gsd/guided-flow.ts +85 -5
  43. package/dist/resources/extensions/gsd/index.ts +34 -1
  44. package/dist/resources/extensions/gsd/mcp-server.ts +33 -12
  45. package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  46. package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
  47. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  48. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  49. package/dist/resources/extensions/gsd/preferences.ts +65 -1
  50. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  51. package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -0
  52. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  53. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  54. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  55. package/dist/resources/extensions/gsd/prompts/system.md +2 -1
  56. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
  57. package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
  58. package/dist/resources/extensions/gsd/roadmap-slices.ts +41 -1
  59. package/dist/resources/extensions/gsd/session-forensics.ts +36 -2
  60. package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
  61. package/dist/resources/extensions/gsd/state.ts +72 -30
  62. package/dist/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  63. package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  64. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  65. package/dist/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  66. package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  67. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
  68. package/dist/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  69. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  70. package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  71. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  72. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  73. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  74. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  75. package/dist/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  76. package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  77. package/dist/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  78. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  79. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  80. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  81. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  82. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  83. package/dist/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  84. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  85. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  86. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  87. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  88. package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  89. package/dist/resources/extensions/gsd/triage-resolution.ts +83 -0
  90. package/dist/resources/extensions/gsd/types.ts +15 -1
  91. package/dist/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  92. package/dist/resources/extensions/gsd/workspace-index.ts +34 -6
  93. package/dist/resources/extensions/subagent/index.ts +5 -0
  94. package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
  95. package/dist/update-check.d.ts +9 -0
  96. package/dist/update-check.js +97 -0
  97. package/package.json +6 -1
  98. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  99. package/packages/pi-ai/dist/providers/anthropic.js +16 -7
  100. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  101. package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
  102. package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
  103. package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
  104. package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
  105. package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
  106. package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
  107. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  108. package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
  109. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  110. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  111. package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
  112. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  113. package/packages/pi-ai/src/providers/anthropic.ts +21 -8
  114. package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
  115. package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
  116. package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
  117. package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
  118. package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
  119. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
  121. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  122. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts +10 -0
  123. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts.map +1 -0
  124. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js +79 -0
  125. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js.map +1 -0
  126. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +18 -0
  127. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  128. package/packages/pi-coding-agent/dist/core/tools/bash.js +77 -1
  129. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  130. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -1
  131. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  132. package/packages/pi-coding-agent/dist/core/tools/index.js +1 -1
  133. package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  134. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  135. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  136. package/packages/pi-coding-agent/dist/index.js +1 -1
  137. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  138. package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
  139. package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
  140. package/packages/pi-coding-agent/src/core/tools/bash-background.test.ts +91 -0
  141. package/packages/pi-coding-agent/src/core/tools/bash.ts +83 -1
  142. package/packages/pi-coding-agent/src/core/tools/index.ts +1 -0
  143. package/packages/pi-coding-agent/src/index.ts +1 -0
  144. package/scripts/postinstall.js +7 -109
  145. package/src/resources/extensions/bg-shell/output-formatter.ts +36 -16
  146. package/src/resources/extensions/bg-shell/process-manager.ts +6 -4
  147. package/src/resources/extensions/bg-shell/types.ts +33 -1
  148. package/src/resources/extensions/browser-tools/capture.ts +18 -16
  149. package/src/resources/extensions/browser-tools/index.ts +20 -0
  150. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  151. package/src/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  152. package/src/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  153. package/src/resources/extensions/browser-tools/tools/device.ts +183 -0
  154. package/src/resources/extensions/browser-tools/tools/extract.ts +229 -0
  155. package/src/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  156. package/src/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  157. package/src/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  158. package/src/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  159. package/src/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  160. package/src/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  161. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
  162. package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
  163. package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
  164. package/src/resources/extensions/gsd/auto-recovery.ts +51 -2
  165. package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
  166. package/src/resources/extensions/gsd/auto.ts +560 -52
  167. package/src/resources/extensions/gsd/captures.ts +49 -0
  168. package/src/resources/extensions/gsd/commands.ts +194 -11
  169. package/src/resources/extensions/gsd/complexity.ts +1 -0
  170. package/src/resources/extensions/gsd/dashboard-overlay.ts +54 -2
  171. package/src/resources/extensions/gsd/diff-context.ts +73 -80
  172. package/src/resources/extensions/gsd/doctor.ts +76 -12
  173. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  174. package/src/resources/extensions/gsd/forensics.ts +95 -52
  175. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  176. package/src/resources/extensions/gsd/guided-flow.ts +85 -5
  177. package/src/resources/extensions/gsd/index.ts +34 -1
  178. package/src/resources/extensions/gsd/mcp-server.ts +33 -12
  179. package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  180. package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
  181. package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  182. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  183. package/src/resources/extensions/gsd/preferences.ts +65 -1
  184. package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  185. package/src/resources/extensions/gsd/prompts/execute-task.md +5 -0
  186. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  187. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  188. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  189. package/src/resources/extensions/gsd/prompts/system.md +2 -1
  190. package/src/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
  191. package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
  192. package/src/resources/extensions/gsd/roadmap-slices.ts +41 -1
  193. package/src/resources/extensions/gsd/session-forensics.ts +36 -2
  194. package/src/resources/extensions/gsd/session-status-io.ts +197 -0
  195. package/src/resources/extensions/gsd/state.ts +72 -30
  196. package/src/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  197. package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  198. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  199. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  200. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  201. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
  202. package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  203. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  204. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  205. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  206. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  207. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  208. package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  209. package/src/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  210. package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  211. package/src/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  212. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  213. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  214. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  215. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  216. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  217. package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  218. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  219. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  220. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  221. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  222. package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  223. package/src/resources/extensions/gsd/triage-resolution.ts +83 -0
  224. package/src/resources/extensions/gsd/types.ts +15 -1
  225. package/src/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  226. package/src/resources/extensions/gsd/workspace-index.ts +34 -6
  227. package/src/resources/extensions/subagent/index.ts +5 -0
  228. package/src/resources/extensions/subagent/worker-registry.ts +99 -0
@@ -612,3 +612,28 @@ describe("constrainScreenshot", () => {
612
612
  assert.equal(meta.height, 1568);
613
613
  });
614
614
  });
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // browser_save_pdf — tool registration
618
+ // ---------------------------------------------------------------------------
619
+
620
+ describe("browser_save_pdf tool registration", () => {
621
+ it("registerPdfTools exports a function", () => {
622
+ const { registerPdfTools } = jiti("../tools/pdf.ts");
623
+ assert.equal(typeof registerPdfTools, "function", "registerPdfTools should be a function");
624
+ });
625
+
626
+ it("tool can be registered with a mock pi", () => {
627
+ const { registerPdfTools } = jiti("../tools/pdf.ts");
628
+ const registeredTools = [];
629
+ const mockPi = {
630
+ registerTool: (tool) => registeredTools.push(tool),
631
+ };
632
+ const mockDeps = {};
633
+ registerPdfTools(mockPi, mockDeps);
634
+ assert.equal(registeredTools.length, 1, "should register exactly 1 tool");
635
+ assert.equal(registeredTools[0].name, "browser_save_pdf", "tool name should be browser_save_pdf");
636
+ assert.ok(registeredTools[0].parameters, "tool should have parameters schema");
637
+ assert.equal(typeof registeredTools[0].execute, "function", "tool should have execute function");
638
+ });
639
+ });
@@ -0,0 +1,216 @@
1
+ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { ToolDeps } from "../state.js";
4
+
5
+ /**
6
+ * Action caching — cache semantic intent → selector mappings to skip LLM inference on repeat visits.
7
+ * Internal optimization that hooks into browser_find_best / browser_act.
8
+ */
9
+
10
+ interface CacheEntry {
11
+ selector: string;
12
+ score: number;
13
+ url: string;
14
+ domHash: string;
15
+ timestamp: number;
16
+ hitCount: number;
17
+ }
18
+
19
+ const cache = new Map<string, CacheEntry>();
20
+ const MAX_CACHE_SIZE = 200;
21
+
22
+ export function registerActionCacheTools(pi: ExtensionAPI, deps: ToolDeps): void {
23
+ // -------------------------------------------------------------------------
24
+ // browser_action_cache
25
+ // -------------------------------------------------------------------------
26
+ pi.registerTool({
27
+ name: "browser_action_cache",
28
+ label: "Browser Action Cache",
29
+ description:
30
+ "Manage the action cache that maps page structure + intent → resolved selectors. " +
31
+ "Cache reduces token cost on repeat visits to same pages. " +
32
+ "Actions: 'stats' (show cache metrics), 'get' (lookup cached selector), " +
33
+ "'put' (store a selector mapping), 'clear' (flush cache).",
34
+ parameters: Type.Object({
35
+ action: Type.String({
36
+ description: "Cache action: 'stats', 'get', 'put', or 'clear'.",
37
+ }),
38
+ intent: Type.Optional(
39
+ Type.String({ description: "Semantic intent key (for get/put). E.g., 'submit_form', 'close_dialog'." }),
40
+ ),
41
+ selector: Type.Optional(
42
+ Type.String({ description: "CSS selector to cache (for put)." }),
43
+ ),
44
+ score: Type.Optional(
45
+ Type.Number({ description: "Confidence score 0–1 for the cached selector (for put)." }),
46
+ ),
47
+ }),
48
+
49
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
50
+ try {
51
+ const { page: p } = await deps.ensureBrowser();
52
+ const url = p.url();
53
+
54
+ switch (params.action) {
55
+ case "stats": {
56
+ const entries = [...cache.values()];
57
+ const totalHits = entries.reduce((sum, e) => sum + e.hitCount, 0);
58
+ return {
59
+ content: [{
60
+ type: "text",
61
+ text: `Action cache: ${cache.size} entries, ${totalHits} total hits\nMax size: ${MAX_CACHE_SIZE}`,
62
+ }],
63
+ details: {
64
+ size: cache.size,
65
+ maxSize: MAX_CACHE_SIZE,
66
+ totalHits,
67
+ entries: entries.map((e) => ({
68
+ url: e.url,
69
+ selector: e.selector,
70
+ hitCount: e.hitCount,
71
+ score: e.score,
72
+ })),
73
+ },
74
+ };
75
+ }
76
+
77
+ case "get": {
78
+ if (!params.intent) {
79
+ return {
80
+ content: [{ type: "text", text: "Intent parameter required for 'get' action." }],
81
+ details: { error: "missing_intent" },
82
+ isError: true,
83
+ };
84
+ }
85
+
86
+ const domHash = await computeDomHash(p);
87
+ const key = buildCacheKey(url, domHash, params.intent);
88
+ const entry = cache.get(key);
89
+
90
+ if (!entry) {
91
+ return {
92
+ content: [{ type: "text", text: `Cache miss for intent "${params.intent}" on ${url}` }],
93
+ details: { hit: false, intent: params.intent, url },
94
+ };
95
+ }
96
+
97
+ // Validate the cached selector still exists
98
+ const exists = await p.locator(entry.selector).first().isVisible().catch(() => false);
99
+ if (!exists) {
100
+ cache.delete(key);
101
+ return {
102
+ content: [{ type: "text", text: `Cache entry stale (selector no longer visible): ${entry.selector}` }],
103
+ details: { hit: false, stale: true, selector: entry.selector },
104
+ };
105
+ }
106
+
107
+ entry.hitCount++;
108
+ return {
109
+ content: [{
110
+ type: "text",
111
+ text: `Cache hit: "${params.intent}" → ${entry.selector} (score: ${entry.score}, hits: ${entry.hitCount})`,
112
+ }],
113
+ details: { hit: true, ...entry },
114
+ };
115
+ }
116
+
117
+ case "put": {
118
+ if (!params.intent || !params.selector) {
119
+ return {
120
+ content: [{ type: "text", text: "Intent and selector parameters required for 'put' action." }],
121
+ details: { error: "missing_params" },
122
+ isError: true,
123
+ };
124
+ }
125
+
126
+ const domHash = await computeDomHash(p);
127
+ const key = buildCacheKey(url, domHash, params.intent);
128
+
129
+ // Evict oldest entries if at capacity
130
+ if (cache.size >= MAX_CACHE_SIZE && !cache.has(key)) {
131
+ const oldestKey = [...cache.entries()]
132
+ .sort(([, a], [, b]) => a.timestamp - b.timestamp)[0]?.[0];
133
+ if (oldestKey) cache.delete(oldestKey);
134
+ }
135
+
136
+ const entry: CacheEntry = {
137
+ selector: params.selector,
138
+ score: params.score ?? 1.0,
139
+ url,
140
+ domHash,
141
+ timestamp: Date.now(),
142
+ hitCount: 0,
143
+ };
144
+ cache.set(key, entry);
145
+
146
+ return {
147
+ content: [{
148
+ type: "text",
149
+ text: `Cached: "${params.intent}" → ${params.selector} (cache size: ${cache.size})`,
150
+ }],
151
+ details: { stored: true, key, ...entry, cacheSize: cache.size },
152
+ };
153
+ }
154
+
155
+ case "clear": {
156
+ const size = cache.size;
157
+ cache.clear();
158
+ return {
159
+ content: [{ type: "text", text: `Action cache cleared (${size} entries removed).` }],
160
+ details: { cleared: size },
161
+ };
162
+ }
163
+
164
+ default:
165
+ return {
166
+ content: [{ type: "text", text: `Unknown action: ${params.action}. Use 'stats', 'get', 'put', or 'clear'.` }],
167
+ details: { error: "unknown_action" },
168
+ isError: true,
169
+ };
170
+ }
171
+ } catch (err: any) {
172
+ return {
173
+ content: [{ type: "text", text: `Action cache error: ${err.message}` }],
174
+ details: { error: err.message },
175
+ isError: true,
176
+ };
177
+ }
178
+ },
179
+ });
180
+ }
181
+
182
+ function buildCacheKey(url: string, domHash: string, intent: string): string {
183
+ // Normalize URL — strip hash and query params for broader matching
184
+ let normalized: string;
185
+ try {
186
+ const u = new URL(url);
187
+ normalized = `${u.origin}${u.pathname}`;
188
+ } catch {
189
+ normalized = url;
190
+ }
191
+ return `${normalized}|${domHash}|${intent}`;
192
+ }
193
+
194
+ async function computeDomHash(page: any): Promise<string> {
195
+ try {
196
+ return await page.evaluate(() => {
197
+ // Structural hash based on element count + tag distribution
198
+ const tags = new Map<string, number>();
199
+ const all = document.querySelectorAll("*");
200
+ for (const el of all) {
201
+ const tag = el.tagName;
202
+ tags.set(tag, (tags.get(tag) ?? 0) + 1);
203
+ }
204
+ const entries = [...tags.entries()].sort((a, b) => a[0].localeCompare(b[0]));
205
+ const str = entries.map(([t, c]) => `${t}:${c}`).join("|");
206
+ // Simple hash
207
+ let h = 5381;
208
+ for (let i = 0; i < str.length; i++) {
209
+ h = ((h << 5) - h + str.charCodeAt(i)) | 0;
210
+ }
211
+ return (h >>> 0).toString(16);
212
+ });
213
+ } catch {
214
+ return "unknown";
215
+ }
216
+ }
@@ -0,0 +1,274 @@
1
+ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { ToolDeps } from "../state.js";
4
+ import { getActionTimeline } from "../state.js";
5
+
6
+ /**
7
+ * Test code generation — transform recorded browser session into a Playwright test script.
8
+ */
9
+
10
+ export function registerCodegenTools(pi: ExtensionAPI, deps: ToolDeps): void {
11
+ pi.registerTool({
12
+ name: "browser_generate_test",
13
+ label: "Browser Generate Test",
14
+ description:
15
+ "Generate a runnable Playwright test script from the recorded action timeline. " +
16
+ "Transforms navigation, click, type, and assertion actions into standard Playwright test syntax. " +
17
+ "Uses stable selectors (role-based preferred). Writes the test file to a configurable path.",
18
+ parameters: Type.Object({
19
+ name: Type.Optional(
20
+ Type.String({ description: "Test name (used for describe/test block and filename). Default: 'recorded-session'." }),
21
+ ),
22
+ outputPath: Type.Optional(
23
+ Type.String({
24
+ description:
25
+ "Output file path for the generated test. Default: writes to session artifacts directory. " +
26
+ "Use a path ending in .spec.ts for standard Playwright test convention.",
27
+ }),
28
+ ),
29
+ includeAssertions: Type.Optional(
30
+ Type.Boolean({ description: "Include assertion steps from the timeline (default: true)." }),
31
+ ),
32
+ }),
33
+
34
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
35
+ try {
36
+ await deps.ensureBrowser();
37
+ const timeline = getActionTimeline();
38
+
39
+ if (timeline.entries.length === 0) {
40
+ return {
41
+ content: [{ type: "text", text: "No actions recorded in the current session. Interact with pages first, then generate a test." }],
42
+ details: { error: "no_actions" },
43
+ isError: true,
44
+ };
45
+ }
46
+
47
+ const testName = params.name ?? "recorded-session";
48
+ const includeAssertions = params.includeAssertions ?? true;
49
+
50
+ // Transform timeline entries into Playwright test code
51
+ const testLines: string[] = [];
52
+ const imports = new Set<string>();
53
+ imports.add("test");
54
+ imports.add("expect");
55
+
56
+ testLines.push(`test.describe('${escapeString(testName)}', () => {`);
57
+ testLines.push(` test('recorded session', async ({ page }) => {`);
58
+
59
+ let lastUrl = "";
60
+ let actionCount = 0;
61
+
62
+ for (const entry of timeline.entries) {
63
+ if (entry.status === "error" && entry.tool !== "browser_assert") continue;
64
+
65
+ const params = parseParamsSummary(entry.paramsSummary);
66
+
67
+ switch (entry.tool) {
68
+ case "browser_navigate": {
69
+ const url = params.url;
70
+ if (url && url !== lastUrl) {
71
+ testLines.push(` await page.goto(${quote(url)});`);
72
+ lastUrl = url;
73
+ actionCount++;
74
+ }
75
+ break;
76
+ }
77
+
78
+ case "browser_click": {
79
+ const selector = params.selector;
80
+ if (selector) {
81
+ testLines.push(` await page.locator(${quote(selector)}).click();`);
82
+ actionCount++;
83
+ }
84
+ break;
85
+ }
86
+
87
+ case "browser_click_ref": {
88
+ // Refs are session-specific — add comment
89
+ testLines.push(` // browser_click_ref: ${entry.paramsSummary} — replace with stable selector`);
90
+ actionCount++;
91
+ break;
92
+ }
93
+
94
+ case "browser_type": {
95
+ const selector = params.selector;
96
+ const text = params.text;
97
+ if (selector && text) {
98
+ testLines.push(` await page.locator(${quote(selector)}).fill(${quote(text)});`);
99
+ actionCount++;
100
+ }
101
+ break;
102
+ }
103
+
104
+ case "browser_fill_ref": {
105
+ testLines.push(` // browser_fill_ref: ${entry.paramsSummary} — replace with stable selector`);
106
+ actionCount++;
107
+ break;
108
+ }
109
+
110
+ case "browser_key_press": {
111
+ const key = params.key;
112
+ if (key) {
113
+ testLines.push(` await page.keyboard.press(${quote(key)});`);
114
+ actionCount++;
115
+ }
116
+ break;
117
+ }
118
+
119
+ case "browser_select_option": {
120
+ const selector = params.selector;
121
+ const option = params.option;
122
+ if (selector && option) {
123
+ testLines.push(` await page.locator(${quote(selector)}).selectOption(${quote(option)});`);
124
+ actionCount++;
125
+ }
126
+ break;
127
+ }
128
+
129
+ case "browser_set_checked": {
130
+ const selector = params.selector;
131
+ const checked = params.checked;
132
+ if (selector) {
133
+ testLines.push(` await page.locator(${quote(selector)}).setChecked(${checked === "true"});`);
134
+ actionCount++;
135
+ }
136
+ break;
137
+ }
138
+
139
+ case "browser_hover": {
140
+ const selector = params.selector;
141
+ if (selector) {
142
+ testLines.push(` await page.locator(${quote(selector)}).hover();`);
143
+ actionCount++;
144
+ }
145
+ break;
146
+ }
147
+
148
+ case "browser_wait_for": {
149
+ const condition = params.condition;
150
+ const value = params.value;
151
+ if (condition === "selector_visible" && value) {
152
+ testLines.push(` await expect(page.locator(${quote(value)})).toBeVisible();`);
153
+ actionCount++;
154
+ } else if (condition === "text_visible" && value) {
155
+ testLines.push(` await expect(page.locator('body')).toContainText(${quote(value)});`);
156
+ actionCount++;
157
+ } else if (condition === "url_contains" && value) {
158
+ testLines.push(` await page.waitForURL(${quote(`**/*${value}*`)});`);
159
+ actionCount++;
160
+ } else if (condition === "network_idle") {
161
+ testLines.push(` await page.waitForLoadState('networkidle');`);
162
+ actionCount++;
163
+ } else if (condition === "delay" && value) {
164
+ testLines.push(` await page.waitForTimeout(${value});`);
165
+ actionCount++;
166
+ }
167
+ break;
168
+ }
169
+
170
+ case "browser_assert": {
171
+ if (!includeAssertions) break;
172
+ // The assertion details are in verificationSummary
173
+ if (entry.verificationSummary) {
174
+ testLines.push(` // Assertion: ${entry.verificationSummary}`);
175
+ }
176
+ actionCount++;
177
+ break;
178
+ }
179
+
180
+ case "browser_scroll": {
181
+ const direction = params.direction;
182
+ const amount = params.amount ?? "300";
183
+ const delta = direction === "up" ? `-${amount}` : amount;
184
+ testLines.push(` await page.mouse.wheel(0, ${delta});`);
185
+ actionCount++;
186
+ break;
187
+ }
188
+
189
+ case "browser_set_viewport": {
190
+ const width = params.width;
191
+ const height = params.height;
192
+ if (width && height) {
193
+ testLines.push(` await page.setViewportSize({ width: ${width}, height: ${height} });`);
194
+ actionCount++;
195
+ }
196
+ break;
197
+ }
198
+
199
+ default:
200
+ // Skip tools that don't map to Playwright test actions
201
+ break;
202
+ }
203
+ }
204
+
205
+ testLines.push(` });`);
206
+ testLines.push(`});`);
207
+
208
+ const importLine = `import { ${[...imports].join(", ")} } from '@playwright/test';`;
209
+ const fullTest = `${importLine}\n\n${testLines.join("\n")}\n`;
210
+
211
+ // Write to file
212
+ let outputPath: string;
213
+ if (params.outputPath) {
214
+ outputPath = params.outputPath;
215
+ } else {
216
+ const safeName = deps.sanitizeArtifactName(testName, "recorded-session");
217
+ outputPath = deps.buildSessionArtifactPath(`${safeName}.spec.ts`);
218
+ }
219
+
220
+ await deps.ensureSessionArtifactDir();
221
+ const { path: writtenPath, bytes } = await deps.writeArtifactFile(outputPath, fullTest);
222
+
223
+ return {
224
+ content: [{
225
+ type: "text",
226
+ text: `Test generated: ${writtenPath}\nActions: ${actionCount}\nTimeline entries processed: ${timeline.entries.length}\n\n${fullTest}`,
227
+ }],
228
+ details: {
229
+ path: writtenPath,
230
+ bytes,
231
+ actionCount,
232
+ timelineEntries: timeline.entries.length,
233
+ testCode: fullTest,
234
+ },
235
+ };
236
+ } catch (err: any) {
237
+ return {
238
+ content: [{ type: "text", text: `Test generation failed: ${err.message}` }],
239
+ details: { error: err.message },
240
+ isError: true,
241
+ };
242
+ }
243
+ },
244
+ });
245
+ }
246
+
247
+ function escapeString(s: string): string {
248
+ return s.replace(/'/g, "\\'").replace(/\\/g, "\\\\");
249
+ }
250
+
251
+ function quote(s: string): string {
252
+ // Use single quotes for simple strings, backtick for those with quotes
253
+ if (!s.includes("'")) return `'${s}'`;
254
+ if (!s.includes("`")) return `\`${s}\``;
255
+ return `'${s.replace(/'/g, "\\'")}'`;
256
+ }
257
+
258
+ /**
259
+ * Parse the paramsSummary string back into key-value pairs.
260
+ * Format: key="value", key=value, key=[N], key={...}
261
+ */
262
+ function parseParamsSummary(summary: string): Record<string, string> {
263
+ const result: Record<string, string> = {};
264
+ if (!summary) return result;
265
+
266
+ const regex = /(\w+)=(?:"([^"]*(?:\\"[^"]*)*)"|([^,\s]+))/g;
267
+ let match;
268
+ while ((match = regex.exec(summary)) !== null) {
269
+ const key = match[1];
270
+ const value = match[2] ?? match[3];
271
+ result[key] = value;
272
+ }
273
+ return result;
274
+ }
@@ -0,0 +1,183 @@
1
+ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { ToolDeps } from "../state.js";
4
+
5
+ /**
6
+ * Device emulation tool — full device simulation using Playwright's built-in device descriptors.
7
+ */
8
+
9
+ export function registerDeviceTools(pi: ExtensionAPI, deps: ToolDeps): void {
10
+ pi.registerTool({
11
+ name: "browser_emulate_device",
12
+ label: "Browser Emulate Device",
13
+ description:
14
+ "Simulate a specific device by setting viewport, user agent, device scale factor, touch, and mobile flag. " +
15
+ "Uses Playwright's built-in device descriptors (~143 devices). Accepts fuzzy matching on device name. " +
16
+ "Note: Full emulation (user agent, isMobile) requires a context restart — the current page state will be lost. " +
17
+ "The tool recreates the context with the device profile applied.",
18
+ parameters: Type.Object({
19
+ device: Type.String({
20
+ description:
21
+ "Device name (e.g., 'iPhone 15', 'Pixel 7', 'iPad Pro 11'). " +
22
+ "Case-insensitive fuzzy matching. Use 'list' to see all available devices.",
23
+ }),
24
+ }),
25
+
26
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
27
+ try {
28
+ const { chromium, devices } = await import("playwright");
29
+ const allDeviceNames = Object.keys(devices);
30
+
31
+ // Handle 'list' request
32
+ if (params.device.toLowerCase() === "list") {
33
+ // Group by base device name (remove landscape variants for cleaner display)
34
+ const baseNames = allDeviceNames.filter((n) => !n.endsWith(" landscape"));
35
+ return {
36
+ content: [{
37
+ type: "text",
38
+ text: `Available devices (${allDeviceNames.length} total, ${baseNames.length} base):\n${baseNames.join("\n")}`,
39
+ }],
40
+ details: { devices: baseNames, total: allDeviceNames.length },
41
+ };
42
+ }
43
+
44
+ // Fuzzy match device name
45
+ const needle = params.device.toLowerCase();
46
+ let exactMatch = allDeviceNames.find((n) => n.toLowerCase() === needle);
47
+ if (!exactMatch) {
48
+ // Try contains match
49
+ const containsMatches = allDeviceNames.filter((n) => n.toLowerCase().includes(needle));
50
+ if (containsMatches.length === 1) {
51
+ exactMatch = containsMatches[0];
52
+ } else if (containsMatches.length > 1) {
53
+ // Pick the shortest match (most specific)
54
+ containsMatches.sort((a, b) => a.length - b.length);
55
+ exactMatch = containsMatches[0];
56
+ const suggestions = containsMatches.slice(0, 5).join(", ");
57
+ // Continue with best match but mention alternatives
58
+ } else {
59
+ // No match at all — suggest closest
60
+ const suggestions = allDeviceNames
61
+ .map((n) => ({ name: n, score: fuzzyScore(needle, n.toLowerCase()) }))
62
+ .sort((a, b) => b.score - a.score)
63
+ .slice(0, 5)
64
+ .map((s) => s.name);
65
+
66
+ return {
67
+ content: [{
68
+ type: "text",
69
+ text: `No device matching "${params.device}". Did you mean:\n${suggestions.map((s) => ` - ${s}`).join("\n")}`,
70
+ }],
71
+ details: { error: "no_match", suggestions },
72
+ isError: true,
73
+ };
74
+ }
75
+ }
76
+
77
+ const deviceDescriptor = devices[exactMatch!];
78
+ if (!deviceDescriptor) {
79
+ return {
80
+ content: [{ type: "text", text: `Device descriptor not found for "${exactMatch}"` }],
81
+ details: { error: "descriptor_not_found" },
82
+ isError: true,
83
+ };
84
+ }
85
+
86
+ // Context restart required for full emulation.
87
+ // Save current URL to navigate back after restart.
88
+ const { page: currentPage, context: currentCtx } = await deps.ensureBrowser();
89
+ const currentUrl = currentPage.url();
90
+
91
+ // Close existing browser and relaunch with device profile
92
+ await deps.closeBrowser();
93
+
94
+ // Re-launch — ensureBrowser doesn't accept device params, so we do it manually.
95
+ // This is a one-off context creation with device emulation.
96
+ const needsHeadless = process.platform === "linux" && !process.env.DISPLAY;
97
+ const launchOptions: Record<string, unknown> = {
98
+ headless: needsHeadless || process.env.FORCE_HEADLESS === "true",
99
+ };
100
+ const customPath = process.env.BROWSER_PATH;
101
+ if (customPath) launchOptions.executablePath = customPath;
102
+
103
+ const browser = await chromium.launch(launchOptions);
104
+ const context = await browser.newContext({
105
+ ...deviceDescriptor,
106
+ });
107
+
108
+ // Inject evaluate helpers
109
+ const { EVALUATE_HELPERS_SOURCE } = await import("../evaluate-helpers.js");
110
+ await context.addInitScript(EVALUATE_HELPERS_SOURCE);
111
+
112
+ // Wire up state
113
+ const {
114
+ setBrowser, setContext, pageRegistry, setSessionStartedAt,
115
+ setSessionArtifactDir, resetAllState,
116
+ } = await import("../state.js");
117
+ const { registryAddPage, registrySetActive } = await import("../core.js");
118
+
119
+ // Reset state for new session
120
+ resetAllState();
121
+ setBrowser(browser);
122
+ setContext(context);
123
+ setSessionStartedAt(Date.now());
124
+
125
+ const page = await context.newPage();
126
+ const entry = registryAddPage(pageRegistry, {
127
+ page,
128
+ title: "",
129
+ url: "about:blank",
130
+ opener: null,
131
+ });
132
+ registrySetActive(pageRegistry, entry.id);
133
+ deps.attachPageListeners(page, entry.id);
134
+
135
+ // Navigate back to previous URL if it wasn't about:blank
136
+ if (currentUrl && currentUrl !== "about:blank") {
137
+ await page.goto(currentUrl, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {});
138
+ }
139
+
140
+ const viewport = deviceDescriptor.viewport;
141
+ const vpText = viewport ? `${viewport.width}x${viewport.height}` : "unknown";
142
+
143
+ return {
144
+ content: [{
145
+ type: "text",
146
+ text: `Device emulation active: ${exactMatch}\nViewport: ${vpText}\nUser Agent: ${deviceDescriptor.userAgent?.slice(0, 80) ?? "default"}...\nMobile: ${deviceDescriptor.isMobile ?? false}\nTouch: ${deviceDescriptor.hasTouch ?? false}\nScale Factor: ${deviceDescriptor.deviceScaleFactor ?? 1}\n\nContext was restarted for full emulation. Page state was reset.`,
147
+ }],
148
+ details: {
149
+ device: exactMatch,
150
+ viewport: vpText,
151
+ isMobile: deviceDescriptor.isMobile ?? false,
152
+ hasTouch: deviceDescriptor.hasTouch ?? false,
153
+ deviceScaleFactor: deviceDescriptor.deviceScaleFactor ?? 1,
154
+ userAgent: deviceDescriptor.userAgent,
155
+ restoredUrl: currentUrl,
156
+ },
157
+ };
158
+ } catch (err: any) {
159
+ return {
160
+ content: [{ type: "text", text: `Device emulation failed: ${err.message}` }],
161
+ details: { error: err.message },
162
+ isError: true,
163
+ };
164
+ }
165
+ },
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Simple fuzzy scoring — counts matching characters in order.
171
+ */
172
+ function fuzzyScore(needle: string, haystack: string): number {
173
+ let score = 0;
174
+ let hi = 0;
175
+ for (let ni = 0; ni < needle.length && hi < haystack.length; ni++) {
176
+ const idx = haystack.indexOf(needle[ni], hi);
177
+ if (idx >= 0) {
178
+ score++;
179
+ hi = idx + 1;
180
+ }
181
+ }
182
+ return score / Math.max(needle.length, 1);
183
+ }