gsd-pi 2.22.0 → 2.23.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 (128) hide show
  1. package/README.md +25 -1
  2. package/dist/cli.js +62 -4
  3. package/dist/headless.d.ts +21 -0
  4. package/dist/headless.js +346 -0
  5. package/dist/help-text.js +32 -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/resources/extensions/bg-shell/output-formatter.ts +36 -16
  11. package/dist/resources/extensions/bg-shell/process-manager.ts +6 -4
  12. package/dist/resources/extensions/bg-shell/types.ts +33 -1
  13. package/dist/resources/extensions/browser-tools/capture.ts +18 -16
  14. package/dist/resources/extensions/browser-tools/index.ts +20 -0
  15. package/dist/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  16. package/dist/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  17. package/dist/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  18. package/dist/resources/extensions/browser-tools/tools/device.ts +183 -0
  19. package/dist/resources/extensions/browser-tools/tools/extract.ts +229 -0
  20. package/dist/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  21. package/dist/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  22. package/dist/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  23. package/dist/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  24. package/dist/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  25. package/dist/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  26. package/dist/resources/extensions/gsd/auto-dashboard.ts +2 -0
  27. package/dist/resources/extensions/gsd/auto-recovery.ts +10 -0
  28. package/dist/resources/extensions/gsd/auto.ts +437 -11
  29. package/dist/resources/extensions/gsd/captures.ts +49 -0
  30. package/dist/resources/extensions/gsd/commands.ts +20 -3
  31. package/dist/resources/extensions/gsd/dashboard-overlay.ts +16 -2
  32. package/dist/resources/extensions/gsd/diff-context.ts +73 -80
  33. package/dist/resources/extensions/gsd/doctor.ts +20 -1
  34. package/dist/resources/extensions/gsd/forensics.ts +95 -52
  35. package/dist/resources/extensions/gsd/guided-flow.ts +10 -5
  36. package/dist/resources/extensions/gsd/mcp-server.ts +33 -12
  37. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  38. package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -0
  39. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  40. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  41. package/dist/resources/extensions/gsd/prompts/system.md +2 -1
  42. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +91 -0
  43. package/dist/resources/extensions/gsd/roadmap-slices.ts +41 -1
  44. package/dist/resources/extensions/gsd/session-forensics.ts +36 -2
  45. package/dist/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  46. package/dist/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  47. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +64 -0
  48. package/dist/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  49. package/dist/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  50. package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  51. package/dist/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  52. package/dist/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  53. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  54. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  55. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  56. package/dist/resources/extensions/gsd/triage-resolution.ts +83 -0
  57. package/dist/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  58. package/dist/resources/extensions/gsd/workspace-index.ts +34 -6
  59. package/package.json +1 -1
  60. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts +10 -0
  61. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts.map +1 -0
  62. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js +79 -0
  63. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js.map +1 -0
  64. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +18 -0
  65. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/core/tools/bash.js +77 -1
  67. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -1
  69. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/tools/index.js +1 -1
  71. package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  73. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  74. package/packages/pi-coding-agent/dist/index.js +1 -1
  75. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  76. package/packages/pi-coding-agent/src/core/tools/bash-background.test.ts +91 -0
  77. package/packages/pi-coding-agent/src/core/tools/bash.ts +83 -1
  78. package/packages/pi-coding-agent/src/core/tools/index.ts +1 -0
  79. package/packages/pi-coding-agent/src/index.ts +1 -0
  80. package/src/resources/extensions/bg-shell/output-formatter.ts +36 -16
  81. package/src/resources/extensions/bg-shell/process-manager.ts +6 -4
  82. package/src/resources/extensions/bg-shell/types.ts +33 -1
  83. package/src/resources/extensions/browser-tools/capture.ts +18 -16
  84. package/src/resources/extensions/browser-tools/index.ts +20 -0
  85. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  86. package/src/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  87. package/src/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  88. package/src/resources/extensions/browser-tools/tools/device.ts +183 -0
  89. package/src/resources/extensions/browser-tools/tools/extract.ts +229 -0
  90. package/src/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  91. package/src/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  92. package/src/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  93. package/src/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  94. package/src/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  95. package/src/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  96. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
  97. package/src/resources/extensions/gsd/auto-recovery.ts +10 -0
  98. package/src/resources/extensions/gsd/auto.ts +437 -11
  99. package/src/resources/extensions/gsd/captures.ts +49 -0
  100. package/src/resources/extensions/gsd/commands.ts +20 -3
  101. package/src/resources/extensions/gsd/dashboard-overlay.ts +16 -2
  102. package/src/resources/extensions/gsd/diff-context.ts +73 -80
  103. package/src/resources/extensions/gsd/doctor.ts +20 -1
  104. package/src/resources/extensions/gsd/forensics.ts +95 -52
  105. package/src/resources/extensions/gsd/guided-flow.ts +10 -5
  106. package/src/resources/extensions/gsd/mcp-server.ts +33 -12
  107. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  108. package/src/resources/extensions/gsd/prompts/execute-task.md +5 -0
  109. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  110. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  111. package/src/resources/extensions/gsd/prompts/system.md +2 -1
  112. package/src/resources/extensions/gsd/prompts/validate-milestone.md +91 -0
  113. package/src/resources/extensions/gsd/roadmap-slices.ts +41 -1
  114. package/src/resources/extensions/gsd/session-forensics.ts +36 -2
  115. package/src/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  116. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  117. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +64 -0
  118. package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  119. package/src/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  120. package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  121. package/src/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  122. package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  123. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  124. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  125. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  126. package/src/resources/extensions/gsd/triage-resolution.ts +83 -0
  127. package/src/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  128. package/src/resources/extensions/gsd/workspace-index.ts +34 -6
@@ -0,0 +1,202 @@
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
+ * State persistence tools — save/restore cookies, localStorage, sessionStorage.
7
+ */
8
+
9
+ const STATE_DIR = ".gsd/browser-state";
10
+
11
+ export function registerStatePersistenceTools(pi: ExtensionAPI, deps: ToolDeps): void {
12
+ // -------------------------------------------------------------------------
13
+ // browser_save_state
14
+ // -------------------------------------------------------------------------
15
+ pi.registerTool({
16
+ name: "browser_save_state",
17
+ label: "Browser Save State",
18
+ description:
19
+ "Save cookies, localStorage, and sessionStorage to disk so authenticated sessions survive browser restarts. " +
20
+ "State files are written to .gsd/browser-state/ and should be gitignored (may contain auth tokens). " +
21
+ "Never displays secret values in output.",
22
+ parameters: Type.Object({
23
+ name: Type.Optional(
24
+ Type.String({ description: "Name for the state file (default: 'default'). Used as the filename stem." }),
25
+ ),
26
+ }),
27
+
28
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
29
+ try {
30
+ const { context: ctx, page: p } = await deps.ensureBrowser();
31
+ const name = deps.sanitizeArtifactName(params.name ?? "default", "default");
32
+
33
+ const { mkdir, writeFile } = await import("node:fs/promises");
34
+ const path = await import("node:path");
35
+ const stateDir = path.resolve(process.cwd(), STATE_DIR);
36
+ await mkdir(stateDir, { recursive: true });
37
+
38
+ // 1. Playwright storageState: cookies + localStorage
39
+ const storageState = await ctx.storageState();
40
+
41
+ // 2. sessionStorage: must be extracted per-origin via page.evaluate
42
+ const sessionStorageData: Record<string, Record<string, string>> = {};
43
+ try {
44
+ const origin = new URL(p.url()).origin;
45
+ const ssData = await p.evaluate(() => {
46
+ const data: Record<string, string> = {};
47
+ for (let i = 0; i < sessionStorage.length; i++) {
48
+ const key = sessionStorage.key(i);
49
+ if (key) data[key] = sessionStorage.getItem(key) ?? "";
50
+ }
51
+ return data;
52
+ });
53
+ if (Object.keys(ssData).length > 0) {
54
+ sessionStorageData[origin] = ssData;
55
+ }
56
+ } catch {
57
+ // Page may not have a valid origin (about:blank, etc.)
58
+ }
59
+
60
+ const combined = {
61
+ storageState,
62
+ sessionStorage: sessionStorageData,
63
+ savedAt: new Date().toISOString(),
64
+ url: p.url(),
65
+ };
66
+
67
+ const filePath = path.join(stateDir, `${name}.json`);
68
+ await writeFile(filePath, JSON.stringify(combined, null, 2));
69
+
70
+ // Ensure .gitignore covers the state dir
71
+ const gitignorePath = path.resolve(process.cwd(), STATE_DIR, ".gitignore");
72
+ await writeFile(gitignorePath, "*\n!.gitignore\n").catch(() => {});
73
+
74
+ const cookieCount = storageState.cookies?.length ?? 0;
75
+ const localStorageOrigins = storageState.origins?.length ?? 0;
76
+ const sessionStorageOrigins = Object.keys(sessionStorageData).length;
77
+
78
+ return {
79
+ content: [{
80
+ type: "text",
81
+ text: `State saved: ${filePath}\nCookies: ${cookieCount}\nlocalStorage origins: ${localStorageOrigins}\nsessionStorage origins: ${sessionStorageOrigins}`,
82
+ }],
83
+ details: {
84
+ path: filePath,
85
+ cookieCount,
86
+ localStorageOrigins,
87
+ sessionStorageOrigins,
88
+ },
89
+ };
90
+ } catch (err: any) {
91
+ return {
92
+ content: [{ type: "text", text: `Save state failed: ${err.message}` }],
93
+ details: { error: err.message },
94
+ isError: true,
95
+ };
96
+ }
97
+ },
98
+ });
99
+
100
+ // -------------------------------------------------------------------------
101
+ // browser_restore_state
102
+ // -------------------------------------------------------------------------
103
+ pi.registerTool({
104
+ name: "browser_restore_state",
105
+ label: "Browser Restore State",
106
+ description:
107
+ "Restore cookies, localStorage, and sessionStorage from a previously saved state file. " +
108
+ "Injects cookies via context.addCookies() and storage via page.evaluate(). " +
109
+ "For full fidelity, restore before navigating to the target site.",
110
+ parameters: Type.Object({
111
+ name: Type.Optional(
112
+ Type.String({ description: "Name of the state file to restore (default: 'default')." }),
113
+ ),
114
+ }),
115
+
116
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
117
+ try {
118
+ const { context: ctx, page: p } = await deps.ensureBrowser();
119
+ const name = deps.sanitizeArtifactName(params.name ?? "default", "default");
120
+
121
+ const { readFile } = await import("node:fs/promises");
122
+ const path = await import("node:path");
123
+ const filePath = path.join(process.cwd(), STATE_DIR, `${name}.json`);
124
+
125
+ let raw: string;
126
+ try {
127
+ raw = await readFile(filePath, "utf-8");
128
+ } catch {
129
+ return {
130
+ content: [{ type: "text", text: `State file not found: ${filePath}` }],
131
+ details: { error: "file_not_found", path: filePath },
132
+ isError: true,
133
+ };
134
+ }
135
+
136
+ const combined = JSON.parse(raw);
137
+ const storageState = combined.storageState;
138
+ const sessionStorageData: Record<string, Record<string, string>> = combined.sessionStorage ?? {};
139
+
140
+ // 1. Restore cookies
141
+ let cookieCount = 0;
142
+ if (storageState?.cookies?.length) {
143
+ await ctx.addCookies(storageState.cookies);
144
+ cookieCount = storageState.cookies.length;
145
+ }
146
+
147
+ // 2. Restore localStorage via page.evaluate
148
+ let localStorageOrigins = 0;
149
+ if (storageState?.origins?.length) {
150
+ for (const origin of storageState.origins) {
151
+ try {
152
+ await p.evaluate((items: Array<{ name: string; value: string }>) => {
153
+ for (const { name, value } of items) {
154
+ localStorage.setItem(name, value);
155
+ }
156
+ }, origin.localStorage ?? []);
157
+ localStorageOrigins++;
158
+ } catch {
159
+ // Origin mismatch — localStorage can only be set on matching origin
160
+ }
161
+ }
162
+ }
163
+
164
+ // 3. Restore sessionStorage via page.evaluate
165
+ let sessionStorageOrigins = 0;
166
+ for (const [_origin, data] of Object.entries(sessionStorageData)) {
167
+ try {
168
+ await p.evaluate((items: Record<string, string>) => {
169
+ for (const [key, value] of Object.entries(items)) {
170
+ sessionStorage.setItem(key, value);
171
+ }
172
+ }, data);
173
+ sessionStorageOrigins++;
174
+ } catch {
175
+ // Origin mismatch
176
+ }
177
+ }
178
+
179
+ return {
180
+ content: [{
181
+ type: "text",
182
+ text: `State restored from: ${filePath}\nCookies: ${cookieCount}\nlocalStorage origins: ${localStorageOrigins}\nsessionStorage origins: ${sessionStorageOrigins}\nSaved at: ${combined.savedAt ?? "unknown"}`,
183
+ }],
184
+ details: {
185
+ path: filePath,
186
+ cookieCount,
187
+ localStorageOrigins,
188
+ sessionStorageOrigins,
189
+ savedAt: combined.savedAt,
190
+ savedUrl: combined.url,
191
+ },
192
+ };
193
+ } catch (err: any) {
194
+ return {
195
+ content: [{ type: "text", text: `Restore state failed: ${err.message}` }],
196
+ details: { error: err.message },
197
+ isError: true,
198
+ };
199
+ }
200
+ },
201
+ });
202
+ }
@@ -0,0 +1,209 @@
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
+ * Visual regression diffing — compare current page screenshot against a stored baseline.
7
+ */
8
+
9
+ const BASELINE_DIR = ".gsd/browser-baselines";
10
+
11
+ export function registerVisualDiffTools(pi: ExtensionAPI, deps: ToolDeps): void {
12
+ pi.registerTool({
13
+ name: "browser_visual_diff",
14
+ label: "Browser Visual Diff",
15
+ description:
16
+ "Compare current page screenshot against a stored baseline pixel-by-pixel. " +
17
+ "Returns similarity score (0–1), diff pixel count, and optionally generates a diff image highlighting changes. " +
18
+ "On first run with no baseline, saves the current screenshot as the baseline. " +
19
+ "Baselines are stored in .gsd/browser-baselines/ (gitignored, environment-specific).",
20
+ parameters: Type.Object({
21
+ name: Type.Optional(
22
+ Type.String({
23
+ description:
24
+ "Baseline name (default: auto-generated from URL + viewport). " +
25
+ "Use consistent names to compare the same view across runs.",
26
+ }),
27
+ ),
28
+ selector: Type.Optional(
29
+ Type.String({
30
+ description: "CSS selector to scope comparison to a specific element instead of full viewport.",
31
+ }),
32
+ ),
33
+ threshold: Type.Optional(
34
+ Type.Number({
35
+ description:
36
+ "Pixel matching threshold 0–1 (default: 0.1). " +
37
+ "Higher values are more tolerant of anti-aliasing and rendering differences.",
38
+ }),
39
+ ),
40
+ updateBaseline: Type.Optional(
41
+ Type.Boolean({
42
+ description: "If true, overwrite the existing baseline with the current screenshot (default: false).",
43
+ }),
44
+ ),
45
+ }),
46
+
47
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
48
+ try {
49
+ const { page: p } = await deps.ensureBrowser();
50
+ const { mkdir, readFile, writeFile } = await import("node:fs/promises");
51
+ const pathMod = await import("node:path");
52
+
53
+ const baselineDir = pathMod.resolve(process.cwd(), BASELINE_DIR);
54
+ await mkdir(baselineDir, { recursive: true });
55
+
56
+ // Ensure .gitignore
57
+ const gitignorePath = pathMod.join(baselineDir, ".gitignore");
58
+ await writeFile(gitignorePath, "*\n!.gitignore\n").catch(() => {});
59
+
60
+ // Generate baseline name
61
+ const url = p.url();
62
+ const viewport = p.viewportSize();
63
+ const vpSuffix = viewport ? `${viewport.width}x${viewport.height}` : "unknown";
64
+ const autoName = deps.sanitizeArtifactName(
65
+ `${new URL(url).pathname.replace(/\//g, "-")}-${vpSuffix}`,
66
+ `baseline-${vpSuffix}`,
67
+ );
68
+ const name = deps.sanitizeArtifactName(params.name ?? autoName, autoName);
69
+
70
+ const baselinePath = pathMod.join(baselineDir, `${name}.png`);
71
+ const diffPath = pathMod.join(baselineDir, `${name}-diff.png`);
72
+
73
+ // Capture current screenshot as PNG (needed for pixel comparison)
74
+ let currentBuffer: Buffer;
75
+ if (params.selector) {
76
+ const locator = p.locator(params.selector).first();
77
+ currentBuffer = await locator.screenshot({ type: "png" });
78
+ } else {
79
+ currentBuffer = await p.screenshot({ type: "png", fullPage: false });
80
+ }
81
+
82
+ // Check if baseline exists
83
+ let baselineBuffer: Buffer | null = null;
84
+ try {
85
+ baselineBuffer = await readFile(baselinePath) as Buffer;
86
+ } catch {
87
+ // No baseline yet
88
+ }
89
+
90
+ if (!baselineBuffer || params.updateBaseline) {
91
+ // Save as new baseline
92
+ await writeFile(baselinePath, currentBuffer);
93
+ return {
94
+ content: [{
95
+ type: "text",
96
+ text: baselineBuffer
97
+ ? `Baseline updated: ${baselinePath}\nSize: ${(currentBuffer.length / 1024).toFixed(1)} KB`
98
+ : `Baseline created (first run): ${baselinePath}\nSize: ${(currentBuffer.length / 1024).toFixed(1)} KB\nRe-run to compare against this baseline.`,
99
+ }],
100
+ details: {
101
+ baselinePath,
102
+ baselineCreated: !baselineBuffer,
103
+ baselineUpdated: !!baselineBuffer,
104
+ sizeBytes: currentBuffer.length,
105
+ },
106
+ };
107
+ }
108
+
109
+ // Perform pixel comparison using sharp for PNG decoding
110
+ const sharp = (await import("sharp")).default;
111
+
112
+ const baselineMeta = await sharp(baselineBuffer).metadata();
113
+ const currentMeta = await sharp(currentBuffer).metadata();
114
+
115
+ const bWidth = baselineMeta.width ?? 0;
116
+ const bHeight = baselineMeta.height ?? 0;
117
+ const cWidth = currentMeta.width ?? 0;
118
+ const cHeight = currentMeta.height ?? 0;
119
+
120
+ // If dimensions differ, report mismatch
121
+ if (bWidth !== cWidth || bHeight !== cHeight) {
122
+ return {
123
+ content: [{
124
+ type: "text",
125
+ text: `Dimension mismatch: baseline is ${bWidth}x${bHeight}, current is ${cWidth}x${cHeight}. Cannot compare.\nUse updateBaseline: true to reset.`,
126
+ }],
127
+ details: {
128
+ match: false,
129
+ dimensionMismatch: true,
130
+ baselineDimensions: { width: bWidth, height: bHeight },
131
+ currentDimensions: { width: cWidth, height: cHeight },
132
+ },
133
+ };
134
+ }
135
+
136
+ // Extract raw RGBA pixel data
137
+ const baselineRaw = await sharp(baselineBuffer).ensureAlpha().raw().toBuffer();
138
+ const currentRaw = await sharp(currentBuffer).ensureAlpha().raw().toBuffer();
139
+
140
+ const width = bWidth;
141
+ const height = bHeight;
142
+ const totalPixels = width * height;
143
+ const threshold = params.threshold ?? 0.1;
144
+
145
+ // Simple pixel-by-pixel comparison (avoiding pixelmatch dependency)
146
+ const diffData = Buffer.alloc(width * height * 4);
147
+ let diffPixels = 0;
148
+ const thresholdSq = threshold * threshold * 255 * 255 * 3;
149
+
150
+ for (let i = 0; i < totalPixels; i++) {
151
+ const offset = i * 4;
152
+ const dr = baselineRaw[offset] - currentRaw[offset];
153
+ const dg = baselineRaw[offset + 1] - currentRaw[offset + 1];
154
+ const db = baselineRaw[offset + 2] - currentRaw[offset + 2];
155
+ const distSq = dr * dr + dg * dg + db * db;
156
+
157
+ if (distSq > thresholdSq) {
158
+ diffPixels++;
159
+ // Mark diff pixels as red
160
+ diffData[offset] = 255; // R
161
+ diffData[offset + 1] = 0; // G
162
+ diffData[offset + 2] = 0; // B
163
+ diffData[offset + 3] = 255; // A
164
+ } else {
165
+ // Dim unchanged pixels
166
+ diffData[offset] = currentRaw[offset] >> 1;
167
+ diffData[offset + 1] = currentRaw[offset + 1] >> 1;
168
+ diffData[offset + 2] = currentRaw[offset + 2] >> 1;
169
+ diffData[offset + 3] = 255;
170
+ }
171
+ }
172
+
173
+ const similarity = 1 - (diffPixels / totalPixels);
174
+ const match = diffPixels === 0;
175
+
176
+ // Save diff image
177
+ await sharp(diffData, { raw: { width, height, channels: 4 } })
178
+ .png()
179
+ .toFile(diffPath);
180
+
181
+ return {
182
+ content: [{
183
+ type: "text",
184
+ text: match
185
+ ? `Visual diff: MATCH (100% similar)\nBaseline: ${baselinePath}`
186
+ : `Visual diff: ${(similarity * 100).toFixed(2)}% similar\nDiff pixels: ${diffPixels} of ${totalPixels} (${((diffPixels / totalPixels) * 100).toFixed(2)}%)\nDiff image: ${diffPath}\nBaseline: ${baselinePath}`,
187
+ }],
188
+ details: {
189
+ match,
190
+ similarity,
191
+ diffPixels,
192
+ totalPixels,
193
+ diffPercentage: (diffPixels / totalPixels) * 100,
194
+ dimensions: { width, height },
195
+ baselinePath,
196
+ diffImagePath: match ? undefined : diffPath,
197
+ threshold,
198
+ },
199
+ };
200
+ } catch (err: any) {
201
+ return {
202
+ content: [{ type: "text", text: `Visual diff failed: ${err.message}` }],
203
+ details: { error: err.message },
204
+ isError: true,
205
+ };
206
+ }
207
+ },
208
+ });
209
+ }
@@ -0,0 +1,104 @@
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
+ * Region zoom / high-res capture — capture and upscale specific page regions.
7
+ */
8
+
9
+ export function registerZoomTools(pi: ExtensionAPI, deps: ToolDeps): void {
10
+ pi.registerTool({
11
+ name: "browser_zoom_region",
12
+ label: "Browser Zoom Region",
13
+ description:
14
+ "Capture and optionally upscale a specific rectangular region of the page for detailed inspection. " +
15
+ "Useful for dense UIs where full-page screenshots have text too small to read. " +
16
+ "Returns the region as an inline image, same as browser_screenshot.",
17
+ parameters: Type.Object({
18
+ x: Type.Number({ description: "Left coordinate of the region in CSS pixels." }),
19
+ y: Type.Number({ description: "Top coordinate of the region in CSS pixels." }),
20
+ width: Type.Number({ description: "Width of the region in CSS pixels." }),
21
+ height: Type.Number({ description: "Height of the region in CSS pixels." }),
22
+ scale: Type.Optional(
23
+ Type.Number({
24
+ description: "Upscale factor (default: 2). Use 1 for native resolution, 2-4 for zoomed detail.",
25
+ }),
26
+ ),
27
+ }),
28
+
29
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
30
+ try {
31
+ const { page: p } = await deps.ensureBrowser();
32
+ const { x, y, width, height } = params;
33
+ const scale = params.scale ?? 2;
34
+
35
+ // Validate dimensions
36
+ if (width <= 0 || height <= 0) {
37
+ return {
38
+ content: [{ type: "text", text: "Width and height must be positive." }],
39
+ details: { error: "invalid_dimensions" },
40
+ isError: true,
41
+ };
42
+ }
43
+
44
+ // Capture the region using Playwright's clip option
45
+ const regionBuffer = await p.screenshot({
46
+ type: "png",
47
+ clip: { x, y, width, height },
48
+ });
49
+
50
+ let outputBuffer: Buffer = regionBuffer;
51
+ let outputMime = "image/png";
52
+
53
+ // Upscale if scale > 1
54
+ if (scale > 1) {
55
+ const sharp = (await import("sharp")).default;
56
+ const targetWidth = Math.round(width * scale);
57
+ const targetHeight = Math.round(height * scale);
58
+
59
+ outputBuffer = await sharp(regionBuffer)
60
+ .resize(targetWidth, targetHeight, {
61
+ kernel: "lanczos3",
62
+ fit: "fill",
63
+ })
64
+ .png()
65
+ .toBuffer();
66
+ }
67
+
68
+ const base64Data = outputBuffer.toString("base64");
69
+ const title = await p.title();
70
+ const url = p.url();
71
+
72
+ return {
73
+ content: [
74
+ {
75
+ type: "text",
76
+ text: `Region capture: ${width}x${height} at (${x},${y})${scale > 1 ? ` upscaled ${scale}x to ${Math.round(width * scale)}x${Math.round(height * scale)}` : ""}\nPage: ${title}\nURL: ${url}`,
77
+ },
78
+ {
79
+ type: "image",
80
+ data: base64Data,
81
+ mimeType: outputMime,
82
+ },
83
+ ],
84
+ details: {
85
+ region: { x, y, width, height },
86
+ scale,
87
+ outputDimensions: {
88
+ width: Math.round(width * scale),
89
+ height: Math.round(height * scale),
90
+ },
91
+ title,
92
+ url,
93
+ },
94
+ };
95
+ } catch (err: any) {
96
+ return {
97
+ content: [{ type: "text", text: `Region zoom failed: ${err.message}` }],
98
+ details: { error: err.message },
99
+ isError: true,
100
+ };
101
+ }
102
+ },
103
+ });
104
+ }
@@ -41,6 +41,8 @@ export interface AutoDashboardData {
41
41
  profileDowngraded?: boolean;
42
42
  /** Number of pending captures awaiting triage (0 if none or file missing) */
43
43
  pendingCaptureCount: number;
44
+ /** Cross-process: another auto-mode session detected via auto.lock (PID, startedAt) */
45
+ remoteSession?: { pid: number; startedAt: string; unitType: string; unitId: string };
44
46
  }
45
47
 
46
48
  // ─── Unit Description Helpers ─────────────────────────────────────────────────
@@ -130,6 +130,16 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
130
130
  if (!absPath) return unitType === "replan-slice";
131
131
  if (!existsSync(absPath)) return false;
132
132
 
133
+ // plan-slice must produce a plan with actual task entries, not just a scaffold.
134
+ // The plan file may exist from a prior discussion/context step with only headings
135
+ // but no tasks. Without this check the artifact is considered "complete" and the
136
+ // unit gets skipped — but deriveState still returns phase:"planning" because the
137
+ // plan has no tasks, creating an infinite skip loop (#699).
138
+ if (unitType === "plan-slice") {
139
+ const planContent = readFileSync(absPath, "utf-8");
140
+ if (!/^- \[[xX ]\] \*\*T\d+:/m.test(planContent)) return false;
141
+ }
142
+
133
143
  // execute-task must also have its checkbox marked [x] in the slice plan
134
144
  if (unitType === "execute-task") {
135
145
  const parts = unitId.split("/");