march-cli 0.1.13 → 0.1.15

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "march-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -14,6 +14,7 @@
14
14
  "scripts": {
15
15
  "dev": "cd .. && node march-cli/bin/march.mjs",
16
16
  "test": "node test/smoke.test.mjs",
17
+ "test:fast": "node test/fast.test.mjs",
17
18
  "test:real": "npm run test:shell-runtime-real && npm run test:shell-tui-real && npm run test:tui-key-real",
18
19
  "test:shell-runtime-real": "node test/shell-real-runtime.acceptance.mjs",
19
20
  "test:shell-tui-real": "node test/shell-tui-real.acceptance.mjs",
@@ -6,6 +6,8 @@ import { toolText } from "./tool-result.mjs";
6
6
  import { stripAnsi } from "../text/ansi.mjs";
7
7
 
8
8
  const OUTPUT_LIMIT = 64 * 1024;
9
+ const DEFAULT_COMMAND_TIMEOUT_SECONDS = 60;
10
+ const COMMAND_KILL_GRACE_MS = 1000;
9
11
 
10
12
  export function createCommandExecTool({ cwd }) {
11
13
  return defineTool({
@@ -15,13 +17,13 @@ export function createCommandExecTool({ cwd }) {
15
17
  parameters: Type.Object({
16
18
  command: Type.String({ description: "Command to execute" }),
17
19
  shell: Type.Optional(Type.String({ description: "auto (default), bash, or powershell" })),
18
- timeout: Type.Optional(Type.Number({ description: "Timeout in seconds; default 60" })),
20
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds; default 60", default: DEFAULT_COMMAND_TIMEOUT_SECONDS })),
19
21
  }),
20
- execute: async (_toolCallId, params) => executeCommand({ cwd, ...params }),
22
+ execute: async (_toolCallId, params, signal) => executeCommand({ cwd, signal, ...params }),
21
23
  });
22
24
  }
23
25
 
24
- export async function executeCommand({ cwd, command, shell = "auto", timeout = 60, spawnImpl = spawn }) {
26
+ export async function executeCommand({ cwd, command, shell = "auto", timeout = DEFAULT_COMMAND_TIMEOUT_SECONDS, signal, spawnImpl = spawn, killProcessTreeImpl = killProcessTree, forceSettleMs = COMMAND_KILL_GRACE_MS }) {
25
27
  let resolved;
26
28
  try {
27
29
  resolved = resolveCommandShell(shell);
@@ -29,14 +31,18 @@ export async function executeCommand({ cwd, command, shell = "auto", timeout = 6
29
31
  return toolText(`Error: ${err.message}`, { error: true });
30
32
  }
31
33
 
32
- const timeoutMs = Math.max(1, Number(timeout) || 60) * 1000;
34
+ const timeoutSeconds = Math.max(0.001, Number(timeout) || DEFAULT_COMMAND_TIMEOUT_SECONDS);
35
+ const timeoutMs = timeoutSeconds * 1000;
33
36
  const result = await spawnCommand(spawnImpl, resolved.bin, [...resolved.args, String(command ?? "")], {
34
37
  cwd,
35
38
  timeoutMs,
39
+ signal,
36
40
  windowsHide: true,
41
+ killProcessTreeImpl,
42
+ forceSettleMs,
37
43
  });
38
44
  if (result.error) {
39
- const detail = result.timedOut ? ` (timed out after ${timeout}s)` : "";
45
+ const detail = result.timedOut ? ` (timed out after ${timeoutSeconds}s)` : "";
40
46
  return toolText(`Error: ${result.error.message}${detail}`, { error: true });
41
47
  }
42
48
  const stdout = stripAnsi(result.stdout ?? "");
@@ -57,12 +63,23 @@ function spawnCommand(spawnImpl, bin, args, options) {
57
63
  let stderr = "";
58
64
  let settled = false;
59
65
  let timedOut = false;
60
- const child = spawnImpl(bin, args, { cwd: options.cwd, windowsHide: options.windowsHide });
66
+ let aborted = false;
67
+ let forceTimer = null;
68
+ const child = spawnImpl(bin, args, {
69
+ cwd: options.cwd,
70
+ windowsHide: options.windowsHide,
71
+ detached: process.platform !== "win32",
72
+ });
61
73
  const timer = setTimeout(() => {
62
74
  timedOut = true;
63
- child.kill?.("SIGTERM");
75
+ terminateChild(new Error("Command timed out"));
64
76
  }, options.timeoutMs);
65
- timer.unref?.();
77
+ const onAbort = () => {
78
+ aborted = true;
79
+ terminateChild(new Error("Command aborted"));
80
+ };
81
+ if (options.signal?.aborted) queueMicrotask(onAbort);
82
+ else options.signal?.addEventListener?.("abort", onAbort, { once: true });
66
83
 
67
84
  child.stdout?.setEncoding?.("utf8");
68
85
  child.stderr?.setEncoding?.("utf8");
@@ -71,16 +88,42 @@ function spawnCommand(spawnImpl, bin, args, options) {
71
88
  child.once?.("error", (error) => finish({ error }));
72
89
  child.once?.("close", (status, signal) => finish({ status, signal }));
73
90
 
91
+ function terminateChild(error) {
92
+ if (settled) return;
93
+ options.killProcessTreeImpl?.(child);
94
+ forceTimer ??= setTimeout(() => finish({ error }), options.forceSettleMs);
95
+ }
96
+
74
97
  function finish(result) {
75
98
  if (settled) return;
76
99
  settled = true;
77
100
  clearTimeout(timer);
78
- const error = result.error ?? (timedOut ? Object.assign(new Error("Command timed out"), { code: "ETIMEDOUT" }) : null);
79
- resolve({ ...result, error, stdout, stderr, timedOut });
101
+ clearTimeout(forceTimer);
102
+ options.signal?.removeEventListener?.("abort", onAbort);
103
+ const error = result.error ?? (timedOut ? Object.assign(new Error("Command timed out"), { code: "ETIMEDOUT" }) : null) ?? (aborted ? Object.assign(new Error("Command aborted"), { code: "ABORT_ERR" }) : null);
104
+ resolve({ ...result, error, stdout, stderr, timedOut, aborted });
80
105
  }
81
106
  });
82
107
  }
83
108
 
109
+ function killProcessTree(child, platform = process.platform) {
110
+ if (!child?.pid) {
111
+ child?.kill?.("SIGTERM");
112
+ return;
113
+ }
114
+ if (platform === "win32") {
115
+ try {
116
+ spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore", timeout: 5000 });
117
+ return;
118
+ } catch {}
119
+ }
120
+ try {
121
+ process.kill(-child.pid, "SIGTERM");
122
+ return;
123
+ } catch {}
124
+ child.kill?.("SIGTERM");
125
+ }
126
+
84
127
  function appendLimited(current, chunk) {
85
128
  const next = current + String(chunk ?? "");
86
129
  return next.length <= OUTPUT_LIMIT ? next : next.slice(-OUTPUT_LIMIT);
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
- import { toolText } from "./tool-result.mjs";
5
+ import { toolText } from "../tool-result.mjs";
6
6
 
7
7
  const DEFAULT_LIMIT = 30;
8
8
  const DEFAULT_DIRECTORY_LIMIT = 200;
@@ -47,7 +47,7 @@ export function readFileSlice({ engine, path, offset = 1, limit = DEFAULT_LIMIT
47
47
  const count = clampLimit(limit);
48
48
  const selected = lines.slice(start - 1, start - 1 + count);
49
49
  const end = start + selected.length - 1;
50
- const body = selected.map((line, index) => `${start + index} | ${line}`).join("\n");
50
+ const body = selected.map((line, index) => `${start + index}| ${line}`).join("\n");
51
51
  const header = `--- ${absPath} (lines ${start}-${end} of ${lines.length}) ---`;
52
52
  const remaining = lines.length - end;
53
53
  const footer = remaining > 0 ? `\n\n[${remaining} more lines in file. Use offset=${end + 1} to continue.]` : "";
@@ -0,0 +1,76 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+ import { defineTool } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { currentModelImageInputError } from "../vision-capability.mjs";
6
+ const IMAGE_MIME_BY_EXT = new Map([
7
+ [".png", "image/png"],
8
+ [".jpg", "image/jpeg"],
9
+ [".jpeg", "image/jpeg"],
10
+ [".webp", "image/webp"],
11
+ [".gif", "image/gif"],
12
+ ]);
13
+
14
+ export function createReadImageTool({ engine, getCurrentModel = null }) {
15
+ return defineTool({
16
+ name: "read_image",
17
+ label: "Read Image",
18
+ description: "Read a local image file and send it to the model as an image attachment. Supports png, jpg/jpeg, webp, and gif.",
19
+ parameters: Type.Object({
20
+ path: Type.String({ description: "Absolute or relative path to the image file" }),
21
+ }),
22
+ execute: async (_toolCallId, params) => {
23
+ const capabilityError = currentModelImageInputError(getCurrentModel);
24
+ if (capabilityError) return imageError(capabilityError, { unsupportedModel: true });
25
+ return readImageFile({ engine, ...params });
26
+ },
27
+ });
28
+ }
29
+
30
+ export function readImageFile({ engine, path }) {
31
+ const absPath = engine.resolvePath(path);
32
+ let stat;
33
+ try {
34
+ stat = statSync(absPath);
35
+ } catch (err) {
36
+ return imageError(`Error reading image ${absPath}: ${err.message}`, { path: absPath });
37
+ }
38
+ if (stat.isDirectory()) {
39
+ return imageError(`Error reading image ${absPath}: this is a directory. Use ls(path) or find(pattern, path) to inspect it.`, { path: absPath, isDirectory: true });
40
+ }
41
+
42
+ const mimeType = IMAGE_MIME_BY_EXT.get(extname(absPath).toLowerCase());
43
+ if (!mimeType) {
44
+ return imageError(`Error reading image ${absPath}: unsupported image type. Supported types: png, jpg, jpeg, webp, gif.`, { path: absPath });
45
+ }
46
+
47
+ let data;
48
+ try {
49
+ data = readFileSync(absPath).toString("base64");
50
+ } catch (err) {
51
+ return imageError(`Error reading image ${absPath}: ${err.message}`, { path: absPath });
52
+ }
53
+
54
+ const size = formatSize(stat.size);
55
+ return {
56
+ content: [
57
+ { type: "text", text: `Read image file: ${absPath}\nMIME: ${mimeType}\nSize: ${size}` },
58
+ { type: "image", data, mimeType },
59
+ ],
60
+ details: {
61
+ path: absPath,
62
+ mimeType,
63
+ sizeBytes: stat.size,
64
+ },
65
+ };
66
+ }
67
+
68
+ function imageError(text, details = {}) {
69
+ return { content: [{ type: "text", text }], details: { ...details, error: true } };
70
+ }
71
+
72
+ function formatSize(bytes) {
73
+ if (bytes < 1024) return `${bytes} B`;
74
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
75
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
76
+ }
@@ -52,14 +52,11 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
52
52
  const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, profilePaths, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
53
53
  const resolvedSessionManager = resolveRunnerSessionManager(cwd, sessionManager);
54
54
  const sessionBinding = createSessionBinding(null);
55
- let currentModelCallKind = "model", currentTurnId = null;
56
- let currentPromptForContext = "";
55
+ let currentModelCallKind = "model", currentTurnId = null, currentPromptForContext = "";
57
56
  let currentTurnContextMode = "rebuild";
58
57
  let nextTurnContextMode = "rebuild";
59
58
  let pendingMidTurnRecallHints = [];
60
- let lastNotificationResult = null;
61
- let runtimeHost = null;
62
- let lifecycleAdapter = null;
59
+ let lastNotificationResult = null, runtimeHost = null, lifecycleAdapter = null;
63
60
  let _currentFastEntry = null;
64
61
  if (useRuntimeHost) {
65
62
  runtimeHost = await createRunnerRuntimeHost({
@@ -83,6 +80,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
83
80
  cwd, provider, modelId, modelRegistry, engine, ui: runtimeUi,
84
81
  memoryTools, shellRuntime, lspService, mcpTools, webTools, permissionController,
85
82
  authStorage: resolvedAuth, projectMarchDir,
83
+ getCurrentModel: () => sessionBinding.get()?.model ?? selectedModel,
86
84
  });
87
85
  const { session } = await createAgentSessionImpl({
88
86
  cwd, agentDir: stateRoot, ...sessionOptions,
@@ -226,10 +224,12 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
226
224
  getExtensionDiagnostics() { return runtimeHost?.getDiagnostics?.() ?? []; },
227
225
  getExtensionLifecycleState() { return lifecycleAdapter.getState(); },
228
226
  getLspStatus() { return lspService.snapshot(); },
229
- async switchPiSession(sessionPath) {
227
+ async switchPiSession(sessionPath, restoreState = null) {
230
228
  if (!runtimeHost) throw new Error("pi runtime host is not enabled");
231
229
  nextTurnContextMode = "rebuild";
232
- return runtimeHost.switchSession(sessionPath);
230
+ const result = await runtimeHost.switchSession(sessionPath);
231
+ if (!result?.cancelled && restoreState) engine.restoreSession(restoreState, null, { replace: true });
232
+ return result;
233
233
  },
234
234
  cycleThinkingLevel() {
235
235
  const level = sessionBinding.get().cycleThinkingLevel();
@@ -30,7 +30,7 @@ export function createRemoteRunnerClient(peer, { initialState = null } = {}) {
30
30
  getExtensionDiagnostics: () => state?.extensionDiagnostics ?? [],
31
31
  getExtensionLifecycleState: () => state?.extensionLifecycleState ?? null,
32
32
  getLspStatus: () => state?.lspStatus ?? null,
33
- async switchPiSession(sessionPath) { return applyResultWithState(await peer.call("switchPiSession", sessionPath)); },
33
+ async switchPiSession(sessionPath, restoreState = null) { return applyResultWithState(await peer.call("switchPiSession", sessionPath, restoreState)); },
34
34
  async cycleThinkingLevel() { return applyResultWithState(await peer.call("cycleThinkingLevel")); },
35
35
  getThinkingLevel: () => state?.engine?.thinkingLevel ?? null,
36
36
  async setThinkingLevel(level) { return applyResultWithState(await peer.call("setThinkingLevel", level)); },
@@ -67,8 +67,8 @@ export function createRunnerIpcTarget({ createRunnerImpl, runnerOptions = {} } =
67
67
  getLspStatus() {
68
68
  return getRunner().getLspStatus();
69
69
  },
70
- async switchPiSession(sessionPath) {
71
- const result = await getRunner().switchPiSession(sessionPath);
70
+ async switchPiSession(sessionPath, restoreState = null) {
71
+ const result = await getRunner().switchPiSession(sessionPath, restoreState);
72
72
  return { result, state: getRunnerState(runner) };
73
73
  },
74
74
  cycleThinkingLevel() {
@@ -123,4 +123,3 @@ export function getRunnerState(runner) {
123
123
  extensionLifecycleState: runner.getExtensionLifecycleState?.() ?? null,
124
124
  };
125
125
  }
126
-
@@ -62,6 +62,7 @@ export async function createRunnerRuntimeHost({
62
62
  authStorage,
63
63
  projectMarchDir,
64
64
  hostedTools,
65
+ getCurrentModel: () => sessionBinding.get()?.model ?? null,
65
66
  });
66
67
  },
67
68
  });
@@ -0,0 +1,39 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { listWindowsWindows } from "./windows-screen.mjs";
4
+
5
+ export function createListWindowsTool({ listWindowsImpl = listWindowsWindows } = {}) {
6
+ return defineTool({
7
+ name: "list_windows",
8
+ label: "List Windows",
9
+ description: "List visible desktop windows so the model can choose a windowId for screen({ target: 'window', windowId }).",
10
+ parameters: Type.Object({
11
+ limit: Type.Optional(Type.Number({ description: "Maximum windows to return; default 30" })),
12
+ }),
13
+ execute: async (_toolCallId, params = {}) => listWindowsTool({ listWindowsImpl, ...params }),
14
+ });
15
+ }
16
+
17
+ export function listWindowsTool({ listWindowsImpl = listWindowsWindows, limit = 30 } = {}) {
18
+ const result = listWindowsImpl();
19
+ if (!result?.ok) return textResult(`Error listing windows: ${result?.message || "unknown error"}`, { error: true });
20
+ const windows = (result.windows ?? []).slice(0, normalizeLimit(limit));
21
+ if (windows.length === 0) return textResult("No visible windows found.", { windows: [] });
22
+ const lines = ["Visible windows:"];
23
+ for (const item of windows) {
24
+ const process = item.process ? ` (${item.process})` : "";
25
+ const bounds = item.bounds ? ` ${item.bounds.width}x${item.bounds.height}+${item.bounds.x},${item.bounds.y}` : "";
26
+ const minimized = item.minimized ? " minimized" : "";
27
+ lines.push(`- ${item.id}${process}${bounds}${minimized}: ${item.title}`);
28
+ }
29
+ lines.push("Use screen({ target: 'window', windowId }) to capture a listed window.");
30
+ return textResult(lines.join("\n"), { windows });
31
+ }
32
+
33
+ function normalizeLimit(limit) {
34
+ return Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), 100) : 30;
35
+ }
36
+
37
+ function textResult(text, details = {}) {
38
+ return { content: [{ type: "text", text }], details };
39
+ }
@@ -0,0 +1,49 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { currentModelImageInputError } from "../vision-capability.mjs";
4
+ import { captureScreenWindows } from "./windows-screen.mjs";
5
+
6
+ export function createScreenTool({ getCurrentModel = null, captureScreenImpl = captureScreenWindows } = {}) {
7
+ return defineTool({
8
+ name: "screen",
9
+ label: "Screen Capture",
10
+ description: "Capture the current desktop or a visible window and send it to the model as an image attachment.",
11
+ parameters: Type.Object({
12
+ target: Type.Optional(Type.String({ description: "desktop (default) or window" })),
13
+ windowId: Type.Optional(Type.String({ description: "Window id from list_windows when target is window" })),
14
+ }),
15
+ execute: async (_toolCallId, params = {}) => captureScreenTool({ getCurrentModel, captureScreenImpl, ...params }),
16
+ });
17
+ }
18
+
19
+ export function captureScreenTool({ getCurrentModel = null, captureScreenImpl = captureScreenWindows, target = "desktop", windowId = null } = {}) {
20
+ const capabilityError = currentModelImageInputError(getCurrentModel);
21
+ if (capabilityError) return screenError(capabilityError, { unsupportedModel: true });
22
+ const normalizedTarget = target === "window" ? "window" : "desktop";
23
+ const result = captureScreenImpl({ target: normalizedTarget, windowId });
24
+ if (!result?.ok) return screenError(`Error capturing screen: ${result?.message || "unknown error"}`, { target: normalizedTarget, windowId });
25
+
26
+ const bounds = result.bounds ?? {};
27
+ const label = normalizedTarget === "window" ? `window ${result.windowId || windowId}` : "desktop";
28
+ return {
29
+ content: [
30
+ { type: "text", text: `Captured ${label} screenshot\nMIME: ${result.mimeType || "image/png"}\nBounds: ${formatBounds(bounds)}` },
31
+ { type: "image", data: result.data, mimeType: result.mimeType || "image/png" },
32
+ ],
33
+ details: {
34
+ target: normalizedTarget,
35
+ windowId: result.windowId ?? windowId ?? undefined,
36
+ bounds,
37
+ mimeType: result.mimeType || "image/png",
38
+ },
39
+ };
40
+ }
41
+
42
+ function screenError(text, details = {}) {
43
+ return { content: [{ type: "text", text }], details: { ...details, error: true } };
44
+ }
45
+
46
+ function formatBounds(bounds) {
47
+ const { x = 0, y = 0, width = 0, height = 0 } = bounds ?? {};
48
+ return `${width}x${height} at ${x},${y}`;
49
+ }
@@ -0,0 +1,133 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ const POWERSHELL = process.env.SystemRoot ? `${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe";
4
+ const MAX_BUFFER = 80 * 1024 * 1024;
5
+
6
+ export function listWindowsWindows() {
7
+ return runJson(LIST_WINDOWS_SCRIPT);
8
+ }
9
+
10
+ export function captureScreenWindows({ target = "desktop", windowId = null } = {}) {
11
+ if (target === "window") {
12
+ if (!windowId) return { ok: false, message: "windowId is required when target is window" };
13
+ return runJson(CAPTURE_WINDOW_SCRIPT.replace("__WINDOW_ID__", escapePowershellString(windowId)));
14
+ }
15
+ return runJson(CAPTURE_DESKTOP_SCRIPT);
16
+ }
17
+
18
+ function runJson(script) {
19
+ if (process.platform !== "win32") return { ok: false, message: `screen tools are not supported on ${process.platform}` };
20
+ try {
21
+ const output = execFileSync(POWERSHELL, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], {
22
+ encoding: "utf8",
23
+ maxBuffer: MAX_BUFFER,
24
+ windowsHide: true,
25
+ }).trim();
26
+ return JSON.parse(output);
27
+ } catch (err) {
28
+ return { ok: false, message: `failed to run Windows screen capture: ${err.stderr || err.message}` };
29
+ }
30
+ }
31
+
32
+ function escapePowershellString(value) {
33
+ return String(value).replaceAll("'", "''");
34
+ }
35
+
36
+ const WIN32_TYPE = String.raw`
37
+ using System;
38
+ using System.Text;
39
+ using System.Runtime.InteropServices;
40
+ public static class MarchWin32 {
41
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
42
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
43
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
44
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
45
+ [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
46
+ [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
47
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
48
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
49
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
50
+ }
51
+ `;
52
+
53
+ const LIST_WINDOWS_SCRIPT = String.raw`
54
+ Add-Type -TypeDefinition @'
55
+ ${WIN32_TYPE}
56
+ '@
57
+ $items = New-Object System.Collections.Generic.List[object]
58
+ [MarchWin32]::EnumWindows({ param($hwnd, $lparam)
59
+ if (-not [MarchWin32]::IsWindowVisible($hwnd)) { return $true }
60
+ $length = [MarchWin32]::GetWindowTextLength($hwnd)
61
+ if ($length -le 0) { return $true }
62
+ $builder = New-Object System.Text.StringBuilder ($length + 1)
63
+ [void][MarchWin32]::GetWindowText($hwnd, $builder, $builder.Capacity)
64
+ $rect = New-Object MarchWin32+RECT
65
+ if (-not [MarchWin32]::GetWindowRect($hwnd, [ref]$rect)) { return $true }
66
+ $width = $rect.Right - $rect.Left
67
+ $height = $rect.Bottom - $rect.Top
68
+ if ($width -le 0 -or $height -le 0) { return $true }
69
+ $pidValue = 0
70
+ [void][MarchWin32]::GetWindowThreadProcessId($hwnd, [ref]$pidValue)
71
+ $processName = $null
72
+ try { $processName = (Get-Process -Id $pidValue -ErrorAction Stop).ProcessName } catch {}
73
+ $items.Add([ordered]@{
74
+ id = ("0x{0:x}" -f $hwnd.ToInt64())
75
+ title = $builder.ToString()
76
+ process = $processName
77
+ pid = $pidValue
78
+ bounds = [ordered]@{ x = $rect.Left; y = $rect.Top; width = $width; height = $height }
79
+ minimized = [MarchWin32]::IsIconic($hwnd)
80
+ }) | Out-Null
81
+ return $true
82
+ }, [IntPtr]::Zero) | Out-Null
83
+ [ordered]@{ ok = $true; windows = $items } | ConvertTo-Json -Compress -Depth 5
84
+ `;
85
+
86
+ const CAPTURE_DESKTOP_SCRIPT = String.raw`
87
+ Add-Type -AssemblyName System.Windows.Forms
88
+ Add-Type -AssemblyName System.Drawing
89
+ $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
90
+ $bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
91
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
92
+ $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size)
93
+ $stream = New-Object System.IO.MemoryStream
94
+ $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
95
+ $graphics.Dispose(); $bitmap.Dispose()
96
+ [ordered]@{
97
+ ok = $true; data = [Convert]::ToBase64String($stream.ToArray()); mimeType = "image/png";
98
+ target = "desktop"; bounds = [ordered]@{ x = $bounds.Left; y = $bounds.Top; width = $bounds.Width; height = $bounds.Height }
99
+ } | ConvertTo-Json -Compress -Depth 5
100
+ `;
101
+
102
+ const CAPTURE_WINDOW_SCRIPT = String.raw`
103
+ Add-Type -AssemblyName System.Drawing
104
+ Add-Type -TypeDefinition @'
105
+ ${WIN32_TYPE}
106
+ '@
107
+ $rawId = '__WINDOW_ID__'
108
+ $hex = $rawId -replace '^0x',''
109
+ try { $hwnd = [IntPtr]::new([Convert]::ToInt64($hex, 16)) } catch {
110
+ [ordered]@{ ok = $false; message = "invalid windowId: $rawId" } | ConvertTo-Json -Compress; exit 0
111
+ }
112
+ if ([MarchWin32]::IsIconic($hwnd)) {
113
+ [ordered]@{ ok = $false; message = "window is minimized and cannot be captured" } | ConvertTo-Json -Compress; exit 0
114
+ }
115
+ $rect = New-Object MarchWin32+RECT
116
+ if (-not [MarchWin32]::GetWindowRect($hwnd, [ref]$rect)) {
117
+ [ordered]@{ ok = $false; message = "window not found: $rawId" } | ConvertTo-Json -Compress; exit 0
118
+ }
119
+ $width = $rect.Right - $rect.Left; $height = $rect.Bottom - $rect.Top
120
+ if ($width -le 0 -or $height -le 0) {
121
+ [ordered]@{ ok = $false; message = "window has empty bounds: $rawId" } | ConvertTo-Json -Compress; exit 0
122
+ }
123
+ $bitmap = New-Object System.Drawing.Bitmap $width, $height
124
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
125
+ $graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, (New-Object System.Drawing.Size $width, $height))
126
+ $stream = New-Object System.IO.MemoryStream
127
+ $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
128
+ $graphics.Dispose(); $bitmap.Dispose()
129
+ [ordered]@{
130
+ ok = $true; data = [Convert]::ToBase64String($stream.ToArray()); mimeType = "image/png";
131
+ target = "window"; windowId = $rawId; bounds = [ordered]@{ x = $rect.Left; y = $rect.Top; width = $width; height = $height }
132
+ } | ConvertTo-Json -Compress -Depth 5
133
+ `;
@@ -17,6 +17,7 @@ export function resolveRunnerSessionOptions({
17
17
  permissionController = null,
18
18
  authStorage = null,
19
19
  projectMarchDir = null,
20
+ getCurrentModel = null,
20
21
  }) {
21
22
  if (engine.cwd !== cwd) {
22
23
  throw new Error(`Runtime session cwd mismatch: engine=${engine.cwd}, session=${cwd}`);
@@ -28,7 +29,7 @@ export function resolveRunnerSessionOptions({
28
29
  ?? (provider && modelId ? getModel(provider, modelId) : null);
29
30
  if (!model) throw new Error(`Model not found: ${provider}/${modelId}`);
30
31
 
31
- const customTools = createMarchCustomTools({ cwd, engine, ui, memoryTools, shellRuntime, lspService, mcpTools, webTools, permissionController, authStorage, projectMarchDir });
32
+ const customTools = createMarchCustomTools({ cwd, engine, ui, memoryTools, shellRuntime, lspService, mcpTools, webTools, permissionController, authStorage, projectMarchDir, getCurrentModel: () => getCurrentModel?.() ?? model });
32
33
  const customToolNames = customTools.map((tool) => tool.name);
33
34
  const tools = [
34
35
  ...customToolNames.filter((name) => name === "read"),
@@ -1,22 +1,29 @@
1
- import { defineTool } from "@earendil-works/pi-coding-agent";
2
- import { Type } from "typebox";
3
1
  import { createCommandExecTool } from "./command-exec-tool.mjs";
4
2
  import { createContextStatsTool } from "./context-stats-tool.mjs";
5
3
  import { createEditFileTool } from "./file-edit-tool.mjs";
6
- import { createReadFileTool } from "./read-file-tool.mjs";
4
+ import { createReadFileTool } from "./file-tools/read-file-tool.mjs";
5
+ import { createReadImageTool } from "./file-tools/read-image-tool.mjs";
6
+ import { createScreenTool } from "./screen-tools/screen-tool.mjs";
7
+ import { createListWindowsTool } from "./screen-tools/list-windows-tool.mjs";
7
8
  import { toolText } from "./tool-result.mjs";
8
9
  import { createShellTools } from "../shell/tools.mjs";
9
- import { createWebTools } from "../web/tools.mjs";
10
10
  import { initImageGen } from "../image-gen/index.mjs";
11
11
  import { createSuperGrokTool } from "../supergrok/tool.mjs";
12
- export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shellRuntime = null, lspService = null, mcpTools = [], webTools = [], permissionController = null, authStorage = null, projectMarchDir = null }) {
12
+
13
+ export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shellRuntime = null, lspService = null, mcpTools = [], webTools = [], permissionController = null, authStorage = null, projectMarchDir = null, getCurrentModel = null }) {
13
14
  const commandExecTool = createCommandExecTool({ cwd });
14
15
  const contextStatsTool = createContextStatsTool({ engine });
15
16
  const editFileTool = createEditFileTool({ engine, ui, lspService });
16
17
  const readFileTool = createReadFileTool({ engine });
18
+ const readImageTool = createReadImageTool({ engine, getCurrentModel });
19
+ const screenTool = createScreenTool({ getCurrentModel });
20
+ const listWindowsTool = createListWindowsTool();
17
21
 
18
22
  const tools = [
19
23
  readFileTool,
24
+ readImageTool,
25
+ screenTool,
26
+ listWindowsTool,
20
27
  contextStatsTool,
21
28
  commandExecTool,
22
29
  editFileTool,
@@ -33,7 +40,7 @@ export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shel
33
40
  return tools.map((tool) => {
34
41
  const execute = tool.execute;
35
42
  if (!execute) return tool;
36
- const wrapped = async (toolCallId, params) => {
43
+ const wrapped = async (toolCallId, params, signal, onUpdate) => {
37
44
  const decision = await permissionController.requestApproval(
38
45
  tool.name,
39
46
  params,
@@ -44,7 +51,7 @@ export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shel
44
51
  if (decision.behavior === "deny") {
45
52
  return toolText(`Permission denied: ${decision.message}`, { error: true, permissionDenied: true });
46
53
  }
47
- return execute(toolCallId, params);
54
+ return execute(toolCallId, params, signal, onUpdate);
48
55
  };
49
56
  return { ...tool, execute: wrapped };
50
57
  });
@@ -0,0 +1,14 @@
1
+ export function modelSupportsImageInput(model) {
2
+ if (!model || typeof model !== "object") return false;
3
+ if (Array.isArray(model.input) && model.input.includes("image")) return true;
4
+ if (model.capabilities?.images === true || model.capabilities?.vision === true) return true;
5
+ return false;
6
+ }
7
+
8
+ export function currentModelImageInputError(getCurrentModel) {
9
+ if (typeof getCurrentModel !== "function") return null;
10
+ const model = getCurrentModel();
11
+ if (modelSupportsImageInput(model)) return null;
12
+ const label = model ? `${model.name || model.id || "unknown"} (${model.provider || "unknown provider"})` : "unknown";
13
+ return `Current model does not support image input: ${label}. Switch to a vision-capable model before using read_image or screen.`;
14
+ }
package/src/cli/args.mjs CHANGED
@@ -11,6 +11,8 @@ export function parseCliArgs(argv) {
11
11
  extension: { type: "string", short: "e", multiple: true },
12
12
  extension: { type: "string", short: "e", multiple: true },
13
13
  config: { type: "boolean" },
14
+ "include-key": { type: "boolean" },
15
+ "profile-only": { type: "boolean" },
14
16
  "dump-context": { type: "boolean" },
15
17
  "pi-sessions": { type: "boolean" },
16
18
  "pi-runtime-host": { type: "boolean" },
@@ -33,6 +35,8 @@ export function parseCliArgs(argv) {
33
35
  extensions: values.extension ?? [],
34
36
  dumpContext: values["dump-context"] ?? false,
35
37
  providerConfig: values.config ?? false,
38
+ includeKey: values["include-key"] ?? false,
39
+ profileOnly: values["profile-only"] ?? false,
36
40
  piSessions: values["pi-sessions"] ?? false,
37
41
  piRuntimeHost: values["pi-runtime-host"] ?? false,
38
42
  shellRuntime: values["no-shell-runtime"] ? false : true,
@@ -50,6 +54,8 @@ Usage:
50
54
  march [options] (starts REPL)
51
55
  march login [provider] Login to an OAuth provider
52
56
  march provider --config Configure provider credentials
57
+ march provider share [id] Share a provider profile
58
+ march provider accept <token>
53
59
  march websearch --config Configure web search credentials
54
60
 
55
61
  Options:
@@ -58,6 +64,8 @@ Options:
58
64
  --resume <id> Resume a pi session by default
59
65
  --json JSON output mode (no TUI)
60
66
  --config With provider/websearch command, open configuration
67
+ --include-key With provider share, include API key
68
+ --profile-only With provider share, omit API key
61
69
  --dump-context Write every prompt sent to the model under .march/context-dumps/
62
70
  --pi-sessions Force pi JSONL SessionManager persistence
63
71
  --pi-runtime-host Force pi AgentSessionRuntime host path