plonk-mcp 0.3.4 → 0.4.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
@@ -1,10 +1,10 @@
1
1
  # plonk-mcp
2
2
 
3
3
  The MCP server for [Plonk](https://github.com/ostapondo/plonk), a macOS menu bar
4
- app that is ten utilities at once, a window manager among them. It lets an agent
5
- work the desk: apply layouts across monitors, save and relaunch workspaces, snap
6
- windows into zones, keep the screen awake, take and annotate screenshots, and
7
- read text off the screen without uploading a pixel.
4
+ app that is eight utilities at once, a window manager among them. It lets an
5
+ agent work the desk: apply layouts across monitors, save and relaunch
6
+ workspaces, snap windows into zones, keep the screen awake, take and annotate
7
+ screenshots, and read text off the screen without uploading a pixel.
8
8
 
9
9
  > browser on the left 60%, terminal top right, notes bottom right
10
10
  >
@@ -54,12 +54,14 @@ config to tell two sessions of the same client apart.
54
54
  | | |
55
55
  | --- | --- |
56
56
  | `get_state` | Monitors, every open window and where it sits, zone sets, saved workspaces, awake status |
57
- | `apply_layout` · `snap_window` | Place windows by fraction of a screen, or drop one into a numbered zone |
57
+ | `apply_layout` · `snap_window` | Place windows by fraction of a screen, or drop one into a zone by number or by name |
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
- | `set_awake` | Keep-awake, optionally time-limited |
61
- | `set_active` | Stay active, so chat apps do not show you as Away |
60
+ | `set_app_rule` · `clear_app_rule` | Where an app's new windows open: a zone, and optionally a monitor |
61
+ | `set_awake` | Keep the Mac awake, optionally time-limited, optionally also holding your chat status at available |
62
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 |
63
+ | `extract_text` | Read the words off the screen, with a box per line in the same coordinates `annotate_screenshot` draws in |
64
+ | `measure_screen` | How far a point can travel each way before it meets an edge, or the distance between two points, in points and pixels |
63
65
  | `select_agent` | Make an agent the active one, optionally the only one allowed to control |
64
66
  | `check_for_update` · `install_update` | Ask GitHub for a newer release and install it |
65
67
 
package/dist/cli.js CHANGED
@@ -8,10 +8,11 @@ import { spawn } from "node:child_process";
8
8
  import { BASE, call, INTERACTIVE_TIMEOUT_MS, processIdentityHolder } from "./api.js";
9
9
  import { options } from "./args.js";
10
10
  import { CLI_NAME } from "./messages.js";
11
+ import { PACKAGE_VERSION } from "./version.js";
11
12
  const USAGE = `plonk — drive the Plonk menu bar app from a shell
12
13
 
13
14
  plonk state [--json] screens, windows, zone sets, workspaces
14
- plonk snap <app> <zone> drop a window into a numbered zone
15
+ plonk snap <app> <zone> drop a window into a zone, by number or name
15
16
  plonk workspaces list saved workspaces
16
17
  plonk launch <name> [--screen N] launch one
17
18
  plonk save <name> save the desktop as one
@@ -80,6 +81,11 @@ async function summarize() {
80
81
  lines.push(`zone sets ${Object.keys(state.zone_sets).sort().join(", ")}`);
81
82
  if (state.excluded_apps?.length)
82
83
  lines.push(`excluded ${state.excluded_apps.join(", ")}`);
84
+ if (state.app_rules?.length) {
85
+ const where = (rule) => rule.screen !== undefined ? ` on screen ${rule.screen}` : rule.screen_uuid ? " on a screen that is not attached" : "";
86
+ const rules = state.app_rules.map((rule) => `${rule.app} -> zone ${rule.zone}${where(rule)}`);
87
+ lines.push(`rules ${rules.join(", ")}`);
88
+ }
83
89
  lines.push(`windows ${state.windows.length}`);
84
90
  for (const window of state.windows) {
85
91
  lines.push(` ${window.app}${window.title ? ` — ${window.title}` : ""} [screen ${window.screen}]`);
@@ -118,6 +124,10 @@ async function awakeWhile(argv) {
118
124
  }
119
125
  async function main() {
120
126
  const argv = process.argv.slice(2);
127
+ if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v")) {
128
+ console.log(PACKAGE_VERSION);
129
+ return;
130
+ }
121
131
  // Everything after `awake while` belongs to the command being run, flags
122
132
  // included, so it is taken verbatim rather than parsed for plonk's own.
123
133
  // Otherwise `plonk awake while cargo build --release` builds in debug.
@@ -142,10 +152,13 @@ async function main() {
142
152
  case "snap": {
143
153
  const [app, zone] = args;
144
154
  if (!app || zone === undefined)
145
- fail("snap needs an app and a zone number, e.g. plonk snap Safari 1");
155
+ fail("snap needs an app and a zone, e.g. plonk snap Safari 1 or plonk snap Slack chat");
156
+ // A whole number travels as one, which is what every app so far
157
+ // expects; anything else is a name, and the app says so if the set
158
+ // has neither.
146
159
  report(await call("/layout/zone", {
147
160
  method: "POST",
148
- body: { app, zone: number(zone, "zone"), screen },
161
+ body: { app, zone: Number.isInteger(Number(zone)) ? Number(zone) : zone, screen },
149
162
  }));
150
163
  }
151
164
  case "workspaces": {
@@ -226,7 +239,7 @@ async function main() {
226
239
  }
227
240
  // Named so the app can attribute the calls, and so "only the active agent
228
241
  // controls" can be pointed at the shell like anything else.
229
- processIdentityHolder().identity = { name: CLI_NAME, version: "", pid: process.pid };
242
+ processIdentityHolder().identity = { name: CLI_NAME, version: PACKAGE_VERSION, pid: process.pid };
230
243
  try {
231
244
  await main();
232
245
  }
package/dist/factory.js CHANGED
@@ -7,7 +7,6 @@ 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";
11
10
  import { register as registerAwake } from "./tools/awake.js";
12
11
  import { register as registerScreenshot } from "./tools/screenshot.js";
13
12
  import { register as registerAnnotate } from "./tools/annotate.js";
@@ -23,7 +22,6 @@ export function createPlonkServer() {
23
22
  registerLayouts(server);
24
23
  registerZones(server);
25
24
  registerAwake(server);
26
- registerActive(server);
27
25
  registerScreenshot(server);
28
26
  registerAnnotate(server);
29
27
  registerText(server);
package/dist/schemas.js CHANGED
@@ -18,7 +18,19 @@ export const itemsSchema = z
18
18
  frame: frameSchema,
19
19
  }))
20
20
  .min(1);
21
- export const zonesSchema = z.array(frameSchema).min(1);
21
+ /** A zone is a frame with, optionally, what it is called. The app decides
22
+ * what a name may be (it cuts one at 24 characters and refuses a bare
23
+ * number), the way it decides the gap; measuring it here would count code
24
+ * units and refuse names the app itself keeps. */
25
+ export const zoneSchema = frameSchema.extend({
26
+ name: z
27
+ .string()
28
+ .trim()
29
+ .min(1)
30
+ .optional()
31
+ .describe("What the zone is called, e.g. 'chat': drawn under its number, spoken to, and accepted by snap_window instead of the number. Unique within the set"),
32
+ });
33
+ export const zonesSchema = z.array(zoneSchema).min(1);
22
34
  export const workspaceItemsSchema = z
23
35
  .array(z.object({
24
36
  app: z.string().describe("App name as it appears in get_state, e.g. 'Safari'"),
@@ -1,11 +1,17 @@
1
1
  import { z } from "zod";
2
2
  import { call, text } from "../api.js";
3
3
  export function register(server) {
4
- server.tool("set_awake", "Turn keep-awake on or off, so the Mac does not sleep part-way through something. " +
5
- "Three ways to end the session, in order of preference: 'pid' ends it the moment that process exits best by far when something is running, because a build or a render knows when it is finished and nothing is left holding the machine awake afterwards; 'until' ends it at a wall-clock time ('17:00', or an ISO-8601 timestamp); 'minutes' ends it after a countdown. Give none of them and it runs until switched off. " +
6
- "Behavior also follows the user's settings: keep-awake may pause on battery or engage automatically while charging, so the returned 'status' is what actually happened and 'awake' is whether an assertion is held right now. The menu bar icon glows while it is. " +
4
+ server.tool("set_awake", "Keep the Mac from sleeping, and optionally keep the user shown as available in chat apps. One session with two levels, called Pulse in the app. " +
5
+ "'available' picks the level. Left off, Plonk holds a power assertion: the Mac does not sleep, but Slack and Teams still slide the user to Away, because those read the system idle timer and an assertion does not touch it. Turned on, Plonk also resets that idle timer with a Shift keypress every two minutes, which is what keeps a chat status green — and which postpones sleep by itself, so being available always includes being awake. Use it whenever the point is how the user looks to other people rather than whether a job finishes. " +
6
+ "Three ways to end the session, in order of preference: 'pid' ends it the moment that process exits best by far when something is running, because a build or a render knows when it is finished and nothing is left holding the machine awake afterwards; 'until' ends it at a wall-clock time ('17:00', or an ISO-8601 timestamp); 'minutes' ends it after a countdown. Give none of them and it runs until switched off, or until the user's configured default timeout expires. " +
7
+ "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. Sessions Plonk starts on its own — a recurring schedule of hours and weekdays, a list of apps whose being open arms it, or the charger being plugged in — are settings on the Pulse page rather than parameters here; get_state reports all of them under 'awake_details'. " +
8
+ "Behavior also follows those settings: a session may pause on battery, so the returned 'status' is what actually happened, 'awake' is whether an assertion is held right now, and 'available' is whether a keypress is actually being posted. Those differ from what was asked for when Plonk has no Accessibility permission (nothing can be posted, though the Mac still stays awake) or when the user disallowed running on battery and the Mac is unplugged; neither is an error, since the request was understood. The menu bar cube glows while a session holds. " +
7
9
  "A process-bound session is deliberately not restored if Plonk restarts, since the pid would mean nothing by then. Errors come back for a pid that is not running or a time that has already passed.", {
8
10
  on: z.boolean(),
11
+ available: z
12
+ .boolean()
13
+ .optional()
14
+ .describe("Also reset the idle timer, so chat apps go on showing the user as available instead of Away. This is the user's stored level rather than a property of one session: later sessions run at it too, until it is changed again. Omit to leave it as the user set it"),
9
15
  minutes: z.number().int().min(1).optional().describe("End the session after this many minutes"),
10
16
  until: z
11
17
  .string()
@@ -17,5 +23,5 @@ export function register(server) {
17
23
  .min(1)
18
24
  .optional()
19
25
  .describe("End when this process exits. Use the pid of the long job being waited on; get_state lists a pid for every open window"),
20
- }, async ({ on, minutes, until, pid }) => text(await call("/awake", { method: "POST", body: { on, minutes, until, pid } })));
26
+ }, async ({ on, available, minutes, until, pid }) => text(await call("/awake", { method: "POST", body: { on, available, minutes, until, pid } })));
21
27
  }
@@ -11,9 +11,11 @@ export function register(server) {
11
11
  items: itemsSchema.optional(),
12
12
  }, async ({ name, items }) => text(await call("/workspaces/save", { method: "POST", body: { name, items } })));
13
13
  server.tool("apply_saved_layout", "Launch a saved workspace by name. Legacy name kept for older clients — new integrations should call launch_workspace, which adds a 'screen' option to pull the whole workspace onto one monitor. Opens every app that is not running, waits for its windows, and moves them into the saved positions; macOS cannot open an app straight into a position, so windows appear first and jump into place. Returns per-app success and reports apps that never opened a window. Takes up to a minute for a large workspace.", { name: z.string() }, async ({ name }) => text(await call("/workspaces/launch", { method: "POST", body: { name }, timeoutMs: 90_000 })));
14
- server.tool("snap_window", "Drop one window into a numbered zone of the snap-zone set assigned to that monitor. The numbers are the ones Plonk draws on the zones while a window is dragged, so 'the middle zone' of a three-zone set is 2. Zone sets and their per-monitor assignment are in get_state; use apply_layout instead when the user describes a size rather than a zone.", {
14
+ server.tool("snap_window", "Drop one window into a zone of the snap-zone set assigned to that monitor, by number or by name. The numbers are the ones Plonk draws on the zones while a window is dragged, so 'the middle zone' of a three-zone set is 2; a name is whatever the set calls a zone ('chat'), listed per zone in get_state.zone_sets, matched ignoring case. Fails with the zones that screen does have, names included, when neither matches. Zone sets and their per-monitor assignment are in get_state; use apply_layout instead when the user describes a size rather than a zone.", {
15
15
  app: z.string().describe("App name to match, e.g. 'Visual Studio Code'"),
16
- zone: z.number().int().min(1).describe("1-based zone number, as shown on the drag overlay"),
16
+ zone: z
17
+ .union([z.number().int().min(1), z.string().min(1)])
18
+ .describe("1-based zone number as shown on the drag overlay, or the zone's name from get_state.zone_sets"),
17
19
  title: z.string().optional().describe("Only windows whose title contains this substring"),
18
20
  screen: z.number().int().optional().describe("Monitor index; defaults to the one the window is on"),
19
21
  }, async (args) => text(await call("/layout/zone", { method: "POST", body: args })));
@@ -1,4 +1,4 @@
1
1
  import { call, text } from "../api.js";
2
2
  export function register(server) {
3
- server.tool("get_state", "Get the current desktop state: all screens/monitors (index, frame, visible area — coordinates have origin at top-left of the primary screen, y grows down), all open windows (app name, title, which screen it is on, absolute frame, and 'fraction' — its position as fractions 0..1 of that screen's visible area), saved layout names, whether keep-awake is on, and 'disabled_features': the modules the user switched off in Plonk (zones, workspaces, shot, ruler, awake, active and so on). A tool belonging to one of those fails with an error saying so until the user switches it back on. ALWAYS call this first before applying a layout, to see which apps are running and how many monitors there are.", {}, async () => text(await call("/state")));
3
+ server.tool("get_state", "Get the current desktop state: all screens/monitors (index, frame, visible area — coordinates have origin at top-left of the primary screen, y grows down), all open windows (app name, title, which screen it is on, absolute frame, and 'fraction' — its position as fractions 0..1 of that screen's visible area), 'zone_sets' (every set by name, each zone as {x,y,w,h} with a 'name' where the set gives it one), 'screen_zone_sets' (which set each monitor wears), saved layout names, 'app_rules' (where each app's new windows open, see set_app_rule), 'place_new_windows' and 'auto_fill_zones', whether a keep-awake session is holding, and 'disabled_features': the modules the user switched off in Plonk (zones, workspaces, shot, ruler, awake and so on). A tool belonging to one of those fails with an error saying so until the user switches it back on. ALWAYS call this first before applying a layout, to see which apps are running and how many monitors there are.", {}, async () => text(await call("/state")));
4
4
  }
@@ -2,7 +2,7 @@ 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. 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.", {
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). A zone may also carry a 'name' ('chat', 'editor'): it is drawn under the zone's number, the user can say it out loud, and snap_window takes it instead of the number; names must be unique within the set, ignoring case, and cannot be a bare number. 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)"),
@@ -18,4 +18,10 @@ export function register(server) {
18
18
  name: z.string().optional().describe("Zone set name, or 'edge' for edge snapping; omit for the default set"),
19
19
  }, async ({ screen, name }) => text(await call("/zones/assign", { method: "POST", body: { screen, name } })));
20
20
  server.tool("delete_zone_set", "Delete a saved zone set by name. Any monitor currently using it falls back to the default set (Halves), so snapping keeps working. Only sets made with save_zone_set can go: the built-ins (Halves, Thirds, 60 / 40, Quarters, Priority) are refused. Deleting is immediate and cannot be undone — the zones would have to be described again. Saved sets and their per-monitor assignments are listed in get_state; use assign_zone_set instead when a monitor should merely stop using a set that others still need.", { name: z.string().describe("Saved zone set name, as shown in get_state") }, async ({ name }) => text(await call("/zones/delete", { method: "POST", body: { name } })));
21
+ server.tool("set_app_rule", "Make an app's windows open into a numbered zone from now on, so an arrangement holds without anyone dragging: 'Slack always in zone 1 on the second monitor'. 'app' is matched anywhere in the app's name or bundle id, case-insensitively, the way get_state.excluded_apps entries are; a bundle id such as 'com.tinyspeck.slackmacgap' is the safest form. 'zone' is the number Plonk draws on the zone (1-based) in the set assigned to that monitor. 'screen' is a monitor index from get_state and is stored as that display's identity, so it survives a reboot renumbering the screens; omit it and the window stays on whichever screen it opened on. One rule per app: setting it again replaces the old one; a rule that names the app exactly wins over a bare-word rule. Applies to ordinary windows that open after it is set, not to windows already open (use snap_window for those) and not to dialogs or panels. An app on get_state.excluded_apps is left alone even with a rule. A rule beats the habit Plonk keeps of where an app's last window went, and both beat filling an empty zone. Current rules are get_state.app_rules. Fails when 'screen' names a monitor that is not attached or a zone that monitor's set does not have.", {
22
+ app: z.string().describe("App name or bundle id to match, e.g. 'com.apple.Safari' or 'Safari'"),
23
+ zone: z.number().int().min(1).describe("1-based zone number, as shown on the drag overlay"),
24
+ screen: z.number().int().optional().describe("Monitor index from get_state (0 = primary); omit for the screen the window opens on"),
25
+ }, async ({ app, zone, screen }) => text(await call("/zones/rules", { method: "POST", body: { app, zone, screen } })));
26
+ server.tool("clear_app_rule", "Remove the rule set for an app with set_app_rule, so its new windows open wherever the app puts them again, or where its last window went when that habit is switched on in Plonk. 'app' is the pattern exactly as get_state.app_rules lists it, ignoring case. Fails when no rule matches; nothing else changes.", { app: z.string().describe("The app pattern as listed in get_state.app_rules") }, async ({ app }) => text(await call("/zones/rules/delete", { method: "POST", body: { app } })));
21
27
  }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { readFileSync } from "node:fs";
2
+ export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plonk-mcp",
3
- "version": "0.3.4",
3
+ "version": "0.4.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",
@@ -1,22 +0,0 @@
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
- }