plonk-mcp 0.2.4 → 0.3.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.
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/cli.js CHANGED
@@ -18,6 +18,7 @@ const USAGE = `plonk — drive the Plonk menu bar app from a shell
18
18
  plonk awake off
19
19
  plonk awake on [--minutes N] [--until HH:MM] [--pid N]
20
20
  plonk awake while <command...> stay awake until that command exits
21
+ plonk measure [X Y] [--screen N] [--tolerance N] measure at that point
21
22
  plonk text [--mode region|window|screen] [--path FILE]
22
23
  plonk shot [--mode region|window|screen] [--path FILE]
23
24
 
@@ -44,6 +45,15 @@ function number(raw, what) {
44
45
  fail(`${what} must be a whole number, got "${raw}"`);
45
46
  return value;
46
47
  }
48
+ /** Points arrive as fractions of a screen, the way every frame in this API
49
+ * does, so a measurement can be pasted straight back into a layout. */
50
+ function fraction(raw, what) {
51
+ const value = Number(raw);
52
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
53
+ fail(`${what} must be a fraction between 0 and 1, got "${raw}"`);
54
+ }
55
+ return value;
56
+ }
47
57
  /** Prints the reply and exits non-zero when the app refused. */
48
58
  function report(result) {
49
59
  if ("error" in result) {
@@ -181,6 +191,24 @@ async function main() {
181
191
  }));
182
192
  break;
183
193
  }
194
+ case "measure": {
195
+ // With no point named there is nobody to ask but the user, so the ruler
196
+ // goes on screen and this waits for them.
197
+ const [x, y] = args;
198
+ const interactive = x === undefined || y === undefined;
199
+ report(await call("/ruler/measure", {
200
+ method: "POST",
201
+ body: interactive
202
+ ? { interactive: true }
203
+ : {
204
+ screen,
205
+ point: { x: fraction(x, "x"), y: fraction(y, "y") },
206
+ tolerance: number(flags.tolerance, "--tolerance"),
207
+ },
208
+ timeoutMs: interactive ? 5 * 60_000 : 30_000,
209
+ }));
210
+ break;
211
+ }
184
212
  case "text": {
185
213
  const result = await call("/shot/text", {
186
214
  method: "POST",
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;
@@ -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
+ }
@@ -0,0 +1,43 @@
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;
5
+ const pointSchema = z.object({
6
+ x: z.number().min(0).max(1),
7
+ y: z.number().min(0).max(1),
8
+ });
9
+ export function register(server) {
10
+ 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. " +
11
+ "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'. " +
12
+ "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. " +
13
+ "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. " +
14
+ "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. " +
15
+ "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'. " +
16
+ "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.", {
17
+ point: pointSchema
18
+ .optional()
19
+ .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"),
20
+ from: pointSchema.optional().describe("One end of a distance, in the same fractions as 'point'"),
21
+ to: pointSchema.optional().describe("The other end of a distance, in the same fractions as 'point'"),
22
+ screen: z
23
+ .number()
24
+ .int()
25
+ .optional()
26
+ .describe("Monitor index from get_state (0 = primary, the default). Every point is a fraction of this screen"),
27
+ tolerance: z
28
+ .number()
29
+ .int()
30
+ .min(1)
31
+ .max(80)
32
+ .optional()
33
+ .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"),
34
+ interactive: z
35
+ .boolean()
36
+ .optional()
37
+ .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"),
38
+ }, async ({ point, from, to, screen, tolerance, interactive }) => text(await call("/ruler/measure", {
39
+ method: "POST",
40
+ body: { point, from, to, screen, tolerance, interactive },
41
+ timeoutMs: interactive ? INTERACTIVE_TIMEOUT_MS : 30_000,
42
+ })));
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plonk-mcp",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
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
  }