plonk-mcp 0.2.5 → 0.3.1

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/README.md CHANGED
@@ -58,6 +58,7 @@ config to tell two sessions of the same client apart.
58
58
  | `save_workspace` · `launch_workspace` · `delete_workspace` | Named desktops that reopen their apps and restore every window |
59
59
  | `save_zone_set` · `assign_zone_set` · `delete_zone_set` | Snap zones, assigned per monitor |
60
60
  | `set_awake` | Keep-awake, optionally time-limited |
61
+ | `set_active` | Stay active, so chat apps do not show you as Away |
61
62
  | `take_screenshot` · `annotate_screenshot` | Capture, mark up, hand the image back — `mode: "app"` photographs one named window even when it is buried, without raising it |
62
63
  | `select_agent` | Make an agent the active one, optionally the only one allowed to control |
63
64
  | `check_for_update` · `install_update` | Ask GitHub for a newer release and install it |
package/dist/api.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { notRunningMessage } from "./messages.js";
1
2
  export const BASE = "http://127.0.0.1:43917";
2
3
  const DEFAULT_TIMEOUT_MS = 15_000;
3
- const NOT_RUNNING = "Plonk menu bar app is not running. Ask the user to launch Plonk.app (its icon should appear in the menu bar).";
4
+ /** Interactive captures and the ruler wait on the user, so they get this budget instead. */
5
+ export const INTERACTIVE_TIMEOUT_MS = 5 * 60_000;
4
6
  // Stamped on every request so the app can attribute it to a client and, in
5
7
  // exclusive mode, gate on it. One mechanism, one shape: the identity always
6
8
  // lives in a holder. HTTP runs each request inside its session's holder; stdio
@@ -124,7 +126,7 @@ export async function call(path, options = {}) {
124
126
  if (timeout.aborted) {
125
127
  return { error: `Plonk did not answer within ${timeoutMs / 1000}s. It may be waiting on a dialog.` };
126
128
  }
127
- return { error: NOT_RUNNING };
129
+ return { error: notRunningMessage(agentIdentityName()) };
128
130
  }
129
131
  const text = await res.text();
130
132
  try {
package/dist/cli.js CHANGED
@@ -5,8 +5,9 @@
5
5
  // neither helps a script, a Raycast command or a Makefile. This is that gap:
6
6
  // every subcommand is one HTTP call to 127.0.0.1, and nothing here holds state.
7
7
  import { spawn } from "node:child_process";
8
- import { BASE, call, processIdentityHolder } from "./api.js";
8
+ import { BASE, call, INTERACTIVE_TIMEOUT_MS, processIdentityHolder } from "./api.js";
9
9
  import { options } from "./args.js";
10
+ import { CLI_NAME } from "./messages.js";
10
11
  const USAGE = `plonk — drive the Plonk menu bar app from a shell
11
12
 
12
13
  plonk state [--json] screens, windows, zone sets, workspaces
@@ -18,6 +19,7 @@ const USAGE = `plonk — drive the Plonk menu bar app from a shell
18
19
  plonk awake off
19
20
  plonk awake on [--minutes N] [--until HH:MM] [--pid N]
20
21
  plonk awake while <command...> stay awake until that command exits
22
+ plonk measure [X Y] [--screen N] [--tolerance N] measure at that point
21
23
  plonk text [--mode region|window|screen] [--path FILE]
22
24
  plonk shot [--mode region|window|screen] [--path FILE]
23
25
 
@@ -44,6 +46,15 @@ function number(raw, what) {
44
46
  fail(`${what} must be a whole number, got "${raw}"`);
45
47
  return value;
46
48
  }
49
+ /** Points arrive as fractions of a screen, the way every frame in this API
50
+ * does, so a measurement can be pasted straight back into a layout. */
51
+ function fraction(raw, what) {
52
+ const value = Number(raw);
53
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
54
+ fail(`${what} must be a fraction between 0 and 1, got "${raw}"`);
55
+ }
56
+ return value;
57
+ }
47
58
  /** Prints the reply and exits non-zero when the app refused. */
48
59
  function report(result) {
49
60
  if ("error" in result) {
@@ -124,12 +135,10 @@ async function main() {
124
135
  return;
125
136
  case "ping":
126
137
  report(await call("/ping"));
127
- break;
128
138
  case "state":
129
139
  if (flags.json)
130
140
  report(await call("/state"));
131
141
  await summarize();
132
- break;
133
142
  case "snap": {
134
143
  const [app, zone] = args;
135
144
  if (!app || zone === undefined)
@@ -138,7 +147,6 @@ async function main() {
138
147
  method: "POST",
139
148
  body: { app, zone: number(zone, "zone"), screen },
140
149
  }));
141
- break;
142
150
  }
143
151
  case "workspaces": {
144
152
  const state = await call("/state");
@@ -155,17 +163,14 @@ async function main() {
155
163
  body: { name: args[0], screen },
156
164
  timeoutMs: 90_000,
157
165
  }));
158
- break;
159
166
  case "save":
160
167
  if (!args[0])
161
168
  fail("save needs a name for the workspace");
162
169
  report(await call("/workspaces/save", { method: "POST", body: { name: args[0] } }));
163
- break;
164
170
  case "zones":
165
171
  if (!args[0])
166
172
  fail("zones needs a set name, or 'edge' for edge snapping");
167
173
  report(await call("/zones/assign", { method: "POST", body: { screen: screen ?? 0, name: args[0] } }));
168
- break;
169
174
  case "awake": {
170
175
  // `while` is handled before flags are parsed; anything else must be on/off.
171
176
  if (args[0] !== "on" && args[0] !== "off")
@@ -179,13 +184,29 @@ async function main() {
179
184
  pid: number(flags.pid, "--pid"),
180
185
  },
181
186
  }));
182
- break;
187
+ }
188
+ case "measure": {
189
+ // With no point named there is nobody to ask but the user, so the ruler
190
+ // goes on screen and this waits for them.
191
+ const [x, y] = args;
192
+ const interactive = x === undefined || y === undefined;
193
+ report(await call("/ruler/measure", {
194
+ method: "POST",
195
+ body: interactive
196
+ ? { interactive: true }
197
+ : {
198
+ screen,
199
+ point: { x: fraction(x, "x"), y: fraction(y, "y") },
200
+ tolerance: number(flags.tolerance, "--tolerance"),
201
+ },
202
+ timeoutMs: interactive ? INTERACTIVE_TIMEOUT_MS : 30_000,
203
+ }));
183
204
  }
184
205
  case "text": {
185
206
  const result = await call("/shot/text", {
186
207
  method: "POST",
187
208
  body: { mode: flags.mode ?? "region", path: flags.path },
188
- timeoutMs: 5 * 60_000,
209
+ timeoutMs: INTERACTIVE_TIMEOUT_MS,
189
210
  });
190
211
  if ("error" in result)
191
212
  report(result);
@@ -197,16 +218,15 @@ async function main() {
197
218
  report(await call("/shot/capture", {
198
219
  method: "POST",
199
220
  body: { mode: flags.mode ?? "region", path: flags.path },
200
- timeoutMs: 5 * 60_000,
221
+ timeoutMs: INTERACTIVE_TIMEOUT_MS,
201
222
  }));
202
- break;
203
223
  default:
204
224
  fail(`unknown command "${command}"\n\n${USAGE}`);
205
225
  }
206
226
  }
207
227
  // Named so the app can attribute the calls, and so "only the active agent
208
228
  // controls" can be pointed at the shell like anything else.
209
- processIdentityHolder().identity = { name: "plonk-cli", version: "", pid: process.pid };
229
+ processIdentityHolder().identity = { name: CLI_NAME, version: "", pid: process.pid };
210
230
  try {
211
231
  await main();
212
232
  }
package/dist/factory.js CHANGED
@@ -7,10 +7,12 @@ import { register as registerState } from "./tools/state.js";
7
7
  import { register as registerLayouts } from "./tools/layouts.js";
8
8
  import { register as registerWorkspaces } from "./tools/workspaces.js";
9
9
  import { register as registerZones } from "./tools/zones.js";
10
+ import { register as registerActive } from "./tools/active.js";
10
11
  import { register as registerAwake } from "./tools/awake.js";
11
12
  import { register as registerScreenshot } from "./tools/screenshot.js";
12
13
  import { register as registerAnnotate } from "./tools/annotate.js";
13
14
  import { register as registerText } from "./tools/text.js";
15
+ import { register as registerRuler } from "./tools/ruler.js";
14
16
  import { register as registerAgents } from "./tools/agents.js";
15
17
  import { register as registerUpdate } from "./tools/update.js";
16
18
  const { version } = createRequire(import.meta.url)("../package.json");
@@ -21,9 +23,11 @@ export function createPlonkServer() {
21
23
  registerLayouts(server);
22
24
  registerZones(server);
23
25
  registerAwake(server);
26
+ registerActive(server);
24
27
  registerScreenshot(server);
25
28
  registerAnnotate(server);
26
29
  registerText(server);
30
+ registerRuler(server);
27
31
  registerAgents(server);
28
32
  registerUpdate(server);
29
33
  return server;
@@ -33,7 +37,7 @@ export function createPlonkServer() {
33
37
  * "pet-project"). The initialized notification can outrun the initialize
34
38
  * handler's bookkeeping in the SDK, leaving clientInfo briefly unset, so this
35
39
  * polls instead of trusting the callback's timing. */
36
- export function watchClientInfo(server, onKnown) {
40
+ function watchClientInfo(server, onKnown) {
37
41
  const poll = (attempt = 0) => {
38
42
  const client = server.server.getClientVersion();
39
43
  if (!client && attempt < 50) {
@@ -45,9 +49,25 @@ export function watchClientInfo(server, onKnown) {
45
49
  };
46
50
  server.server.oninitialized = () => poll();
47
51
  }
52
+ /** Once the client is known, names the holder after it and keeps the app told
53
+ * about it. Returns a stop function that ends both once they have started. */
54
+ export function bindClient(server, holder, nextPid) {
55
+ let stop = () => { };
56
+ watchClientInfo(server, ({ name, version }) => {
57
+ const identity = { name, version, pid: nextPid() };
58
+ holder.identity = identity;
59
+ const stopHello = startHello(identity);
60
+ const stopInbox = startInboxLoop(server, identity);
61
+ stop = () => {
62
+ stopHello();
63
+ stopInbox();
64
+ };
65
+ });
66
+ return () => stop();
67
+ }
48
68
  /** Registers the identity with the app and keeps it marked online with a
49
69
  * heartbeat. Returns a stop function for when the session ends. */
50
- export function startHello(identity) {
70
+ function startHello(identity) {
51
71
  const hello = () => call("/agents/hello", {
52
72
  method: "POST",
53
73
  body: { name: identity.name, version: identity.version, pid: identity.pid },
@@ -66,7 +86,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
66
86
  * queue, and draining what this client cannot act on would silently throw the
67
87
  * user's words away instead of leaving them for a CLI adapter.
68
88
  * Returns a stop function; it is a no-op when the loop never started. */
69
- export function startInboxLoop(server, identity) {
89
+ function startInboxLoop(server, identity) {
70
90
  if (!server.server.getClientCapabilities()?.sampling) {
71
91
  console.error(`plonk-mcp: ${identity.name} does not support MCP sampling, so Plonk cannot hand it spoken ` +
72
92
  `or queued prompts. Configure a CLI adapter for it in Plonk (Settings, AI · MCP) to use voice.`);
package/dist/http.js CHANGED
@@ -6,7 +6,7 @@ import { createServer } from "node:http";
6
6
  import { randomUUID, timingSafeEqual } from "node:crypto";
7
7
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
8
  import { localApiToken, runWithIdentity } from "./api.js";
9
- import { createPlonkServer, startHello, startInboxLoop, watchClientInfo } from "./factory.js";
9
+ import { bindClient, createPlonkServer } from "./factory.js";
10
10
  // The app's registry tells sessions apart by (name, pid). Every HTTP client
11
11
  // shares this process, so each session gets a synthetic pid instead.
12
12
  let syntheticPid = 100_000 + (process.pid % 1_000) * 100;
@@ -75,19 +75,13 @@ export async function serveHttp(port) {
75
75
  }),
76
76
  };
77
77
  session.transport.onclose = () => {
78
- session.stopHello?.();
79
- session.stopInbox?.();
78
+ session.stop?.();
80
79
  const sid = session.transport.sessionId;
81
80
  if (sid !== undefined)
82
81
  sessions.delete(sid);
83
82
  };
84
83
  const server = createPlonkServer();
85
- watchClientInfo(server, ({ name, version }) => {
86
- const identity = { name, version, pid: syntheticPid++ };
87
- session.holder.identity = identity;
88
- session.stopHello = startHello(identity);
89
- session.stopInbox = startInboxLoop(server, identity);
90
- });
84
+ session.stop = bindClient(server, session.holder, () => syntheticPid++);
91
85
  await server.connect(session.transport);
92
86
  await runWithIdentity(session.holder, () => session.transport.handleRequest(req, res));
93
87
  };
@@ -0,0 +1,14 @@
1
+ // Errors the client composes itself, worded for whoever will read them.
2
+ /** The identity `plonk` registers before its first call, so a refusal can be
3
+ * addressed to a person at a prompt rather than to a model. */
4
+ export const CLI_NAME = "plonk-cli";
5
+ const NOT_RUNNING_FOR_AGENT = "Plonk menu bar app is not running. Ask the user to launch Plonk.app (its icon should appear in the menu bar).";
6
+ // A person who ran `npm i -g plonk-mcp` first has no reason to know the app is
7
+ // a separate install, so the cask is the useful half of this sentence.
8
+ const NOT_RUNNING_FOR_CLI = "Plonk.app is not running. Launch it (its icon appears in the menu bar), or install it first: brew install --cask ostapondo/plonk/plonk";
9
+ /** The refused-connection message for the client named `agent`. Same cause
10
+ * either way; the CLI's form tells the reader what to do instead of telling
11
+ * them to ask themselves. */
12
+ export function notRunningMessage(agent) {
13
+ return agent === CLI_NAME ? NOT_RUNNING_FOR_CLI : NOT_RUNNING_FOR_AGENT;
14
+ }
package/dist/schemas.js CHANGED
@@ -1,5 +1,9 @@
1
1
  // Shared zod schemas for tool inputs.
2
2
  import { z } from "zod";
3
+ export const pointSchema = z.object({
4
+ x: z.number().min(0).max(1),
5
+ y: z.number().min(0).max(1),
6
+ });
3
7
  export const frameSchema = z.object({
4
8
  x: z.number().min(0).max(1),
5
9
  y: z.number().min(0).max(1),
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // instead, for clients that cannot spawn a process — several at once.
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
8
  import { BASE, isAppReachable, processIdentityHolder } from "./api.js";
9
- import { createPlonkServer, startHello, startInboxLoop, watchClientInfo } from "./factory.js";
9
+ import { bindClient, createPlonkServer } from "./factory.js";
10
10
  import { serveHttp } from "./http.js";
11
11
  const args = process.argv.slice(2);
12
12
  if (args.includes("--http")) {
@@ -19,14 +19,8 @@ if (args.includes("--http")) {
19
19
  await serveHttp(port);
20
20
  }
21
21
  else {
22
- const holder = processIdentityHolder();
23
22
  const server = createPlonkServer();
24
- watchClientInfo(server, ({ name, version }) => {
25
- const identity = { name, version, pid: process.pid };
26
- holder.identity = identity;
27
- startHello(identity);
28
- startInboxLoop(server, identity);
29
- });
23
+ bindClient(server, processIdentityHolder(), () => process.pid);
30
24
  await server.connect(new StdioServerTransport());
31
25
  }
32
26
  // stdout carries the stdio protocol, so this goes to stderr. Not fatal: the
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import { call, text } from "../api.js";
3
+ export function register(server) {
4
+ server.tool("set_active", "Turn stay-active on or off, so chat apps go on showing the user as available instead of Away. " +
5
+ "Pick this over 'set_awake' by what is being protected: set_awake holds a power assertion and stops the Mac sleeping, which does nothing for a Slack or Teams status; set_active resets the system idle timer by posting a Shift keypress every two minutes, which is what those apps actually read. Resetting the idle timer also postpones sleep, so stay-active implies keep-awake and there is no need to turn both on. " +
6
+ "Two ways to end the session: 'until' ends it at a wall-clock time ('17:00', or an ISO-8601 timestamp); 'minutes' ends it after a countdown. Give neither and it runs until switched off, or until the user's configured default timeout expires. " +
7
+ "This only starts and ends sessions. The recurring schedule (hours and weekdays) and the list of apps that arm it automatically are settings on the Stay active page, not parameters here; get_state reports both under 'active_details'. " +
8
+ "Switching it by hand overrides the schedule until the schedule itself next changes, so turning it off during scheduled hours lasts until those hours end rather than being undone on the next tick. " +
9
+ "Returns 'active', whether a keypress is actually being posted right now, and 'status', what happened in words. Those differ when Plonk has no Accessibility permission (nothing can be posted) or when the user disallowed running on battery and the Mac is unplugged; neither is reported as an error, since the request was understood. An 'until' that has already passed is an error.", {
10
+ on: z.boolean(),
11
+ minutes: z
12
+ .number()
13
+ .int()
14
+ .min(1)
15
+ .optional()
16
+ .describe("End the session after this many minutes"),
17
+ until: z
18
+ .string()
19
+ .optional()
20
+ .describe("End at a time of day, e.g. '17:00' (the next such moment — tomorrow if today's has passed), or an ISO-8601 timestamp like '2026-08-08T17:00:00Z'"),
21
+ }, async ({ on, minutes, until }) => text(await call("/active", { method: "POST", body: { on, minutes, until } })));
22
+ }
@@ -1,9 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { call, text } from "../api.js";
3
- const pointSchema = z.object({
4
- x: z.number().min(0).max(1),
5
- y: z.number().min(0).max(1),
6
- });
3
+ import { pointSchema } from "../schemas.js";
7
4
  export function register(server) {
8
5
  server.tool("annotate_screenshot", "Draw on a screenshot you already took, then copy it to the clipboard and show it to the user. Call take_screenshot first and LOOK at the image: you cannot know where anything is until you have seen it. Points are fractions 0..1 of the image, origin TOP-LEFT, so a rectangle around a left sidebar that is a seventh of the width and starts under the title bar is [{x:0,y:0.05},{x:0.14,y:1}]. Rectangle and ellipse take two opposite corners, arrow takes start then tip, pen and highlight take a run of points. Returns the marked image so you can check what you drew.", {
9
6
  path: z.string().describe("Path returned by take_screenshot"),
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ import { call, INTERACTIVE_TIMEOUT_MS, text } from "../api.js";
3
+ import { pointSchema } from "../schemas.js";
4
+ export function register(server) {
5
+ server.tool("measure_screen", "Measure the screen in points and pixels, without taking a picture of it. Plonk photographs the screen once and walks out from the given point in all four directions until one pixel is unlike the one before it, which is where an edge is. What comes back is how far the point could travel each way: the run across and the run down. " +
6
+ "Prefer this over take_screenshot whenever the answer is a number: how wide that sidebar is, how tall that row is, how big the gap between two things is, is that tap target 44 points. An image costs far more tokens and still has to be eyeballed. Use extract_text when the answer is words, and take_screenshot when it is 'what does this look like'. " +
7
+ "Read the result honestly: these are two independent runs through one point, not the outline of an element. Inside a plain rectangle they are its width and height; inside a gap they are the gap; on a large flat background they run until something else is in the way, which may be most of the screen. When the exact bounds of a specific element matter, pass 'interactive' and let the user point at it. " +
8
+ "Three ways to ask. Pass 'point' for the runs through one place. Pass 'from' and 'to' for the straight-line distance between two places, which needs no capture at all. Pass 'interactive' to hand the user the ruler and wait up to five minutes while they measure it themselves. " +
9
+ "Points are fractions 0..1 of the screen's visible area with origin at TOP-LEFT, the same space apply_layout and save_zone_set use, so {x:0.5,y:0.5} is the middle of the screen. " +
10
+ "Returns 'points' {x,y,w,h} in screen points (absolute, origin top-left of the primary display) where w is the run across and h the run down, 'pixels' {w,h} in the display's own pixels — twice the points on a Retina screen, which is the difference that matters when checking an asset — 'fraction' {x,y,w,h} of that screen's visible area ready to hand to apply_layout, 'scale', and 'text', the same line Plonk shows the user. A distance also carries 'distance' and 'distance_pixels'. " +
11
+ "Needs macOS Screen Recording permission, the same as a screenshot; without it the call fails rather than guessing. What is measured is a still taken when the call started, so a screen that is animating measures as it was at that moment.", {
12
+ point: pointSchema
13
+ .optional()
14
+ .describe("Where to measure from, as fractions 0..1 of the screen's visible area, origin TOP-LEFT. {x:0.5,y:0.5} is the middle of the screen"),
15
+ from: pointSchema.optional().describe("One end of a distance, in the same fractions as 'point'"),
16
+ to: pointSchema.optional().describe("The other end of a distance, in the same fractions as 'point'"),
17
+ screen: z
18
+ .number()
19
+ .int()
20
+ .optional()
21
+ .describe("Monitor index from get_state (0 = primary, the default). Every point is a fraction of this screen"),
22
+ tolerance: z
23
+ .number()
24
+ .int()
25
+ .min(1)
26
+ .max(80)
27
+ .optional()
28
+ .describe("How different one pixel must be from the pixel beside it, on a scale of 255, to count as an edge. Omit to use the user's setting (10 by default). Lower stops at fainter borders and finds smaller things; raise it for a photograph or video, where every pixel differs a little from the last"),
29
+ interactive: z
30
+ .boolean()
31
+ .optional()
32
+ .describe("Hand the user the ruler instead of measuring a given point: they hover, drag, click to copy and press Escape, and the last measurement comes back. The one to use when it is their screen and their judgement of what to measure. Waits up to five minutes"),
33
+ }, async ({ point, from, to, screen, tolerance, interactive }) => text(await call("/ruler/measure", {
34
+ method: "POST",
35
+ body: { point, from, to, screen, tolerance, interactive },
36
+ timeoutMs: interactive ? INTERACTIVE_TIMEOUT_MS : 30_000,
37
+ })));
38
+ }
@@ -1,9 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { z } from "zod";
3
- import { call, text } from "../api.js";
3
+ import { call, INTERACTIVE_TIMEOUT_MS, text } from "../api.js";
4
4
  // The interactive modes hand the user a crosshair and wait for them.
5
5
  const INTERACTIVE_MODES = new Set(["region", "window"]);
6
- const INTERACTIVE_TIMEOUT_MS = 5 * 60_000;
7
6
  // Refuse to inline anything larger; a full retina desktop is easily 10 MB,
8
7
  // which is dead weight in the conversation.
9
8
  const MAX_INLINE_BYTES = 4 << 20;
@@ -44,7 +43,7 @@ export function register(server) {
44
43
  body: { mode: wanted, app, title_contains, annotate, path, clipboard, preview: include_image !== false },
45
44
  timeoutMs: INTERACTIVE_MODES.has(wanted) ? INTERACTIVE_TIMEOUT_MS : undefined,
46
45
  });
47
- const savedPath = "path" in result && typeof result.path === "string" ? result.path : undefined;
46
+ const savedPath = "path" in result ? result.path : undefined;
48
47
  if (!savedPath || include_image === false)
49
48
  return text(result);
50
49
  const preview = "preview_path" in result && typeof result.preview_path === "string"
@@ -1,7 +1,5 @@
1
1
  import { z } from "zod";
2
- import { call, text } from "../api.js";
3
- // The interactive mode hands the user a crosshair and waits for them.
4
- const INTERACTIVE_TIMEOUT_MS = 5 * 60_000;
2
+ import { call, INTERACTIVE_TIMEOUT_MS, text } from "../api.js";
5
3
  export function register(server) {
6
4
  server.tool("extract_text", "Read the words off the screen, or off a saved image, and return them as text. Recognition runs on the Mac itself and nothing is uploaded. " +
7
5
  "Prefer this over take_screenshot whenever the answer is words rather than a picture — an error dialog, a log, a terminal, a table, text baked into an image or a paused video, a PDF page in a viewer that will not let text be selected. It costs a fraction of the tokens an image does and does not depend on reading pixels correctly. " +
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { call, text } from "../api.js";
3
3
  import { workspaceItemsSchema } from "../schemas.js";
4
- /// Launching waits for every app to open a window, which the app gives up on
5
- /// after 20 seconds per app.
4
+ // Launching waits for every app to open a window, which the app gives up on
5
+ // after 20 seconds per app.
6
6
  const LAUNCH_TIMEOUT_MS = 90_000;
7
7
  export function register(server) {
8
8
  server.tool("save_workspace", "Save a workspace: the apps of a desktop setup, where each window goes, and what each app should open. Pass 'items' to describe the arrangement, or omit them to snapshot the windows exactly as they are on screen right now. Saving over an existing name replaces it. Saved workspaces are listed in get_state, with their full contents.", {
@@ -2,11 +2,17 @@ import { z } from "zod";
2
2
  import { call, text } from "../api.js";
3
3
  import { zonesSchema } from "../schemas.js";
4
4
  export function register(server) {
5
- server.tool("save_zone_set", "Create or replace a named zone set used for drag snapping. Zones are rectangles {x,y,w,h} as fractions 0..1 of a screen's visible area, origin TOP-LEFT; each zone must stay inside the screen, but zones may overlap each other (the smallest one under the cursor wins). Pass 'screen' to also assign the set to that monitor so it becomes active immediately. Built-in sets already exist: Halves, Thirds, 60 / 40, Quarters, Priority.", {
5
+ server.tool("save_zone_set", "Create or replace a named zone set used for drag snapping. Zones are rectangles {x,y,w,h} as fractions 0..1 of a screen's visible area, origin TOP-LEFT; each zone must stay inside the screen, but zones may overlap each other (the smallest one under the cursor wins). Pass 'screen' to also assign the set to that monitor so it becomes active immediately. Pass 'gap' to give this set its own spacing around windows in points, or null to make it follow the default gap again; omitting it keeps whatever the set had. Built-in sets already exist: Halves, Thirds, 60 / 40, Quarters, Priority.", {
6
6
  name: z.string().describe("Zone set name, e.g. 'coding'"),
7
7
  zones: zonesSchema,
8
8
  screen: z.number().int().optional().describe("Monitor index to assign this set to (0 = primary)"),
9
- }, async ({ name, zones, screen }) => text(await call("/zones/save", { method: "POST", body: { name, zones, screen } })));
9
+ gap: z
10
+ .number()
11
+ .min(0)
12
+ .nullable()
13
+ .optional()
14
+ .describe("This set's own gap in points; null follows the default gap (get_state.zone_gap); omit to leave unchanged"),
15
+ }, async ({ name, zones, screen, gap }) => text(await call("/zones/save", { method: "POST", body: { name, zones, screen, gap } })));
10
16
  server.tool("assign_zone_set", "Assign a zone set (built-in or saved) to one monitor, so dragging a window there snaps to that set's zones. Each monitor keeps its own assignment; assigning replaces whatever that monitor used before and takes effect on the next drag. Omit 'name' to restore the default set (Halves); pass 'edge' for plain edge snapping instead of zones. Available set names and current per-monitor assignments are in get_state.", {
11
17
  screen: z.number().int().describe("Monitor index (0 = primary)"),
12
18
  name: z.string().optional().describe("Zone set name, or 'edge' for edge snapping; omit for the default set"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plonk-mcp",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "mcpName": "io.github.ostapondo/plonk",
5
5
  "description": "MCP server for Plonk, a macOS window manager: zones you draw yourself, workspaces, keep-awake, screenshots and on-device OCR, as tools an agent can call.",
6
6
  "type": "module",
@@ -44,10 +44,10 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@modelcontextprotocol/sdk": "^1.12.0",
47
- "zod": "^3.24.0"
47
+ "zod": "^4.4.3"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^18.19.0",
51
- "typescript": "^5.5.0"
51
+ "typescript": "^7.0.2"
52
52
  }
53
53
  }