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
@@ -43,6 +43,71 @@ function getTempFilePath(): string {
43
43
  return join(tmpdir(), `pi-bash-${id}.log`);
44
44
  }
45
45
 
46
+ /**
47
+ * Detect whether a command fragment ends with an unquoted & (background operator).
48
+ * Returns true for patterns like: `cmd &`, `cmd arg &`, `cmd & disown`, `(cmd) &`.
49
+ * Returns false when & appears inside a string literal or as &&.
50
+ */
51
+ function endsWithBackgroundOperator(fragment: string): boolean {
52
+ // Remove content inside single-quoted strings to avoid false positives
53
+ const stripped = fragment.replace(/'[^']*'/g, "''");
54
+ // Match trailing & not preceded by another & (i.e., not &&)
55
+ return /(?<!&)&\s*(?:disown\s*)?(?:#.*)?$/.test(stripped.trim());
56
+ }
57
+
58
+ /**
59
+ * Determine whether a command segment already redirects stdout away from the terminal.
60
+ * Checks for >, >>, &>, |, /dev/null redirects.
61
+ */
62
+ function hasOutputRedirect(segment: string): boolean {
63
+ // Remove single-quoted strings to avoid matching inside them
64
+ const stripped = segment.replace(/'[^']*'/g, "''");
65
+ // Match >, >> not preceded by 2 (stderr-only) — we only care about stdout
66
+ // Also match &> (combined), >&, or a pipe | which routes stdout elsewhere
67
+ return /(?<!\d)(?:>>?|&>|>&|\|)/.test(stripped);
68
+ }
69
+
70
+ /**
71
+ * Rewrite a command that uses & for backgrounding so the background process
72
+ * does not inherit the bash tool's stdout/stderr pipes.
73
+ *
74
+ * Without this, `python -m http.server 8080 &` causes the bash tool to hang
75
+ * indefinitely because Node.js keeps the pipe open until every process that
76
+ * inherited it exits — including the long-running server.
77
+ *
78
+ * The rewrite adds `>/dev/null 2>&1` before each & where stdout is not already
79
+ * redirected, ensuring the background process detaches from the pipes while
80
+ * still producing a human-readable notice in the tool output.
81
+ *
82
+ * Returns { command: string; rewritten: boolean }.
83
+ */
84
+ export function rewriteBackgroundCommand(command: string): { command: string; rewritten: boolean } {
85
+ // Quick pre-check: if there's no & at all, skip the more expensive processing
86
+ if (!command.includes("&")) return { command, rewritten: false };
87
+
88
+ // Split on ; and newlines to handle compound commands.
89
+ // We rewrite each segment independently.
90
+ // Note: this is intentionally simple and covers the common LLM patterns.
91
+ // It does not attempt to parse complex nested subshells.
92
+ const segments = command.split(/(?<=[;\n])/);
93
+ let anyRewritten = false;
94
+
95
+ const rewrittenSegments = segments.map((segment) => {
96
+ if (!endsWithBackgroundOperator(segment)) return segment;
97
+ if (hasOutputRedirect(segment)) return segment;
98
+
99
+ anyRewritten = true;
100
+ // Insert >/dev/null 2>&1 before the trailing & (and optional disown/comment)
101
+ return segment.replace(
102
+ /(?<!&)(&\s*(?:disown\s*)?(?:#.*)?)$/,
103
+ ">/dev/null 2>&1 $1",
104
+ );
105
+ });
106
+
107
+ if (!anyRewritten) return { command, rewritten: false };
108
+ return { command: rewrittenSegments.join(""), rewritten: true };
109
+ }
110
+
46
111
  const bashSchema = Type.Object({
47
112
  command: Type.String({ description: "Bash command to execute" }),
48
113
  timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
@@ -239,8 +304,25 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
239
304
  }
240
305
  }
241
306
 
307
+ // Rewrite background commands (&) to redirect output away from the pipes.
308
+ // Without this, `cmd &` causes the tool to hang because the background
309
+ // process inherits the piped stdout/stderr and keeps them open indefinitely.
310
+ const bgResult = rewriteBackgroundCommand(command);
311
+ const effectiveCommand = bgResult.command;
312
+ if (bgResult.rewritten) {
313
+ // Surface a brief advisory so the LLM knows what happened.
314
+ // The rewrite is transparent for the common case; explicit detachment
315
+ // (nohup, start_new_session) is preferred for robustness.
316
+ onUpdate?.({
317
+ content: [{
318
+ type: "text" as const,
319
+ text: "Note: Background command output redirected to /dev/null to prevent pipe hang. Use nohup or setsid for reliable detachment.",
320
+ }],
321
+ details: undefined,
322
+ });
323
+ }
242
324
  // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
243
- const resolvedCommand = sanitizeCommand(commandPrefix ? `${commandPrefix}\n${command}` : command);
325
+ const resolvedCommand = sanitizeCommand(commandPrefix ? `${commandPrefix}\n${effectiveCommand}` : effectiveCommand);
244
326
  const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
245
327
 
246
328
  return new Promise((resolve, reject) => {
@@ -7,6 +7,7 @@ export {
7
7
  type BashToolOptions,
8
8
  bashTool,
9
9
  createBashTool,
10
+ rewriteBackgroundCommand,
10
11
  } from "./bash.js";
11
12
  export {
12
13
  type BashInterceptorRule,
@@ -235,6 +235,7 @@ export {
235
235
  type BashToolInput,
236
236
  type BashToolOptions,
237
237
  bashTool,
238
+ rewriteBackgroundCommand,
238
239
  checkBashInterception,
239
240
  type CompiledInterceptor,
240
241
  compileInterceptor,
@@ -10,12 +10,19 @@ import {
10
10
  import type { BgProcess, OutputDigest, OutputLine, GetOutputOptions } from "./types.js";
11
11
  import {
12
12
  ERROR_PATTERNS,
13
+ ERROR_PATTERN_UNION,
14
+ WARNING_PATTERN_UNION,
15
+ READINESS_PATTERN_UNION,
16
+ BUILD_COMPLETE_PATTERN_UNION,
17
+ TEST_RESULT_PATTERN_UNION,
13
18
  WARNING_PATTERNS,
14
19
  URL_PATTERN,
15
20
  PORT_PATTERN,
21
+ PORT_PATTERN_SOURCE,
16
22
  READINESS_PATTERNS,
17
23
  BUILD_COMPLETE_PATTERNS,
18
24
  TEST_RESULT_PATTERNS,
25
+ LINE_DEDUP_MAX,
19
26
  } from "./types.js";
20
27
  import { addEvent, pushAlert } from "./process-manager.js";
21
28
  import { transitionToReady } from "./readiness-detector.js";
@@ -24,8 +31,8 @@ import { formatUptime, formatTimeAgo } from "./utilities.js";
24
31
  // ── Output Analysis ────────────────────────────────────────────────────────
25
32
 
26
33
  export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "stderr"): void {
27
- // Error detection
28
- if (ERROR_PATTERNS.some(p => p.test(line))) {
34
+ // Error detection — single union regex instead of .some(p => p.test(line))
35
+ if (ERROR_PATTERN_UNION.test(line)) {
29
36
  bg.recentErrors.push(line.trim().slice(0, 200)); // Cap line length
30
37
  if (bg.recentErrors.length > 50) bg.recentErrors.splice(0, bg.recentErrors.length - 50);
31
38
 
@@ -40,8 +47,8 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
40
47
  }
41
48
  }
42
49
 
43
- // Warning detection
44
- if (WARNING_PATTERNS.some(p => p.test(line))) {
50
+ // Warning detection — single union regex
51
+ if (WARNING_PATTERN_UNION.test(line)) {
45
52
  bg.recentWarnings.push(line.trim().slice(0, 200));
46
53
  if (bg.recentWarnings.length > 50) bg.recentWarnings.splice(0, bg.recentWarnings.length - 50);
47
54
  }
@@ -56,9 +63,10 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
56
63
  }
57
64
  }
58
65
 
59
- // Port extraction
66
+ // Port extraction — PORT_PATTERN has /g flag so must be re-created per call
67
+ // Use PORT_PATTERN_SOURCE (string) to avoid re-parsing the literal each time
68
+ const portRe = new RegExp(PORT_PATTERN_SOURCE, "gi");
60
69
  let portMatch: RegExpExecArray | null;
61
- const portRe = new RegExp(PORT_PATTERN.source, PORT_PATTERN.flags);
62
70
  while ((portMatch = portRe.exec(line)) !== null) {
63
71
  const port = parseInt(portMatch[1], 10);
64
72
  if (port > 0 && port <= 65535 && !bg.ports.includes(port)) {
@@ -71,7 +79,7 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
71
79
  }
72
80
  }
73
81
 
74
- // Readiness detection
82
+ // Readiness detection — single union regex
75
83
  if (bg.status === "starting") {
76
84
  // Check custom ready pattern first
77
85
  if (bg.readyPattern) {
@@ -83,14 +91,14 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
83
91
  }
84
92
 
85
93
  // Check built-in readiness patterns
86
- if (bg.status === "starting" && READINESS_PATTERNS.some(p => p.test(line))) {
94
+ if (bg.status === "starting" && READINESS_PATTERN_UNION.test(line)) {
87
95
  transitionToReady(bg, `Readiness pattern matched: ${line.trim().slice(0, 100)}`);
88
96
  }
89
97
  }
90
98
 
91
99
  // Recovery detection: if we were in error and see a success pattern
92
100
  if (bg.status === "error") {
93
- if (READINESS_PATTERNS.some(p => p.test(line)) || BUILD_COMPLETE_PATTERNS.some(p => p.test(line))) {
101
+ if (READINESS_PATTERN_UNION.test(line) || BUILD_COMPLETE_PATTERN_UNION.test(line)) {
94
102
  bg.status = "ready";
95
103
  bg.recentErrors = [];
96
104
  addEvent(bg, { type: "recovered", detail: "Process recovered from error state" });
@@ -98,10 +106,22 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
98
106
  }
99
107
  }
100
108
 
101
- // Dedup tracking
109
+ // Dedup tracking — evict oldest entry when map exceeds LINE_DEDUP_MAX (LRU via Map insertion order)
102
110
  bg.totalRawLines++;
103
111
  const lineHash = line.trim().slice(0, 100);
104
- bg.lineDedup.set(lineHash, (bg.lineDedup.get(lineHash) || 0) + 1);
112
+ const existing = bg.lineDedup.get(lineHash);
113
+ if (existing !== undefined) {
114
+ // Re-insert to update insertion order (move to tail = most recent)
115
+ bg.lineDedup.delete(lineHash);
116
+ bg.lineDedup.set(lineHash, existing + 1);
117
+ } else {
118
+ if (bg.lineDedup.size >= LINE_DEDUP_MAX) {
119
+ // Evict oldest entry (Map iteration order = insertion order = LRU at head)
120
+ const oldest = bg.lineDedup.keys().next().value;
121
+ if (oldest !== undefined) bg.lineDedup.delete(oldest);
122
+ }
123
+ bg.lineDedup.set(lineHash, 1);
124
+ }
105
125
  }
106
126
 
107
127
  // ── Digest Generation ──────────────────────────────────────────────────────
@@ -154,12 +174,12 @@ export function getHighlights(bg: BgProcess, maxLines: number = 15): string[] {
154
174
  for (let i = 0; i < bg.output.length; i++) {
155
175
  const entry = bg.output[i];
156
176
  let score = 0;
157
- if (ERROR_PATTERNS.some(p => p.test(entry.line))) score += 10;
158
- if (WARNING_PATTERNS.some(p => p.test(entry.line))) score += 5;
177
+ if (ERROR_PATTERN_UNION.test(entry.line)) score += 10;
178
+ if (WARNING_PATTERN_UNION.test(entry.line)) score += 5;
159
179
  if (URL_PATTERN.test(entry.line)) score += 3;
160
- if (READINESS_PATTERNS.some(p => p.test(entry.line))) score += 8;
161
- if (TEST_RESULT_PATTERNS.some(p => p.test(entry.line))) score += 7;
162
- if (BUILD_COMPLETE_PATTERNS.some(p => p.test(entry.line))) score += 6;
180
+ if (READINESS_PATTERN_UNION.test(entry.line)) score += 8;
181
+ if (TEST_RESULT_PATTERN_UNION.test(entry.line)) score += 7;
182
+ if (BUILD_COMPLETE_PATTERN_UNION.test(entry.line)) score += 6;
163
183
  // Boost recent lines so highlights favor fresh output over stale
164
184
  if (i >= bg.output.length - 50) score += 2;
165
185
  if (score > 0) {
@@ -39,6 +39,8 @@ export function setPendingAlerts(alerts: string[]): void {
39
39
 
40
40
  export function addOutputLine(bg: BgProcess, stream: "stdout" | "stderr", line: string): void {
41
41
  bg.output.push({ stream, line, ts: Date.now() });
42
+ if (stream === "stdout") bg.stdoutLineCount++;
43
+ else bg.stderrLineCount++;
42
44
  if (bg.output.length > MAX_BUFFER_LINES) {
43
45
  const excess = bg.output.length - MAX_BUFFER_LINES;
44
46
  bg.output.splice(0, excess);
@@ -60,8 +62,6 @@ export function pushAlert(bg: BgProcess, message: string): void {
60
62
  }
61
63
 
62
64
  export function getInfo(p: BgProcess): BgProcessInfo {
63
- const stdoutLines = p.output.filter(l => l.stream === "stdout").length;
64
- const stderrLines = p.output.filter(l => l.stream === "stderr").length;
65
65
  return {
66
66
  id: p.id,
67
67
  label: p.label,
@@ -72,8 +72,8 @@ export function getInfo(p: BgProcess): BgProcessInfo {
72
72
  exitCode: p.exitCode,
73
73
  signal: p.signal,
74
74
  outputLines: p.output.length,
75
- stdoutLines,
76
- stderrLines,
75
+ stdoutLines: p.stdoutLineCount,
76
+ stderrLines: p.stderrLineCount,
77
77
  status: p.status,
78
78
  processType: p.processType,
79
79
  ports: p.ports,
@@ -161,6 +161,8 @@ export function startProcess(opts: StartOptions): BgProcess {
161
161
  commandHistory: [],
162
162
  lineDedup: new Map(),
163
163
  totalRawLines: 0,
164
+ stdoutLineCount: 0,
165
+ stderrLineCount: 0,
164
166
  envKeys: Object.keys(opts.env || {}),
165
167
  restartCount: 0,
166
168
  startConfig: {
@@ -90,10 +90,14 @@ export interface BgProcess {
90
90
  lastWarningCount: number;
91
91
  /** Command history for shell-type sessions */
92
92
  commandHistory: string[];
93
- /** Dedup tracker: hash → count of repeated lines */
93
+ /** Dedup tracker: hash → count of repeated lines (capped at LINE_DEDUP_MAX entries) */
94
94
  lineDedup: Map<string, number>;
95
95
  /** Total raw lines (before dedup) for token savings calc */
96
96
  totalRawLines: number;
97
+ /** Tracked stdout line count (incremented in addOutputLine, avoids O(n) filter) */
98
+ stdoutLineCount: number;
99
+ /** Tracked stderr line count (incremented in addOutputLine, avoids O(n) filter) */
100
+ stderrLineCount: number;
97
101
  /** Env snapshot (keys only, no values for security) */
98
102
  envKeys: string[];
99
103
  /** Restart count */
@@ -163,6 +167,8 @@ export interface ProcessManifest {
163
167
  export const MAX_BUFFER_LINES = 5000;
164
168
  export const MAX_EVENTS = 200;
165
169
  export const DEAD_PROCESS_TTL = 10 * 60 * 1000;
170
+ /** Maximum unique entries in the per-process lineDedup Map before LRU eviction. */
171
+ export const LINE_DEDUP_MAX = 500;
166
172
  export const PORT_PROBE_TIMEOUT = 500;
167
173
  export const READY_POLL_INTERVAL = 250;
168
174
  export const DEFAULT_READY_TIMEOUT = 30000;
@@ -249,3 +255,29 @@ export const BUILD_COMPLETE_PATTERNS: RegExp[] = [
249
255
  /webpack\s+\d+\.\d+/i,
250
256
  /bundle\s+(?:is\s+)?ready/i,
251
257
  ];
258
+
259
+ // ── Compiled union regexes (single-pass alternatives to .some(p => p.test(line))) ──
260
+ // Built once at module load — eliminates per-line RegExp construction overhead.
261
+
262
+ export const ERROR_PATTERN_UNION = new RegExp(
263
+ ERROR_PATTERNS.map(p => p.source).join("|"),
264
+ "i",
265
+ );
266
+ export const WARNING_PATTERN_UNION = new RegExp(
267
+ WARNING_PATTERNS.map(p => p.source).join("|"),
268
+ "i",
269
+ );
270
+ export const READINESS_PATTERN_UNION = new RegExp(
271
+ READINESS_PATTERNS.map(p => p.source).join("|"),
272
+ "i",
273
+ );
274
+ export const BUILD_COMPLETE_PATTERN_UNION = new RegExp(
275
+ BUILD_COMPLETE_PATTERNS.map(p => p.source).join("|"),
276
+ "i",
277
+ );
278
+ export const TEST_RESULT_PATTERN_UNION = new RegExp(
279
+ TEST_RESULT_PATTERNS.map(p => p.source).join("|"),
280
+ "i",
281
+ );
282
+ /** PORT_PATTERN compiled once for reuse in analyzeLine (needs exec, so must be re-created per call with /g) */
283
+ export const PORT_PATTERN_SOURCE = PORT_PATTERN.source;
@@ -10,9 +10,11 @@ import sharp from "sharp";
10
10
  import type { CompactPageState, CompactSelectorState } from "./state.js";
11
11
  import { formatCompactStateSummary } from "./utils.js";
12
12
 
13
- // Anthropic API rejects images > 2000px in multi-image requests.
14
- // Cap at 1568px (recommended optimal size) to stay well within limits.
15
- const MAX_SCREENSHOT_DIM = 1568;
13
+ // Anthropic vision: 1568px is the recommended optimal width. Height is capped
14
+ // generously at 8000px so tall full-page screenshots remain readable rather
15
+ // than being squished into a square constraint.
16
+ const MAX_SCREENSHOT_WIDTH = 1568;
17
+ const MAX_SCREENSHOT_HEIGHT = 8000;
16
18
 
17
19
  // ---------------------------------------------------------------------------
18
20
  // Compact page state capture
@@ -120,9 +122,10 @@ export async function postActionSummary(p: Page, target?: Page | Frame): Promise
120
122
  // ---------------------------------------------------------------------------
121
123
 
122
124
  /**
123
- * If either dimension of the image buffer exceeds MAX_SCREENSHOT_DIM,
124
- * downscale proportionally using sharp. Returns the original buffer
125
- * unchanged if already within limits.
125
+ * Constrain screenshot dimensions for the Anthropic vision API.
126
+ * Width is capped at 1568px (optimal) and height at 8000px, each
127
+ * independently, using `fit: "inside"` so aspect ratio is preserved.
128
+ * Small images are never upscaled.
126
129
  *
127
130
  * `page` parameter is retained for ToolDeps signature stability (D008)
128
131
  * but is no longer used — all processing is server-side via sharp.
@@ -133,18 +136,17 @@ export async function constrainScreenshot(
133
136
  mimeType: string,
134
137
  quality: number,
135
138
  ): Promise<Buffer> {
136
- const { width, height } = await sharp(buffer).metadata();
139
+ const meta = await sharp(buffer).metadata();
140
+ const width = meta.width;
141
+ const height = meta.height;
137
142
 
138
- if (
139
- width !== undefined &&
140
- height !== undefined &&
141
- width <= MAX_SCREENSHOT_DIM &&
142
- height <= MAX_SCREENSHOT_DIM
143
- ) {
144
- return buffer;
145
- }
143
+ if (width === undefined || height === undefined) return buffer;
144
+ if (width <= MAX_SCREENSHOT_WIDTH && height <= MAX_SCREENSHOT_HEIGHT) return buffer;
146
145
 
147
- const resizer = sharp(buffer).resize(MAX_SCREENSHOT_DIM, MAX_SCREENSHOT_DIM, { fit: "inside" });
146
+ const resizer = sharp(buffer).resize(MAX_SCREENSHOT_WIDTH, MAX_SCREENSHOT_HEIGHT, {
147
+ fit: "inside",
148
+ withoutEnlargement: true,
149
+ });
148
150
 
149
151
  if (mimeType === "image/png") {
150
152
  return Buffer.from(await resizer.png().toBuffer());
@@ -17,6 +17,16 @@ import { registerWaitTools } from "./tools/wait.js";
17
17
  import { registerPageTools } from "./tools/pages.js";
18
18
  import { registerFormTools } from "./tools/forms.js";
19
19
  import { registerIntentTools } from "./tools/intent.js";
20
+ import { registerPdfTools } from "./tools/pdf.js";
21
+ import { registerStatePersistenceTools } from "./tools/state-persistence.js";
22
+ import { registerNetworkMockTools } from "./tools/network-mock.js";
23
+ import { registerDeviceTools } from "./tools/device.js";
24
+ import { registerExtractTools } from "./tools/extract.js";
25
+ import { registerVisualDiffTools } from "./tools/visual-diff.js";
26
+ import { registerZoomTools } from "./tools/zoom.js";
27
+ import { registerCodegenTools } from "./tools/codegen.js";
28
+ import { registerActionCacheTools } from "./tools/action-cache.js";
29
+ import { registerInjectionDetectionTools } from "./tools/injection-detect.js";
20
30
 
21
31
  export default function (pi: ExtensionAPI) {
22
32
  pi.on("session_shutdown", async () => { await closeBrowser(); });
@@ -48,4 +58,14 @@ export default function (pi: ExtensionAPI) {
48
58
  registerPageTools(pi, deps);
49
59
  registerFormTools(pi, deps);
50
60
  registerIntentTools(pi, deps);
61
+ registerPdfTools(pi, deps);
62
+ registerStatePersistenceTools(pi, deps);
63
+ registerNetworkMockTools(pi, deps);
64
+ registerDeviceTools(pi, deps);
65
+ registerExtractTools(pi, deps);
66
+ registerVisualDiffTools(pi, deps);
67
+ registerZoomTools(pi, deps);
68
+ registerCodegenTools(pi, deps);
69
+ registerActionCacheTools(pi, deps);
70
+ registerInjectionDetectionTools(pi, deps);
51
71
  }
@@ -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
+ }