@penpair/core 0.4.0 → 0.4.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.
@@ -51,7 +51,7 @@ export interface Adapter {
51
51
  /** Declares an adapter. Returns the definition unchanged, so a module can `export default defineAdapter({...})` with types checked. */
52
52
  export declare const defineAdapter: <T extends Adapter>(adapter: T) => T;
53
53
  /** The commands of the core itself; an adapter cannot take one of these names. */
54
- export declare const reservedCommands: readonly ["journey-map", "integration-map", "design-release", "formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect", "up", "down", "open", "status", "doctor", "backup", "restore", "baselines", "config", "runs", "onboard", "help"];
54
+ export declare const reservedCommands: readonly ["journey-map", "integration-map", "design-release", "formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect", "up", "down", "open", "status", "doctor", "wake", "backup", "restore", "baselines", "config", "runs", "onboard", "help"];
55
55
  export interface LoadedAdapters {
56
56
  adapters: Adapter[];
57
57
  problems: string[];
package/dist/adapters.js CHANGED
@@ -38,6 +38,7 @@ export const reservedCommands = [
38
38
  "open",
39
39
  "status",
40
40
  "doctor",
41
+ "wake",
41
42
  "backup",
42
43
  "restore",
43
44
  "baselines",
@@ -0,0 +1,22 @@
1
+ export interface BrowserTargetOptions {
2
+ origin: string;
3
+ fileId: string;
4
+ port: number;
5
+ timeoutMs?: number;
6
+ allowLoopbackAlias?: boolean;
7
+ signal?: AbortSignal;
8
+ fetch?: typeof fetch;
9
+ }
10
+ export interface BrowserWakeResult {
11
+ activated: true;
12
+ targetId: string;
13
+ keepAlive: "none";
14
+ }
15
+ export interface BrowserInspectionResult {
16
+ targetId: string;
17
+ keepAlive: "unknown";
18
+ }
19
+ /** Activate only the existing page for the pinned file; never reload or evaluate page code. */
20
+ export declare function wakeBrowserTarget(options: BrowserTargetOptions): Promise<BrowserWakeResult>;
21
+ /** Read debugging metadata only; a listed tab does not establish plugin readiness or keep-alive. */
22
+ export declare function inspectBrowserTarget(options: BrowserTargetOptions): Promise<BrowserInspectionResult>;
@@ -0,0 +1,241 @@
1
+ const maxTargetBytes = 1024 * 1024;
2
+ const maxTargetCount = 1000;
3
+ /** Activate only the existing page for the pinned file; never reload or evaluate page code. */
4
+ export async function wakeBrowserTarget(options) {
5
+ return withBrowserDeadline(options, async (request) => {
6
+ const targetId = await findBrowserTarget(request);
7
+ const response = await browserRequest(request, `/json/activate/${encodeURIComponent(targetId)}`);
8
+ void response.body?.cancel().catch(() => undefined);
9
+ request.signal.throwIfAborted();
10
+ return {
11
+ activated: true,
12
+ targetId,
13
+ keepAlive: "none",
14
+ };
15
+ });
16
+ }
17
+ /** Read debugging metadata only; a listed tab does not establish plugin readiness or keep-alive. */
18
+ export async function inspectBrowserTarget(options) {
19
+ return withBrowserDeadline(options, async (request) => ({
20
+ targetId: await findBrowserTarget(request),
21
+ keepAlive: "unknown",
22
+ }));
23
+ }
24
+ async function withBrowserDeadline(options, work) {
25
+ const origin = validateBrowserOptions(options);
26
+ if (options.signal?.aborted) {
27
+ throw new Error("Browser debugging cancelled");
28
+ }
29
+ const controller = new AbortController();
30
+ const timeoutMs = options.timeoutMs ?? 5000;
31
+ let timedOut = false;
32
+ const externalAbort = () => controller.abort();
33
+ const timer = setTimeout(() => {
34
+ timedOut = true;
35
+ controller.abort();
36
+ }, timeoutMs);
37
+ options.signal?.addEventListener("abort", externalAbort, {
38
+ once: true,
39
+ });
40
+ let abort = () => undefined;
41
+ const interrupted = new Promise((_resolve, reject) => {
42
+ abort = () => reject(new Error(timedOut ? "Browser debugging deadline exceeded" : "Browser debugging cancelled"));
43
+ controller.signal.addEventListener("abort", abort, {
44
+ once: true,
45
+ });
46
+ });
47
+ try {
48
+ if (options.signal?.aborted) {
49
+ controller.abort();
50
+ }
51
+ controller.signal.throwIfAborted();
52
+ return await Promise.race([
53
+ interrupted,
54
+ work({
55
+ origin,
56
+ fileId: options.fileId,
57
+ allowLoopbackAlias: options.allowLoopbackAlias === true,
58
+ endpoint: `http://127.0.0.1:${options.port}`,
59
+ fetch: options.fetch ?? fetch,
60
+ signal: controller.signal,
61
+ }),
62
+ ]);
63
+ }
64
+ catch (error) {
65
+ if (controller.signal.aborted) {
66
+ throw new Error(timedOut ? "Browser debugging deadline exceeded" : "Browser debugging cancelled");
67
+ }
68
+ throw error;
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ options.signal?.removeEventListener("abort", externalAbort);
73
+ controller.signal.removeEventListener("abort", abort);
74
+ }
75
+ }
76
+ function validateBrowserOptions(options) {
77
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) {
78
+ throw new Error("Browser debugging port must be an integer from 1 to 65535");
79
+ }
80
+ const timeoutMs = options.timeoutMs ?? 5000;
81
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000) {
82
+ throw new Error("Browser debugging timeout must be an integer from 1000 to 30000 ms");
83
+ }
84
+ if (typeof options.fileId !== "string" ||
85
+ !options.fileId.trim() ||
86
+ options.fileId.length > 256 ||
87
+ /[\u0000-\u0020\u007f]/.test(options.fileId)) {
88
+ throw new Error("Browser activation requires a pinned file ID");
89
+ }
90
+ let origin;
91
+ try {
92
+ origin = new URL(options.origin);
93
+ }
94
+ catch {
95
+ throw new Error("Browser activation requires a valid Penpot origin");
96
+ }
97
+ if (![
98
+ "http:",
99
+ "https:",
100
+ ].includes(origin.protocol) ||
101
+ origin.username ||
102
+ origin.password ||
103
+ origin.pathname !== "/" ||
104
+ origin.search ||
105
+ origin.hash) {
106
+ throw new Error("Browser activation requires a Penpot origin without credentials, path or query");
107
+ }
108
+ if (options.allowLoopbackAlias && !isHttpLoopback(origin)) {
109
+ throw new Error("Browser loopback aliases require an HTTP localhost or 127.0.0.1 origin");
110
+ }
111
+ return origin.origin;
112
+ }
113
+ async function browserRequest(request, path) {
114
+ request.signal.throwIfAborted();
115
+ let response;
116
+ try {
117
+ response = await request.fetch(`${request.endpoint}${path}`, {
118
+ method: "GET",
119
+ redirect: "error",
120
+ signal: request.signal,
121
+ });
122
+ }
123
+ catch {
124
+ throw new Error("Cannot reach the local browser debugging endpoint; start a dedicated browser with a loopback debugging port");
125
+ }
126
+ request.signal.throwIfAborted();
127
+ if (!response.ok || response.redirected) {
128
+ void response.body?.cancel().catch(() => undefined);
129
+ throw new Error("The local browser debugging endpoint refused the request");
130
+ }
131
+ return response;
132
+ }
133
+ async function findBrowserTarget(request) {
134
+ const response = await browserRequest(request, "/json/list");
135
+ const text = await readTargetList(response);
136
+ request.signal.throwIfAborted();
137
+ let targets;
138
+ try {
139
+ targets = JSON.parse(text);
140
+ }
141
+ catch {
142
+ throw new Error("Invalid browser debugging target list");
143
+ }
144
+ if (!Array.isArray(targets) ||
145
+ targets.length > maxTargetCount ||
146
+ !targets.every(isBrowserTarget)) {
147
+ throw new Error("Invalid browser debugging target list");
148
+ }
149
+ const matching = targets.filter((target) => target.type === "page" && matchesFile(target.url, request));
150
+ if (matching.length !== 1) {
151
+ throw new Error(matching.length === 0
152
+ ? "No browser page matches the project's Penpot origin and pinned file ID"
153
+ : "Multiple browser pages match the pinned file; close duplicate tabs before waking");
154
+ }
155
+ const targetId = matching[0].id;
156
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(targetId)) {
157
+ throw new Error("Invalid browser debugging target ID");
158
+ }
159
+ return targetId;
160
+ }
161
+ function isBrowserTarget(value) {
162
+ if (!value || typeof value !== "object") {
163
+ return false;
164
+ }
165
+ const target = value;
166
+ return (typeof target.id === "string" &&
167
+ typeof target.type === "string" &&
168
+ typeof target.url === "string");
169
+ }
170
+ function isHttpLoopback(url) {
171
+ return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
172
+ }
173
+ function matchesOrigin(target, request) {
174
+ if (target.origin === request.origin) {
175
+ return true;
176
+ }
177
+ if (!request.allowLoopbackAlias || !isHttpLoopback(target)) {
178
+ return false;
179
+ }
180
+ const expected = new URL(request.origin);
181
+ return isHttpLoopback(expected) && target.port === expected.port;
182
+ }
183
+ function matchesFile(targetUrl, request) {
184
+ let url;
185
+ try {
186
+ url = new URL(targetUrl);
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ if (!matchesOrigin(url, request) || url.username || url.password) {
192
+ return false;
193
+ }
194
+ const hashRoute = url.hash.slice(1);
195
+ const hashQuery = hashRoute.indexOf("?");
196
+ const parameters = hashQuery >= 0 && hashRoute.slice(0, hashQuery) === "/workspace"
197
+ ? new URLSearchParams(hashRoute.slice(hashQuery + 1))
198
+ : url.pathname === "/workspace" && !url.hash
199
+ ? url.searchParams
200
+ : undefined;
201
+ const fileIds = parameters?.getAll("file-id");
202
+ return fileIds?.length === 1 && fileIds[0] === request.fileId;
203
+ }
204
+ async function readTargetList(response) {
205
+ const declaredBytes = Number(response.headers.get("content-length"));
206
+ if (declaredBytes > maxTargetBytes) {
207
+ void response.body?.cancel().catch(() => undefined);
208
+ throw new Error("Browser debugging target list exceeds the size limit");
209
+ }
210
+ if (!response.body) {
211
+ throw new Error("Invalid browser debugging target list");
212
+ }
213
+ const reader = response.body.getReader();
214
+ const chunks = [];
215
+ let bytes = 0;
216
+ try {
217
+ while (true) {
218
+ const next = await reader.read();
219
+ if (next.done) {
220
+ break;
221
+ }
222
+ bytes += next.value.byteLength;
223
+ if (bytes > maxTargetBytes) {
224
+ throw new Error("Browser debugging target list exceeds the size limit");
225
+ }
226
+ chunks.push(next.value);
227
+ }
228
+ }
229
+ catch (error) {
230
+ void reader.cancel().catch(() => undefined);
231
+ if (error instanceof Error &&
232
+ error.message === "Browser debugging target list exceeds the size limit") {
233
+ throw error;
234
+ }
235
+ throw new Error("Cannot read browser debugging target list");
236
+ }
237
+ finally {
238
+ reader.releaseLock();
239
+ }
240
+ return Buffer.concat(chunks, bytes).toString("utf8");
241
+ }
package/dist/cli-main.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { auditDesignRules } from "./audit-rules.js";
2
2
  import { projectStatus, projectFileUrl, openProject } from "./project-status.js";
3
+ import { wakeBrowserTarget } from "./browser-wake.js";
4
+ import { pluginHealthFromError } from "./plugin-health.js";
3
5
  import { artifactSchemas, migrateProjectConfig } from "./formats.js";
4
6
  import { importDtcg, exportDtcg } from "./portable-tokens.js";
5
7
  import { captureStructuralSnapshot, writeStructuralSnapshot, readStructuralSnapshot, diffStructuralSnapshots, renderStructuralDiff, } from "./structure-snapshot.js";
@@ -44,7 +46,7 @@ import { askHidden, askLine, openInBrowser } from "./instance/tty.js";
44
46
  import { validateSkillAgent } from "./skill-hosts.js";
45
47
  import { loadAdapters, readRawConfig, reservedCommands, } from "./adapters.js";
46
48
  import { requireLocalProfile } from "./local-profile.js";
47
- import { assertEndpointIsInstance, doctorExternal, LifecycleError, } from "./instance/connection.js";
49
+ import { assertEndpointIsInstance, doctorExternal, doctorEditor, frontendUrl, LifecycleError, } from "./instance/connection.js";
48
50
  import { nodeRunner } from "./instance/runner.js";
49
51
  import { version } from "./version.js";
50
52
  import { guardFile } from "./file-guard.js";
@@ -71,6 +73,7 @@ Connect / diagnose:
71
73
  penpair config check [--json] validate configuration offline
72
74
  penpair config schema print its JSON Schema
73
75
  penpair doctor [--json] [--timeout-ms 5000] origin, transport, fresh editor read and file pin (1000–30000 ms)
76
+ penpair wake --debug-port 9222 [--json] activate the pinned browser tab, then verify the editor; no reload or replay
74
77
  penpair status [--adapters] observed project health and optional companion readiness
75
78
  penpair open [--print] open the pinned public file URL, or print it
76
79
  penpair probe [--integrity] observed APIs and limits
@@ -84,7 +87,7 @@ Build:
84
87
  penpair snippets / snippets check list or validate configured trusted scripts
85
88
  penpair tokens add-set|set|remove|promote inspect token edits; --apply writes once
86
89
  penpair archive --page PAGE --name Archive --keep PREFIX [--apply]
87
- (run-file/export/shots accept --layout auto|native|geometry; default auto)
90
+ (run-file/run/archive/token edits/export/shots accept --layout auto|native|geometry, repeatable --layout-root ID, --layout-max-nodes 1..100000)
88
91
  (journaled writes accept --resume RUN_ID --apply, --checkpoint and --trace)
89
92
  Inspect / spec:
90
93
  penpair pages file identity, revision and pages
@@ -235,9 +238,19 @@ async function main(argv, cli) {
235
238
  "timeout-ms": {
236
239
  type: "string",
237
240
  },
241
+ "debug-port": {
242
+ type: "string",
243
+ },
238
244
  layout: {
239
245
  type: "string",
240
246
  },
247
+ "layout-root": {
248
+ type: "string",
249
+ multiple: true,
250
+ },
251
+ "layout-max-nodes": {
252
+ type: "string",
253
+ },
241
254
  page: {
242
255
  type: "string",
243
256
  },
@@ -432,6 +445,11 @@ async function main(argv, cli) {
432
445
  "max-total-bytes",
433
446
  "capture-timeout-ms",
434
447
  ];
448
+ const layoutFlags = [
449
+ "layout",
450
+ "layout-root",
451
+ "layout-max-nodes",
452
+ ];
435
453
  const flags = {
436
454
  formats: [
437
455
  "out",
@@ -486,6 +504,7 @@ async function main(argv, cli) {
486
504
  "theme",
487
505
  ]
488
506
  : [
507
+ ...layoutFlags,
489
508
  "type",
490
509
  "value",
491
510
  "description",
@@ -499,16 +518,17 @@ async function main(argv, cli) {
499
518
  "page",
500
519
  "scale",
501
520
  "name",
502
- "layout",
521
+ ...layoutFlags,
503
522
  ],
504
523
  shots: [
505
524
  "page",
506
525
  "prefix",
507
526
  "out",
508
527
  "scale",
509
- "layout",
528
+ ...layoutFlags,
510
529
  ],
511
530
  archive: [
531
+ ...layoutFlags,
512
532
  "page",
513
533
  "name",
514
534
  "keep",
@@ -527,7 +547,7 @@ async function main(argv, cli) {
527
547
  "include",
528
548
  ],
529
549
  "run-file": [
530
- "layout",
550
+ ...layoutFlags,
531
551
  "include",
532
552
  "inspect",
533
553
  "readback",
@@ -540,6 +560,7 @@ async function main(argv, cli) {
540
560
  "trace",
541
561
  ],
542
562
  run: [
563
+ ...layoutFlags,
543
564
  "builtins-only",
544
565
  "param",
545
566
  "apply",
@@ -594,6 +615,10 @@ async function main(argv, cli) {
594
615
  doctor: [
595
616
  "timeout-ms",
596
617
  ],
618
+ wake: [
619
+ "debug-port",
620
+ "timeout-ms",
621
+ ],
597
622
  up: [],
598
623
  down: [],
599
624
  open: [
@@ -710,12 +735,31 @@ async function main(argv, cli) {
710
735
  ].includes(values.layout)) {
711
736
  throw new CommandArgumentError("--layout must be auto, native or geometry");
712
737
  }
713
- if (command === "doctor" &&
738
+ if (values["layout-max-nodes"] !== undefined &&
739
+ (!/^\d+$/.test(values["layout-max-nodes"]) ||
740
+ Number(values["layout-max-nodes"]) < 1 ||
741
+ Number(values["layout-max-nodes"]) > 100000)) {
742
+ throw new CommandArgumentError("--layout-max-nodes must be an integer from 1 to 100000");
743
+ }
744
+ if (values["layout-root"] &&
745
+ (values["layout-root"].some((id) => !id.trim()) ||
746
+ new Set(values["layout-root"]).size !== values["layout-root"].length)) {
747
+ throw new CommandArgumentError("--layout-root must name distinct nonempty shape IDs");
748
+ }
749
+ if ((command === "doctor" || command === "wake") &&
714
750
  values["timeout-ms"] !== undefined &&
715
751
  (!/^\d+$/.test(values["timeout-ms"]) ||
716
752
  Number(values["timeout-ms"]) < 1000 ||
717
753
  Number(values["timeout-ms"]) > 30000)) {
718
- throw new CommandArgumentError("doctor --timeout-ms must be an integer from 1000 to 30000");
754
+ throw new CommandArgumentError(`${command} --timeout-ms must be an integer from 1000 to 30000`);
755
+ }
756
+ if (command === "wake" &&
757
+ (rest.length ||
758
+ !values["debug-port"] ||
759
+ !/^\d+$/.test(values["debug-port"]) ||
760
+ Number(values["debug-port"]) < 1 ||
761
+ Number(values["debug-port"]) > 65535)) {
762
+ throw new CommandArgumentError("wake requires --debug-port with an integer from 1 to 65535");
719
763
  }
720
764
  let tokenEdit;
721
765
  if (command === "tokens" &&
@@ -1358,7 +1402,11 @@ async function main(argv, cli) {
1358
1402
  {
1359
1403
  check: "editor and plugin",
1360
1404
  ok: false,
1361
- detail: "No resolved project credential/connection; editor identity was not observed",
1405
+ detail: "No resolved project credential/connection; editor identity was not observed; heartbeat age unavailable; keep-alive unknown",
1406
+ evidence: {
1407
+ heartbeat: "unknown",
1408
+ pluginHealth: pluginHealthFromError(undefined),
1409
+ },
1362
1410
  remedy: "Configure the private credential source, connect the intended editor, then rerun doctor.",
1363
1411
  },
1364
1412
  ]
@@ -1392,6 +1440,57 @@ async function main(argv, cli) {
1392
1440
  }
1393
1441
  return ok ? 0 : 1;
1394
1442
  }
1443
+ if (command === "wake") {
1444
+ const config = await loadCommandConfig(root);
1445
+ if (!config.file?.id) {
1446
+ throw new CommandArgumentError("Pin the owner-confirmed file before waking its browser tab");
1447
+ }
1448
+ const mcp = await mcpProbeFor();
1449
+ if (!mcp) {
1450
+ throw new Error("Configure the project's MCP connection before waking and verifying its tab");
1451
+ }
1452
+ assertEndpointIsInstance({
1453
+ origin: mcp.origin,
1454
+ source: "project connection",
1455
+ }, config);
1456
+ const timeoutMs = Number(values["timeout-ms"] ?? 5000);
1457
+ const started = Date.now();
1458
+ const activation = await wakeBrowserTarget({
1459
+ origin: isExternal(config) ? config.profile.origin : frontendUrl(config),
1460
+ allowLoopbackAlias: !isExternal(config),
1461
+ fileId: config.file.id,
1462
+ port: Number(values["debug-port"]),
1463
+ timeoutMs,
1464
+ signal: commandSignal(),
1465
+ });
1466
+ const remaining = timeoutMs - (Date.now() - started);
1467
+ const checks = remaining > 0
1468
+ ? await doctorEditor(config, {
1469
+ mcp,
1470
+ timeoutMs: remaining,
1471
+ })
1472
+ : [
1473
+ {
1474
+ check: "editor and plugin",
1475
+ ok: false,
1476
+ detail: "Tab activation exhausted the verification deadline",
1477
+ },
1478
+ ];
1479
+ const ready = checks.length > 0 && checks.every((check) => check.ok);
1480
+ output.result({
1481
+ ...activation,
1482
+ ready,
1483
+ checks,
1484
+ });
1485
+ if (!values.json) {
1486
+ output.log("Browser activation acknowledged; sustained keep-alive: none. No write was replayed.");
1487
+ for (const check of checks) {
1488
+ output.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
1489
+ }
1490
+ output.log("Reconcile any uncertain run independently before explicitly resuming it.");
1491
+ }
1492
+ return ready ? 0 : 1;
1493
+ }
1395
1494
  if (command === "open") {
1396
1495
  if (rest.length) {
1397
1496
  throw new CommandArgumentError("open accepts only --print for a URL preview");
@@ -1683,10 +1782,24 @@ async function main(argv, cli) {
1683
1782
  const client = await guarded();
1684
1783
  commandRedactor(endpointRedactor(endpoint));
1685
1784
  const options = {
1686
- ...(values.layout
1785
+ ...(values.layout || values["layout-root"] || values["layout-max-nodes"]
1687
1786
  ? {
1688
1787
  layout: {
1689
- strategy: values.layout,
1788
+ ...(values.layout
1789
+ ? {
1790
+ strategy: values.layout,
1791
+ }
1792
+ : {}),
1793
+ ...(values["layout-root"]
1794
+ ? {
1795
+ shapeIds: values["layout-root"],
1796
+ }
1797
+ : {}),
1798
+ ...(values["layout-max-nodes"]
1799
+ ? {
1800
+ maxNodes: Number(values["layout-max-nodes"]),
1801
+ }
1802
+ : {}),
1690
1803
  },
1691
1804
  }
1692
1805
  : {}),
@@ -2391,15 +2504,16 @@ async function runCliBound(argv, options = {}) {
2391
2504
  ...argv,
2392
2505
  ];
2393
2506
  const timeoutAt = args.indexOf("--timeout-ms");
2507
+ const boundedDiagnosis = args[0] === "doctor" || args[0] === "wake";
2394
2508
  let invalidTimeout = false;
2395
2509
  if (timeoutAt !== -1) {
2396
2510
  const milliseconds = Number(args[timeoutAt + 1]);
2397
2511
  invalidTimeout =
2398
2512
  !Number.isInteger(milliseconds) ||
2399
- milliseconds < (args[0] === "doctor" ? 1000 : 1) ||
2400
- milliseconds > (args[0] === "doctor" ? 30000 : 1_800_000) ||
2513
+ milliseconds < (boundedDiagnosis ? 1000 : 1) ||
2514
+ milliseconds > (boundedDiagnosis ? 30000 : 1_800_000) ||
2401
2515
  args.indexOf("--timeout-ms", timeoutAt + 1) !== -1;
2402
- if (args[0] !== "doctor") {
2516
+ if (!boundedDiagnosis) {
2403
2517
  args.splice(timeoutAt, 2);
2404
2518
  }
2405
2519
  if (!invalidTimeout) {
@@ -2426,8 +2540,8 @@ async function runCliBound(argv, options = {}) {
2426
2540
  try {
2427
2541
  if (invalidTimeout) {
2428
2542
  const result = await collectCommand(args[0] ?? "help", async () => {
2429
- throw new Error(args[0] === "doctor"
2430
- ? "doctor --timeout-ms must be one integer from 1000 to 30000"
2543
+ throw new Error(boundedDiagnosis
2544
+ ? `${args[0]} --timeout-ms must be one integer from 1000 to 30000`
2431
2545
  : "--timeout-ms must be one integer from 1 to 1800000");
2432
2546
  });
2433
2547
  if (json) {
package/dist/errors.js CHANGED
@@ -9,7 +9,7 @@ export const errorRemedies = Object.freeze({
9
9
  "mcp-unavailable": "Start the Penpot instance and check the MCP URL. Run `penpair probe` to see what answers.",
10
10
  "auth-rejected": "Penpot rejected the MCP key. Use the key from Account, Integrations, MCP Server.",
11
11
  "plugin-disconnected": "In the open Penpot file choose File, MCP Server, Connect, and keep that tab visible, then run the command again.",
12
- "plugin-suspended": "The browser put the Penpot tab to sleep. Bring it to the foreground and keep it visible while a command runs. A suspended-tab error does not mean nothing was written: read the page before running a write again.",
12
+ "plugin-suspended": "The browser put the Penpot tab to sleep. Bring it to the foreground, or use penpair wake --debug-port PORT with a dedicated debugging browser, then run penpair doctor. Waking is temporary recovery. A suspended-tab error does not mean nothing was written: read the page and reconcile the run before explicitly replaying a write.",
13
13
  "tool-failed": "Penpot's tool reported a failure; the message above says what.",
14
14
  "snippet-too-large": "Split the snippet. Penpot's proxy refuses request bodies of about 110 KB, so snippets stay under 90 KB.",
15
15
  });
@@ -1,3 +1,4 @@
1
+ import { type PluginHealth } from "../plugin-health.js";
1
2
  import { type ExternalConfig, type InstanceConfig, type ProjectConfig } from "./config.js";
2
3
  /** What Penpot itself, the connection and the checks need, whichever way Penpot is run. Nothing here knows about Docker. */
3
4
  export declare class LifecycleError extends Error {
@@ -18,6 +19,7 @@ export interface DoctorCheck extends Check {
18
19
  pageId?: string;
19
20
  apiVersion?: string;
20
21
  heartbeat?: "unknown";
22
+ pluginHealth?: PluginHealth;
21
23
  };
22
24
  remedy?: string;
23
25
  file?: {
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { commandSignal, withCommandSignal } from "../command-result.js";
3
3
  import { redactEndpoint } from "../endpoint.js";
4
+ import { pluginHealthFromError, responsivePluginHealth, } from "../plugin-health.js";
4
5
  import { isExternal, } from "./config.js";
5
6
  /** What Penpot itself, the connection and the checks need, whichever way Penpot is run. Nothing here knows about Docker. */
6
7
  export class LifecycleError extends Error {
@@ -105,7 +106,7 @@ export async function doctorEditor(config, options) {
105
106
  check: "editor and plugin",
106
107
  layer: "execution",
107
108
  ok: true,
108
- detail: `Fresh read from file "${actual.file}"`,
109
+ detail: `Fresh read from file "${actual.file}"; heartbeat age unavailable; keep-alive unknown`,
109
110
  observedAt: new Date().toISOString(),
110
111
  durationMs: Date.now() - started,
111
112
  evidence: {
@@ -117,11 +118,16 @@ export async function doctorEditor(config, options) {
117
118
  }
118
119
  : {}),
119
120
  heartbeat: "unknown",
121
+ pluginHealth: responsivePluginHealth(),
120
122
  },
121
123
  });
122
124
  out.push(fileIdentityCheck(config, actual));
123
125
  }
124
126
  catch (error) {
127
+ const pluginHealth = pluginHealthFromError(error);
128
+ const heartbeatDetail = pluginHealth.heartbeatAgeMs === null
129
+ ? "heartbeat age unavailable"
130
+ : `last heartbeat ${pluginHealth.heartbeatAgeMs / 1000}s ago (reported by upstream)`;
125
131
  if (!connected) {
126
132
  out.push({
127
133
  check: "MCP transport",
@@ -136,10 +142,16 @@ export async function doctorEditor(config, options) {
136
142
  check: "editor and plugin",
137
143
  layer: "execution",
138
144
  ok: false,
139
- detail: redactEndpoint(error instanceof Error ? error.message : String(error)),
145
+ detail: `${redactEndpoint(error instanceof Error ? error.message : String(error))}; ${heartbeatDetail}; keep-alive unknown`,
140
146
  observedAt: new Date().toISOString(),
141
147
  durationMs: Date.now() - started,
142
- remedy: "Open the intended file, connect the MCP plugin and keep the editor responsive; rerun doctor for a fresh read.",
148
+ evidence: {
149
+ heartbeat: "unknown",
150
+ pluginHealth,
151
+ },
152
+ remedy: pluginHealth.state === "suspended"
153
+ ? "Bring the intended Penpot tab to the foreground, then rerun doctor for a fresh read. Reconcile any uncertain run before replaying a write."
154
+ : "Open the intended file, connect the MCP plugin and keep the editor responsive; rerun doctor for a fresh read.",
143
155
  });
144
156
  }
145
157
  finished = true;
@@ -225,7 +225,7 @@ const pp = (() => {
225
225
  try { return await settleLayout(async nativeMs => {
226
226
  if (penpot.currentFile.id !== fileId || penpot.currentPage.id !== pageId) throw new Error("Layout target changed between samples");
227
227
  return observeLayout(penpot, root, { nativeMs, maxNodes: options.maxNodes });
228
- }, { timeoutMs: options.timeoutMs === undefined ? 10000 : options.timeoutMs, intervalMs: options.intervalMs === undefined ? 100 : options.intervalMs, strategy: options.strategy === undefined ? "auto" : options.strategy, sleep: wait }); }
228
+ }, { timeoutMs: options.timeoutMs === undefined ? 10000 : options.timeoutMs, intervalMs: options.intervalMs === undefined ? 100 : options.intervalMs, strategy: options.strategy === undefined ? "auto" : options.strategy, maxNodes: options.maxNodes, sleep: wait }); }
229
229
  catch (error) { return { stable: false, method: "unavailable", samples: 0, reason: String(error && error.message || error) }; }
230
230
  };
231
231
  const flush = async (options = {}) => {
@@ -2,21 +2,26 @@ import { settleLayout } from "./layout-settling.js";
2
2
  /** Trusted, dependency-free observation source for the Penpot sandbox. */
3
3
  const layoutObservationSource = String.raw `
4
4
  const observeLayout = async (penpot, root, options) => {
5
- if (!Number.isInteger(options.maxNodes === undefined ? 10000 : options.maxNodes) || (options.maxNodes !== undefined && (options.maxNodes < 1 || options.maxNodes > 10000))) throw new Error("Invalid settling node budget");
5
+ if (!Number.isInteger(options.maxNodes === undefined ? 10000 : options.maxNodes) || (options.maxNodes !== undefined && (options.maxNodes < 1 || options.maxNodes > 100000))) throw new Error("Invalid settling node budget");
6
+ const roots = Array.isArray(root) ? root : [root];
7
+ if (!roots.length || roots.some(shape => !shape)) throw new Error("layout target does not exist");
6
8
  const fileId = penpot.currentFile.id, pageId = penpot.currentPage.id;
7
9
  const validTarget = () => {
8
10
  if (penpot.currentFile.id !== fileId || penpot.currentPage.id !== pageId) throw new Error("Layout target changed while waiting");
9
- if (root.id !== penpot.currentPage.root.id && typeof penpot.currentPage.getShapeById === "function" && !penpot.currentPage.getShapeById(root.id)) throw new Error("Layout target no longer exists");
11
+ for (const shape of roots) {
12
+ if (shape.id !== penpot.currentPage.root.id && typeof penpot.currentPage.getShapeById === "function" && !penpot.currentPage.getShapeById(shape.id)) throw new Error("Layout target no longer exists");
13
+ }
10
14
  };
11
- if (!root) throw new Error("layout target does not exist");
15
+
12
16
  let nativeOutcome = "skipped", nativeError;
13
- const target = typeof root.waitForLayoutUpdate === "function" ? root : penpot;
17
+ validTarget();
18
+ const nativeTargets = roots.every(shape => typeof shape.waitForLayoutUpdate === "function") ? roots : [penpot];
14
19
  if (options.nativeMs > 0) {
15
20
  nativeOutcome = "unsupported";
16
- if (typeof target.waitForLayoutUpdate === "function") {
21
+ if (nativeTargets.every(target => typeof target.waitForLayoutUpdate === "function")) {
17
22
  let timer;
18
23
  try {
19
- await Promise.race([target.waitForLayoutUpdate(options.nativeMs), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Native layout attempt timed out")), options.nativeMs); })]);
24
+ await Promise.race([Promise.all(nativeTargets.map(target => target.waitForLayoutUpdate(options.nativeMs))), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Native layout attempt timed out")), options.nativeMs); })]);
20
25
  nativeOutcome = "completed";
21
26
  } catch (error) { nativeOutcome = "failed"; nativeError = String(error && error.message || error); }
22
27
  finally { clearTimeout(timer); }
@@ -24,20 +29,25 @@ const observeLayout = async (penpot, root, options) => {
24
29
  }
25
30
  validTarget();
26
31
  // Keep hierarchy and sibling order as well as geometry; repeated IDs include cycles.
27
- const geometry = [], stack = [[root, null, 0]], seen = new Set();
32
+ const geometry = [], stack = roots.map(shape => [shape, null, 0]).reverse(), seen = new Set();
33
+ let truncated = false;
28
34
  while (stack.length) {
29
- if (geometry.length >= (options.maxNodes || 10000)) throw new Error("Layout sample exceeded node budget");
35
+ if (geometry.length >= (options.maxNodes || 10000)) { truncated = true; break; }
30
36
  const [shape, parent, index] = stack.pop();
31
37
  if (!shape || typeof shape.id !== "string" || !shape.id || seen.has(shape.id) || ![shape.x, shape.y, shape.width, shape.height].every(Number.isFinite)) throw new Error("Malformed layout geometry or duplicate IDs");
32
38
  seen.add(shape.id);
33
39
  geometry.push([shape.id, shape.x, shape.y, shape.width, shape.height, parent, index]);
34
40
  const children = shape.children || [];
35
41
  if (!Array.isArray(children)) throw new Error("Malformed layout hierarchy");
36
- if (geometry.length + stack.length + children.length > (options.maxNodes || 10000)) throw new Error("Layout sample exceeded node budget");
37
- for (let index = children.length - 1; index >= 0; index--) stack.push([children[index], shape.id, index]);
42
+ const available = (options.maxNodes || 10000) - geometry.length;
43
+ if (stack.length + children.length > available) truncated = true;
44
+ // Only retain the bounded depth-first prefix; avoid allocating a page-sized traversal stack.
45
+ if (children.length >= available) stack.length = 0;
46
+ else if (stack.length > available - children.length) stack.splice(0, stack.length - (available - children.length));
47
+ for (let index = Math.min(children.length, available) - 1; index >= 0; index--) stack.push([children[index], shape.id, index]);
38
48
  }
39
49
  validTarget();
40
- return { ok: true, fileId, pageId, native: nativeOutcome === "completed", nativeOutcome, nativeError, geometry, truncated: false };
50
+ return { ok: true, fileId, pageId, native: nativeOutcome === "completed", nativeOutcome, nativeError, geometry, rootIds: roots.map(shape => shape.id), truncated };
41
51
  };
42
52
  `;
43
53
  export const layoutPolicySource = `${layoutObservationSource}\nconst settleLayout = ${settleLayout.toString()};`;
@@ -1,5 +1,6 @@
1
1
  import type { LayoutResult, LayoutStrategy } from "./layout.js";
2
2
  interface LayoutSettlingOptions {
3
+ maxNodes?: number;
3
4
  timeoutMs: number;
4
5
  intervalMs: number;
5
6
  strategy: LayoutStrategy;
@@ -1,11 +1,14 @@
1
1
  /** Self-contained policy: its JavaScript source also runs inside the Penpot sandbox. */
2
2
  export async function settleLayout(observe, options) {
3
- const { timeoutMs, intervalMs, strategy = "auto", sleep } = options;
4
- if (![
5
- "auto",
6
- "native",
7
- "geometry",
8
- ].includes(strategy) ||
3
+ const { timeoutMs, intervalMs, strategy = "auto", maxNodes = 10000, sleep } = options;
4
+ if (!Number.isInteger(maxNodes) ||
5
+ maxNodes < 1 ||
6
+ maxNodes > 100000 ||
7
+ ![
8
+ "auto",
9
+ "native",
10
+ "geometry",
11
+ ].includes(strategy) ||
9
12
  !Number.isFinite(timeoutMs) ||
10
13
  timeoutMs < 1 ||
11
14
  timeoutMs > 30000 ||
@@ -17,6 +20,9 @@ export async function settleLayout(observe, options) {
17
20
  const started = Date.now();
18
21
  const deadline = started + timeoutMs;
19
22
  let samples = 0;
23
+ let nodeCount = 0;
24
+ let rootIds = [];
25
+ let truncated = false;
20
26
  let nativeOutcome = "skipped";
21
27
  let fallbackReason;
22
28
  let targetIdentity;
@@ -24,8 +30,12 @@ export async function settleLayout(observe, options) {
24
30
  let matchingSamples = 0;
25
31
  const createResult = (stable, method, reason) => ({
26
32
  stable,
27
- method,
33
+ method: truncated ? "partial" : method,
28
34
  samples,
35
+ rootIds,
36
+ nodeCount,
37
+ maxNodes,
38
+ truncated,
29
39
  strategy,
30
40
  nativeOutcome,
31
41
  ...(fallbackReason
@@ -34,9 +44,10 @@ export async function settleLayout(observe, options) {
34
44
  }
35
45
  : {}),
36
46
  elapsedMs: Date.now() - started,
37
- ...(reason
47
+ ...(reason || truncated
38
48
  ? {
39
- reason,
49
+ reason: reason ??
50
+ "Only the bounded layout sample was observed; independent readback is required",
40
51
  }
41
52
  : {}),
42
53
  });
@@ -74,20 +85,34 @@ export async function settleLayout(observe, options) {
74
85
  const nextTargetIdentity = JSON.stringify([
75
86
  observation.fileId,
76
87
  observation.pageId,
77
- observation.geometry?.[0]?.[0],
88
+ observation.rootIds ?? [
89
+ observation.geometry?.[0]?.[0],
90
+ ],
78
91
  ]);
79
92
  if (targetIdentity && targetIdentity !== nextTargetIdentity) {
80
93
  throw new Error("Layout target changed between samples");
81
94
  }
82
95
  targetIdentity = nextTargetIdentity;
83
- if (observation.truncated ||
84
- !Array.isArray(observation.geometry) ||
96
+ if (!Array.isArray(observation.geometry) ||
85
97
  !observation.geometry.length ||
86
- observation.geometry.length > 10000) {
98
+ observation.geometry.length > maxNodes) {
87
99
  throw new Error("Incomplete layout geometry evidence");
88
100
  }
101
+ const observedRoots = observation.rootIds ?? [
102
+ observation.geometry[0][0],
103
+ ];
104
+ if (!Array.isArray(observedRoots) ||
105
+ !observedRoots.length ||
106
+ observedRoots.some((id) => typeof id !== "string" || !id) ||
107
+ new Set(observedRoots).size !== observedRoots.length) {
108
+ throw new Error("Malformed layout root evidence");
109
+ }
110
+ rootIds = observedRoots;
111
+ nodeCount = observation.geometry.length;
112
+ truncated = observation.truncated;
89
113
  const observedShapeIds = new Set();
90
114
  const siblingCountByParent = new Map();
115
+ let rootIndex = 0;
91
116
  for (const geometryRow of observation.geometry) {
92
117
  if (!Array.isArray(geometryRow) ||
93
118
  geometryRow.length !== 7 ||
@@ -102,9 +127,12 @@ export async function settleLayout(observe, options) {
102
127
  geometryRow[6] < 0) {
103
128
  throw new Error("Layout API returned no geometry evidence or malformed hierarchy");
104
129
  }
105
- if (!observedShapeIds.size
106
- ? geometryRow[5] !== null || geometryRow[6] !== 0
107
- : geometryRow[5] === null || typeof geometryRow[5] !== "string") {
130
+ if (geometryRow[5] === null) {
131
+ if (geometryRow[0] !== rootIds[rootIndex++] || geometryRow[6] !== 0) {
132
+ throw new Error("Malformed layout root hierarchy");
133
+ }
134
+ }
135
+ else if (typeof geometryRow[5] !== "string") {
108
136
  throw new Error("Malformed layout root hierarchy");
109
137
  }
110
138
  if (geometryRow[5] !== null) {
@@ -116,6 +144,9 @@ export async function settleLayout(observe, options) {
116
144
  }
117
145
  observedShapeIds.add(geometryRow[0]);
118
146
  }
147
+ if (!truncated && rootIndex !== rootIds.length) {
148
+ throw new Error("Incomplete layout root evidence");
149
+ }
119
150
  if (sampleIndex === 0) {
120
151
  nativeOutcome =
121
152
  observation.nativeOutcome ||
@@ -143,7 +174,10 @@ export async function settleLayout(observe, options) {
143
174
  if (Date.now() >= deadline) {
144
175
  return createResult(false, "geometry", "Layout observation exceeded its deadline");
145
176
  }
146
- const currentGeometry = JSON.stringify(observation.geometry);
177
+ const currentGeometry = JSON.stringify([
178
+ observation.geometry,
179
+ observation.truncated,
180
+ ]);
147
181
  matchingSamples = currentGeometry === previousGeometry ? matchingSamples + 1 : 1;
148
182
  if (matchingSamples >= 3) {
149
183
  return createResult(true, "geometry");
package/dist/layout.d.ts CHANGED
@@ -2,6 +2,8 @@ import type { PenpotToolClient } from "./client.js";
2
2
  export type LayoutStrategy = "auto" | "native" | "geometry";
3
3
  export interface LayoutOptions {
4
4
  shapeId?: string;
5
+ shapeIds?: string[];
6
+ maxNodes?: number;
5
7
  strategy?: LayoutStrategy;
6
8
  timeoutMs?: number;
7
9
  intervalMs?: number;
@@ -9,7 +11,11 @@ export interface LayoutOptions {
9
11
  }
10
12
  export interface LayoutResult {
11
13
  stable: boolean;
12
- method: "native" | "geometry" | "unavailable";
14
+ method: "native" | "geometry" | "partial" | "unavailable";
15
+ rootIds?: string[];
16
+ nodeCount?: number;
17
+ maxNodes?: number;
18
+ truncated?: boolean;
13
19
  samples: number;
14
20
  reason?: string;
15
21
  strategy?: LayoutStrategy;
package/dist/layout.js CHANGED
@@ -5,10 +5,25 @@ import { layoutPolicySource } from "./layout-policy.js";
5
5
  import { settleLayout } from "./layout-settling.js";
6
6
  /** Layout evidence is independent of render equality and visual approval. Transport failures propagate without fallback. */
7
7
  export async function waitForStableLayout(client, options = {}) {
8
- const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, } = options;
8
+ const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, shapeIds, maxNodes = 10000, } = options;
9
+ const roots = shapeIds ??
10
+ (shapeId
11
+ ? [
12
+ shapeId,
13
+ ]
14
+ : []);
15
+ if (!Array.isArray(roots) ||
16
+ roots.some((id) => typeof id !== "string" || !id.trim()) ||
17
+ new Set(roots).size !== roots.length) {
18
+ throw new Error("Layout roots must be distinct nonempty shape IDs");
19
+ }
20
+ if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 100000) {
21
+ throw new Error("Layout node budget must be an integer from 1 to 100000");
22
+ }
9
23
  try {
10
- return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst shape = CFG.shapeId ? penpot.currentPage.getShapeById(CFG.shapeId) : penpot.currentPage.root;\nreturn await observeLayout(penpot, shape, CFG);`, {
11
- shapeId,
24
+ return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst roots = CFG.shapeIds.length ? CFG.shapeIds.map(id => penpot.currentPage.getShapeById(id)) : [penpot.currentPage.root];\nreturn await observeLayout(penpot, roots, CFG);`, {
25
+ shapeIds: roots,
26
+ maxNodes,
12
27
  nativeMs,
13
28
  }, "read"), {
14
29
  retries: 0,
@@ -17,6 +32,7 @@ export async function waitForStableLayout(client, options = {}) {
17
32
  timeoutMs,
18
33
  intervalMs,
19
34
  strategy,
35
+ maxNodes,
20
36
  sleep,
21
37
  });
22
38
  }
@@ -0,0 +1,13 @@
1
+ export interface PluginHealth {
2
+ state: "responsive" | "suspended" | "unknown";
3
+ heartbeatAgeMs: number | null;
4
+ heartbeatSource: "upstream-error" | "unavailable";
5
+ keepAlive: {
6
+ method: "unknown";
7
+ verified: false;
8
+ };
9
+ }
10
+ /** Fresh execution proves responsiveness, not the plugin's heartbeat time or browser policy. */
11
+ export declare function responsivePluginHealth(): PluginHealth;
12
+ /** Upstream error text is the only heartbeat telemetry exposed by the current connection. */
13
+ export declare function pluginHealthFromError(error: unknown): PluginHealth;
@@ -0,0 +1,39 @@
1
+ function health(state, heartbeatAgeMs) {
2
+ return {
3
+ state,
4
+ heartbeatAgeMs,
5
+ heartbeatSource: heartbeatAgeMs === null ? "unavailable" : "upstream-error",
6
+ keepAlive: {
7
+ method: "unknown",
8
+ verified: false,
9
+ },
10
+ };
11
+ }
12
+ /** Fresh execution proves responsiveness, not the plugin's heartbeat time or browser policy. */
13
+ export function responsivePluginHealth() {
14
+ return health("responsive", null);
15
+ }
16
+ /** Upstream error text is the only heartbeat telemetry exposed by the current connection. */
17
+ export function pluginHealthFromError(error) {
18
+ const message = error instanceof Error
19
+ ? error.message.slice(0, 8192)
20
+ : typeof error === "string"
21
+ ? error.slice(0, 8192)
22
+ : "";
23
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
24
+ const suspended = code === "plugin-suspended" || /\bsuspended\b|\bno heartbeat\b/i.test(message);
25
+ if (!suspended) {
26
+ return health("unknown", null);
27
+ }
28
+ const duration = /\bno heartbeat\s+for\s+([^\s,)]+?)\s*(milliseconds?|ms|seconds?|s)\b/i.exec(message);
29
+ if (!duration || !/^\d+(?:\.\d+)?$/.test(duration[1])) {
30
+ return health("suspended", null);
31
+ }
32
+ const value = Number(duration[1]);
33
+ const heartbeatAgeMs = value * (/^(?:ms|milliseconds?)$/i.test(duration[2]) ? 1 : 1000);
34
+ return health("suspended", Number.isFinite(heartbeatAgeMs) &&
35
+ heartbeatAgeMs >= 0 &&
36
+ heartbeatAgeMs <= Number.MAX_SAFE_INTEGER
37
+ ? heartbeatAgeMs
38
+ : null);
39
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
- export declare const version = "0.4.0";
2
+ export declare const version = "0.4.1";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
- export const version = "0.4.0";
2
+ export const version = "0.4.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/core",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Design together. Build together. The library and engine behind penpair: connect AI agents and people to a Penpot design file, safely. Install @penpair/web or @penpair/docker for the command.",
5
5
  "license": "MIT",
6
6
  "repository": {