@zhijiewang/openharness 2.30.0 → 2.31.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.
@@ -19,6 +19,15 @@ export class SessionTracer {
19
19
  activeSpans = new Map();
20
20
  spanCounter = 0;
21
21
  otlp;
22
+ /**
23
+ * Pending spans that have ended but not yet been POSTed to OTLP. Drained
24
+ * by a microtask-debounced flush (one POST per microtask boundary even if
25
+ * many spans end in the same tick) and by the public `flush()` method.
26
+ */
27
+ otlpBuffer = [];
28
+ otlpFlushScheduled = false;
29
+ /** In-flight fetches so `flush()` can await any POSTs already on the wire. */
30
+ otlpInFlight = new Set();
22
31
  constructor(sessionId, otlp) {
23
32
  this.sessionId = sessionId;
24
33
  this.otlp = otlp;
@@ -56,19 +65,60 @@ export class SessionTracer {
56
65
  this.shipSpanOTLP(span);
57
66
  return span;
58
67
  }
59
- /** Fire-and-forget POST of a single span to the configured OTLP HTTP endpoint. Errors swallowed — telemetry must never crash the agent. */
68
+ /**
69
+ * Buffer the span for OTLP shipping. The actual POST is deferred to a
70
+ * microtask so multiple spans ending in the same tick coalesce into a
71
+ * single batch POST instead of one fetch each. Errors are swallowed —
72
+ * telemetry must never crash the agent.
73
+ */
60
74
  shipSpanOTLP(span) {
61
75
  if (!this.otlp)
62
76
  return;
63
- const payload = exportTraceOTLP(this.sessionId, [span]);
64
- fetch(this.otlp.endpoint, {
77
+ this.otlpBuffer.push(span);
78
+ if (this.otlpFlushScheduled)
79
+ return;
80
+ this.otlpFlushScheduled = true;
81
+ queueMicrotask(() => {
82
+ this.otlpFlushScheduled = false;
83
+ this.drainOTLPBuffer();
84
+ });
85
+ }
86
+ /** Send whatever is in `otlpBuffer` as a single fire-and-forget POST. The
87
+ * returned promise is tracked in `otlpInFlight` so `flush()` can await it. */
88
+ drainOTLPBuffer() {
89
+ if (!this.otlp || this.otlpBuffer.length === 0)
90
+ return;
91
+ const batch = this.otlpBuffer;
92
+ this.otlpBuffer = [];
93
+ const payload = exportTraceOTLP(this.sessionId, batch);
94
+ const p = fetch(this.otlp.endpoint, {
65
95
  method: "POST",
66
96
  headers: { "Content-Type": "application/json", ...(this.otlp.headers ?? {}) },
67
97
  body: JSON.stringify(payload),
68
- }).catch(() => {
69
- /* swallow — telemetry must not interfere with the agent */
98
+ }).then(() => undefined, () => undefined);
99
+ this.otlpInFlight.add(p);
100
+ p.finally(() => {
101
+ this.otlpInFlight.delete(p);
70
102
  });
71
103
  }
104
+ /**
105
+ * Drain any pending OTLP buffer and await every in-flight POST. Call this at
106
+ * session end so spans aren't dropped on `process.exit`. No-op when OTLP is
107
+ * not configured. Errors are swallowed (already, by `drainOTLPBuffer`).
108
+ */
109
+ async flush() {
110
+ if (!this.otlp)
111
+ return;
112
+ // Drain any not-yet-shipped buffer first; cancel pending microtask flush
113
+ // (the buffer becomes empty so the microtask would no-op anyway, but
114
+ // clearing the flag is explicit).
115
+ this.otlpFlushScheduled = false;
116
+ this.drainOTLPBuffer();
117
+ // Wait for every fetch we've kicked off (microtask-shipped or just now).
118
+ if (this.otlpInFlight.size > 0) {
119
+ await Promise.allSettled(Array.from(this.otlpInFlight));
120
+ }
121
+ }
72
122
  /** Get all completed spans */
73
123
  getSpans() {
74
124
  return [...this.spans];
@@ -170,8 +220,22 @@ export function formatTrace(spans) {
170
220
  lines.push(`Total: ${spans.length} spans, ${totalMs}ms, ${errors} errors`);
171
221
  return lines.join("\n");
172
222
  }
223
+ /**
224
+ * Coerce an arbitrary string (UUID with hyphens, "span-N", etc.) into a fixed-length
225
+ * lowercase hex string suitable for OTLP. OTLP collectors (Jaeger, Tempo, OTel
226
+ * Collector) validate that traceId is 32 hex chars and spanId is 16 hex chars and
227
+ * reject anything containing `-` or non-hex letters. We strip non-hex chars, then
228
+ * pad-left with zeros (or truncate from the left) to the target length.
229
+ */
230
+ function toHexId(input, length) {
231
+ const hex = input.toLowerCase().replace(/[^0-9a-f]/g, "");
232
+ if (hex.length === 0)
233
+ return "0".repeat(length);
234
+ return hex.length >= length ? hex.slice(0, length) : hex.padStart(length, "0");
235
+ }
173
236
  /** Export trace in OpenTelemetry-compatible format */
174
237
  export function exportTraceOTLP(sessionId, spans) {
238
+ const traceId = toHexId(sessionId, 32);
175
239
  return {
176
240
  resourceSpans: [
177
241
  {
@@ -185,9 +249,9 @@ export function exportTraceOTLP(sessionId, spans) {
185
249
  {
186
250
  scope: { name: "openharness.agent" },
187
251
  spans: spans.map((s) => ({
188
- traceId: sessionId.padEnd(32, "0").slice(0, 32),
189
- spanId: s.spanId.padEnd(16, "0").slice(0, 16),
190
- parentSpanId: s.parentSpanId?.padEnd(16, "0").slice(0, 16),
252
+ traceId,
253
+ spanId: toHexId(s.spanId, 16),
254
+ parentSpanId: s.parentSpanId ? toHexId(s.parentSpanId, 16) : undefined,
191
255
  name: s.name,
192
256
  startTimeUnixNano: s.startTime * 1_000_000,
193
257
  endTimeUnixNano: s.endTime * 1_000_000,
package/dist/main.js CHANGED
@@ -1111,6 +1111,62 @@ program
1111
1111
  .action(async () => {
1112
1112
  await runInitWizard({ exitOnDone: true });
1113
1113
  });
1114
+ // ── project — per-project state management ──
1115
+ //
1116
+ // `oh project purge [path]` — delete all openHarness state for a project
1117
+ //
1118
+ // Mirrors Claude Code's `claude project purge`. Removes the entire `.oh/`
1119
+ // directory at the target path plus the workspace-trust entry (if any).
1120
+ // Sessions, credentials, plugins, telemetry, traces, and global config are
1121
+ // NOT touched — they're global-and-cross-project. Default UX prints the
1122
+ // deletion plan and asks for confirmation; --dry-run previews; --yes skips
1123
+ // the prompt. `--all` is deferred (openHarness has no project registry, so
1124
+ // "all projects" isn't well-defined without a session-cwd scan).
1125
+ const projectCmd = program.command("project").description("Manage per-project openHarness state");
1126
+ projectCmd
1127
+ .command("purge [path]")
1128
+ .description("Delete all openHarness state for a project (config, rules, memory, skills, agents, plans, checkpoints, trust entry). Sessions, credentials, plugins, telemetry, and global config are NOT touched. Defaults to the current directory.")
1129
+ .option("--dry-run", "Preview what would be deleted without touching the filesystem")
1130
+ .option("-y, --yes", "Skip the confirmation prompt")
1131
+ .action(async (pathArg, opts) => {
1132
+ const { planPurge, formatPurgePlan, executePurge } = await import("./harness/project-purge.js");
1133
+ const target = pathArg ?? process.cwd();
1134
+ if (!existsSync(target)) {
1135
+ process.stderr.write(`Error: path does not exist: ${target}\n`);
1136
+ process.exit(1);
1137
+ }
1138
+ const plan = planPurge(target);
1139
+ console.log(formatPurgePlan(plan));
1140
+ if (plan.entries.length === 0) {
1141
+ return;
1142
+ }
1143
+ if (opts.dryRun) {
1144
+ console.log("\n(dry-run — no files were deleted)");
1145
+ return;
1146
+ }
1147
+ if (!opts.yes) {
1148
+ const readline = await import("node:readline/promises");
1149
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1150
+ try {
1151
+ const answer = (await rl.question("\nProceed with deletion? [y/N] ")).trim();
1152
+ if (!/^y(es)?$/i.test(answer)) {
1153
+ console.log("Aborted.");
1154
+ return;
1155
+ }
1156
+ }
1157
+ finally {
1158
+ rl.close();
1159
+ }
1160
+ }
1161
+ const result = executePurge(plan);
1162
+ console.log(`\nDeleted ${result.deleted} of ${plan.entries.length} target(s).`);
1163
+ if (result.errors.length > 0) {
1164
+ console.log(`${result.errors.length} error(s):`);
1165
+ for (const err of result.errors)
1166
+ console.log(` ⚠ ${err}`);
1167
+ process.exit(1);
1168
+ }
1169
+ });
1114
1170
  // ── auth (audit B6) — provider-agnostic credential management ──
1115
1171
  //
1116
1172
  // `oh auth login [provider] --key <value>` — set API key for a provider
@@ -134,6 +134,10 @@ export class AnthropicProvider {
134
134
  let currentToolId = "";
135
135
  let currentToolName = "";
136
136
  let currentToolArgs = "";
137
+ // Persist across chunk boundaries: a TCP/TLS framing boundary can land
138
+ // between the SSE `event:` and `data:` lines, leaving the event type
139
+ // staged for the next chunk's first `data:` line.
140
+ let currentEvent = "";
137
141
  while (true) {
138
142
  const { done, value } = await reader.read();
139
143
  if (done)
@@ -141,7 +145,6 @@ export class AnthropicProvider {
141
145
  buffer += decoder.decode(value, { stream: true });
142
146
  const lines = buffer.split("\n");
143
147
  buffer = lines.pop() ?? "";
144
- let currentEvent = "";
145
148
  for (const line of lines) {
146
149
  const trimmed = line.trim();
147
150
  if (trimmed.startsWith("event:")) {
package/dist/repl.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Imperative REPL — extracted business logic from React REPL.tsx.
3
3
  * Uses TerminalRenderer for display instead of Ink.
4
4
  */
5
+ import { readdirSync, statSync } from "node:fs";
5
6
  import { homedir } from "node:os";
6
7
  import { getCommandEntries } from "./commands/index.js";
7
8
  import { roll } from "./cybergotchi/bones.js";
@@ -185,7 +186,6 @@ export async function startREPL(config) {
185
186
  const dir = lastSep >= 0 ? expanded.slice(0, lastSep + 1) : ".";
186
187
  const prefix = lastSep >= 0 ? expanded.slice(lastSep + 1) : expanded;
187
188
  try {
188
- const { readdirSync, statSync } = require("node:fs");
189
189
  const entries = readdirSync(dir)
190
190
  .filter((name) => name.toLowerCase().startsWith(prefix.toLowerCase()))
191
191
  .slice(0, 10);
@@ -161,6 +161,13 @@ export class AgentDispatcher {
161
161
  if (filtered.length > 0)
162
162
  taskTools = filtered;
163
163
  }
164
+ // Plumb cwd through config.workingDir so parallel runTask calls don't
165
+ // race on the global process.cwd(). The query loop seeds ToolContext
166
+ // with this value; built-in tools (FileRead, Glob, Bash, …) honor it.
167
+ // Previously this method called `process.chdir(worktreePath)` and a
168
+ // matching `process.chdir(originalCwd)` in `finally` — but since
169
+ // `process.cwd()` is process-wide, two concurrent tasks would clobber
170
+ // each other's directory mid-execution.
164
171
  const config = {
165
172
  provider: this.provider,
166
173
  tools: taskTools,
@@ -169,6 +176,7 @@ export class AgentDispatcher {
169
176
  model: this.model,
170
177
  maxTurns: 20,
171
178
  abortSignal: this.abortSignal,
179
+ workingDir: worktreePath ?? cwd,
172
180
  };
173
181
  // Inject blocker results as context
174
182
  let promptWithContext = task.prompt;
@@ -184,37 +192,16 @@ export class AgentDispatcher {
184
192
  promptWithContext = `${blockerContext}\n\n---\n\n${task.prompt}`;
185
193
  }
186
194
  }
187
- const originalCwd = process.cwd();
188
- if (worktreePath) {
189
- try {
190
- process.chdir(worktreePath);
191
- }
192
- catch {
193
- /* ignore */
194
- }
195
- }
196
195
  let output = "";
197
196
  let errorMessage = null;
198
- try {
199
- for await (const event of query(promptWithContext, config)) {
200
- if (event.type === "text_delta")
201
- output += event.content;
202
- if (event.type === "error") {
203
- errorMessage = event.message;
204
- break;
205
- }
206
- forwardChildEvent(event, taskCallId, this.emitChildEvent);
207
- }
208
- }
209
- finally {
210
- if (worktreePath) {
211
- try {
212
- process.chdir(originalCwd);
213
- }
214
- catch {
215
- /* ignore */
216
- }
197
+ for await (const event of query(promptWithContext, config)) {
198
+ if (event.type === "text_delta")
199
+ output += event.content;
200
+ if (event.type === "error") {
201
+ errorMessage = event.message;
202
+ break;
217
203
  }
204
+ forwardChildEvent(event, taskCallId, this.emitChildEvent);
218
205
  }
219
206
  if (errorMessage !== null) {
220
207
  result = { id: task.id, output: `Error: ${errorMessage}`, isError: true, durationMs: Date.now() - start };
@@ -2,6 +2,8 @@
2
2
  * Tool execution during LLM streaming — concurrent tool execution
3
3
  * with permission checks and queue management.
4
4
  */
5
+ import { getAffectedFiles } from "../harness/checkpoints.js";
6
+ import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
5
7
  import { findToolByName } from "../Tool.js";
6
8
  import { checkPermission } from "../types/permissions.js";
7
9
  const MAX_CONCURRENCY = 10;
@@ -54,23 +56,69 @@ export class StreamingToolExecutor {
54
56
  tracked.status = "completed";
55
57
  return;
56
58
  }
59
+ const argsPreview = JSON.stringify(tracked.toolCall.arguments).slice(0, 1000);
57
60
  // Permission check
58
61
  const perm = checkPermission(this.permissionMode, tool.riskLevel, tool.isReadOnly(tracked.toolCall.arguments), tool.name, tracked.toolCall.arguments);
59
- if (!perm.allowed && perm.reason === "needs-approval" && this.askUser) {
60
- const { formatToolArgs } = await import("../utils/tool-summary.js");
61
- const description = formatToolArgs(tool.name, tracked.toolCall.arguments);
62
- const allowed = await this.askUser(tool.name, description, tool.riskLevel);
63
- if (!allowed) {
64
- tracked.result = { output: "Permission denied.", isError: true };
62
+ if (!perm.allowed) {
63
+ if (perm.reason === "needs-approval") {
64
+ // Hook: permissionRequest — give configured hooks first say. If they
65
+ // explicitly allow/deny, that wins; otherwise fall through to the
66
+ // interactive prompt or to a fail-closed deny in headless mode.
67
+ const hookOutcome = await emitHookWithOutcome("permissionRequest", {
68
+ toolName: tool.name,
69
+ toolArgs: argsPreview,
70
+ toolInputJson: JSON.stringify(tracked.toolCall.arguments).slice(0, 1000),
71
+ permissionMode: this.permissionMode,
72
+ permissionAction: "ask",
73
+ });
74
+ const denyAndEmit = (source, reason, output) => {
75
+ emitHook("permissionDenied", {
76
+ toolName: tool.name,
77
+ toolArgs: argsPreview,
78
+ permissionMode: this.permissionMode,
79
+ denySource: source,
80
+ denyReason: reason,
81
+ });
82
+ tracked.result = { output, isError: true };
83
+ tracked.status = "completed";
84
+ };
85
+ if (hookOutcome.permissionDecision === "allow") {
86
+ // Hook granted — proceed.
87
+ }
88
+ else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
89
+ const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
90
+ denyAndEmit("hook", hookOutcome.reason ?? "hook denied", `Permission denied by hook${reason}`);
91
+ return;
92
+ }
93
+ else if (this.askUser) {
94
+ const { formatToolArgs } = await import("../utils/tool-summary.js");
95
+ const description = formatToolArgs(tool.name, tracked.toolCall.arguments);
96
+ const allowed = await this.askUser(tool.name, description, tool.riskLevel);
97
+ if (!allowed) {
98
+ denyAndEmit("user", "user declined", "Permission denied by user.");
99
+ return;
100
+ }
101
+ }
102
+ else {
103
+ // Headless mode with no hook decision and no interactive prompt.
104
+ denyAndEmit("headless", "no hook decision and no interactive prompt available", "Permission denied: needs-approval (no interactive prompt available; configure a permissionRequest hook to gate this tool)");
105
+ return;
106
+ }
107
+ }
108
+ else {
109
+ // Auto-mode policy block (deny / acceptEdits / etc) — symmetric event.
110
+ emitHook("permissionDenied", {
111
+ toolName: tool.name,
112
+ toolArgs: argsPreview,
113
+ permissionMode: this.permissionMode,
114
+ denySource: "policy",
115
+ denyReason: perm.reason,
116
+ });
117
+ tracked.result = { output: `Denied: ${perm.reason}`, isError: true };
65
118
  tracked.status = "completed";
66
119
  return;
67
120
  }
68
121
  }
69
- else if (!perm.allowed) {
70
- tracked.result = { output: `Denied: ${perm.reason}`, isError: true };
71
- tracked.status = "completed";
72
- return;
73
- }
74
122
  // Validate input
75
123
  const parsed = tool.inputSchema.safeParse(tracked.toolCall.arguments);
76
124
  if (!parsed.success) {
@@ -84,6 +132,17 @@ export class StreamingToolExecutor {
84
132
  tracked.status = "completed";
85
133
  return;
86
134
  }
135
+ // Hook: preToolUse — last gate before execution. A hook that returns
136
+ // false (exit code 1 / { allowed: false }) blocks the call.
137
+ const preAllowed = emitHook("preToolUse", {
138
+ toolName: tool.name,
139
+ toolArgs: argsPreview,
140
+ });
141
+ if (!preAllowed) {
142
+ tracked.result = { output: "Blocked by preToolUse hook.", isError: true };
143
+ tracked.status = "completed";
144
+ return;
145
+ }
87
146
  // Execute with per-call context (streaming output chunks + abort signal)
88
147
  const callId = tracked.toolCall.id;
89
148
  const callContext = {
@@ -138,6 +197,33 @@ export class StreamingToolExecutor {
138
197
  if (toolSpanId)
139
198
  callContext.tracer?.endSpan(toolSpanId, "error", { error: tracked.result.output });
140
199
  }
200
+ // Hook: postToolUse / postToolUseFailure (mutually exclusive — strict CC parity)
201
+ if (tracked.result) {
202
+ const outputPreview = tracked.result.output.slice(0, 1000);
203
+ if (tracked.result.isError) {
204
+ emitHook("postToolUseFailure", {
205
+ toolName: tool.name,
206
+ toolArgs: argsPreview,
207
+ toolOutput: outputPreview,
208
+ toolError: "ReportedError",
209
+ errorMessage: outputPreview,
210
+ });
211
+ }
212
+ else {
213
+ emitHook("postToolUse", {
214
+ toolName: tool.name,
215
+ toolArgs: argsPreview,
216
+ toolOutput: outputPreview,
217
+ });
218
+ // Emit fileChanged hook for file-modifying tools
219
+ if (["Edit", "Write", "MultiEdit"].includes(tool.name)) {
220
+ const filePaths = getAffectedFiles(tool.name, parsed.data);
221
+ for (const fp of filePaths) {
222
+ emitHook("fileChanged", { filePath: fp, toolName: tool.name });
223
+ }
224
+ }
225
+ }
226
+ }
141
227
  tracked.status = "completed";
142
228
  this.processQueue(); // Process next queued tools
143
229
  }
@@ -6,13 +6,13 @@ declare const createSchema: z.ZodObject<{
6
6
  schedule: z.ZodString;
7
7
  prompt: z.ZodString;
8
8
  }, "strip", z.ZodTypeAny, {
9
- action: "create";
10
9
  name: string;
10
+ action: "create";
11
11
  prompt: string;
12
12
  schedule: string;
13
13
  }, {
14
- action: "create";
15
14
  name: string;
15
+ action: "create";
16
16
  prompt: string;
17
17
  schedule: string;
18
18
  }>;
@@ -6,8 +6,8 @@ declare const inputSchema: z.ZodObject<{
6
6
  line: z.ZodOptional<z.ZodNumber>;
7
7
  character: z.ZodOptional<z.ZodNumber>;
8
8
  }, "strip", z.ZodTypeAny, {
9
- action: "diagnostics" | "definition" | "references" | "hover";
10
9
  file_path: string;
10
+ action: "diagnostics" | "definition" | "references" | "hover";
11
11
  line?: number | undefined;
12
12
  character?: number | undefined;
13
13
  }, {
@@ -59,10 +59,9 @@ export const FileReadTool = {
59
59
  return { output: `Error: ${filePath} is a directory, not a file.`, isError: true };
60
60
  }
61
61
  const ext = path.extname(filePath).toLowerCase();
62
- // Image files: return as base64
62
+ // Image files: return as base64 (auto-downscaled if oversized)
63
63
  if (IMAGE_EXTENSIONS.has(ext)) {
64
- const buffer = await fs.readFile(filePath);
65
- const base64 = buffer.toString("base64");
64
+ const raw = await fs.readFile(filePath);
66
65
  const mimeTypes = {
67
66
  ".png": "image/png",
68
67
  ".jpg": "image/jpeg",
@@ -72,7 +71,11 @@ export const FileReadTool = {
72
71
  ".bmp": "image/bmp",
73
72
  ".svg": "image/svg+xml",
74
73
  };
75
- return { output: `__IMAGE__:${mimeTypes[ext] ?? "image/png"}:${base64}`, isError: false };
74
+ const mediaType = mimeTypes[ext] ?? "image/png";
75
+ const { downscaleIfLarge } = await import("../../utils/image-downscale.js");
76
+ const { buffer } = await downscaleIfLarge(raw, mediaType);
77
+ const base64 = buffer.toString("base64");
78
+ return { output: `__IMAGE__:${mediaType}:${base64}`, isError: false };
76
79
  }
77
80
  // PDF files: extract text per page (basic extraction)
78
81
  if (ext === ".pdf") {
@@ -17,9 +17,9 @@ declare const inputSchema: z.ZodObject<{
17
17
  "-n": z.ZodOptional<z.ZodBoolean>;
18
18
  }, "strip", z.ZodTypeAny, {
19
19
  pattern: string;
20
+ path?: string | undefined;
20
21
  type?: string | undefined;
21
22
  "-i"?: boolean | undefined;
22
- path?: string | undefined;
23
23
  context?: number | undefined;
24
24
  glob?: string | undefined;
25
25
  offset?: number | undefined;
@@ -32,9 +32,9 @@ declare const inputSchema: z.ZodObject<{
32
32
  "-n"?: boolean | undefined;
33
33
  }, {
34
34
  pattern: string;
35
+ path?: string | undefined;
35
36
  type?: string | undefined;
36
37
  "-i"?: boolean | undefined;
37
- path?: string | undefined;
38
38
  context?: number | undefined;
39
39
  glob?: string | undefined;
40
40
  offset?: number | undefined;
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { z } from "zod";
4
+ import { downscaleIfLarge } from "../../utils/image-downscale.js";
4
5
  const SUPPORTED_TYPES = {
5
6
  ".png": "image/png",
6
7
  ".jpg": "image/jpeg",
@@ -37,7 +38,11 @@ export const ImageReadTool = {
37
38
  };
38
39
  }
39
40
  try {
40
- const buffer = await fs.readFile(filePath);
41
+ const raw = await fs.readFile(filePath);
42
+ // Auto-downscale to ≤2000px on the longest dimension. PDFs and
43
+ // missing-sharp installs pass through unchanged. Aspect + format
44
+ // preserved by sharp.
45
+ const { buffer } = await downscaleIfLarge(raw, mediaType);
41
46
  const base64 = buffer.toString("base64");
42
47
  return {
43
48
  output: `${IMAGE_PREFIX}:${mediaType}:${base64}`,
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import { z } from "zod";
3
3
  const inputSchema = z.object({
4
4
  command: z.string().describe("PowerShell command to execute"),
@@ -21,7 +21,16 @@ export const PowerShellTool = {
21
21
  }
22
22
  const timeout = input.timeout ?? 120_000;
23
23
  try {
24
- const output = execSync(`powershell.exe -NoProfile -NonInteractive -Command "${input.command.replace(/"/g, '\\"')}"`, { encoding: "utf-8", timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true });
24
+ // execFileSync(file, args[]) spawns powershell.exe directly without a
25
+ // cmd.exe wrapper, so cmd.exe metachars (& | < > ^ %VAR%) are inert.
26
+ // The user's command is passed as a single -Command arg; PowerShell
27
+ // parses it as PowerShell, not as a doubly-parsed shell string.
28
+ const output = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", input.command], {
29
+ encoding: "utf-8",
30
+ timeout,
31
+ maxBuffer: 10 * 1024 * 1024,
32
+ windowsHide: true,
33
+ });
25
34
  return { output: output.trim(), isError: false };
26
35
  }
27
36
  catch (err) {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Image auto-downscale — bound the longest dimension to a fixed maximum
3
+ * before encoding the image as base64 for the model.
4
+ *
5
+ * Why: most providers reject or downsample images above ~1568-2048px on
6
+ * the longest side. Shipping a 4000px screenshot wastes input tokens, can
7
+ * exceed the request size limit, and historically broke the session
8
+ * outright when an oversized image landed in the conversation history.
9
+ *
10
+ * The function is a no-op for images already within bounds, for formats
11
+ * sharp doesn't process (PDF, SVG), and when sharp itself isn't installed
12
+ * (it's an `optionalDependency` so unsupported platforms still install).
13
+ * Any sharp error returns the original buffer unchanged — we never break a
14
+ * tool call over a downscale failure.
15
+ */
16
+ /** @internal Test-only reset of the lazy sharp cache. */
17
+ export declare function _resetSharpCacheForTest(): void;
18
+ export type DownscaleResult = {
19
+ /** The (possibly resized) buffer to encode. */
20
+ buffer: Buffer;
21
+ /** True if a resize actually happened; false for passthrough. */
22
+ downscaled: boolean;
23
+ /** Set when sharp wasn't available — caller may want to surface a one-time hint. */
24
+ reason?: "sharp-unavailable" | "unsupported-format" | "within-bounds" | "sharp-error";
25
+ };
26
+ /**
27
+ * Downscale `buffer` so its longest dimension is ≤ `maxDimension` (default 2000).
28
+ * Aspect ratio preserved. Format preserved (PNG stays PNG, JPEG stays JPEG, etc.).
29
+ *
30
+ * Pure pass-through for: PDF, SVG, BMP (sharp doesn't handle reliably),
31
+ * already-small images, missing sharp, and any sharp error.
32
+ */
33
+ export declare function downscaleIfLarge(buffer: Buffer, mediaType: string, maxDimension?: number): Promise<DownscaleResult>;
34
+ //# sourceMappingURL=image-downscale.d.ts.map