@penpair/core 0.1.3 → 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.
Files changed (149) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +62 -19
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +213 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +33 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +443 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +49 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +248 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +150 -11
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +362 -59
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +47 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +2107 -247
  21. package/dist/client.d.ts +30 -6
  22. package/dist/client.js +160 -35
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +468 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +184 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +105 -0
  29. package/dist/deep-check.js +57 -9
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +275 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +133 -21
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +113 -10
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +58 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +58 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +229 -0
  44. package/dist/index.d.ts +34 -1
  45. package/dist/index.js +34 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +657 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +435 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +191 -29
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +178 -30
  54. package/dist/instance/connection.d.ts +39 -4
  55. package/dist/instance/connection.js +198 -14
  56. package/dist/instance/gitignore.js +5 -2
  57. package/dist/instance/init.js +32 -7
  58. package/dist/instance/runner.js +32 -10
  59. package/dist/instance/setup.js +46 -13
  60. package/dist/instance/tty.js +28 -8
  61. package/dist/journal.d.ts +87 -0
  62. package/dist/journal.js +423 -0
  63. package/dist/kit-smoke.d.ts +31 -0
  64. package/dist/kit-smoke.js +145 -0
  65. package/dist/kit-source.d.ts +2 -0
  66. package/dist/kit-source.js +279 -0
  67. package/dist/kit.d.ts +25 -0
  68. package/dist/kit.js +158 -0
  69. package/dist/layout-policy.d.ts +2 -0
  70. package/dist/layout-policy.js +86 -0
  71. package/dist/layout.d.ts +21 -0
  72. package/dist/layout.js +36 -0
  73. package/dist/live-smoke.d.ts +8 -0
  74. package/dist/live-smoke.js +23 -0
  75. package/dist/local-profile.js +2 -1
  76. package/dist/mcp-server.d.ts +12 -0
  77. package/dist/mcp-server.js +415 -0
  78. package/dist/native-api.d.ts +15 -0
  79. package/dist/native-api.js +78 -0
  80. package/dist/onboard.d.ts +10 -0
  81. package/dist/onboard.js +41 -0
  82. package/dist/operation.d.ts +22 -3
  83. package/dist/operation.js +341 -31
  84. package/dist/page-session.d.ts +23 -0
  85. package/dist/page-session.js +142 -0
  86. package/dist/png.d.ts +5 -0
  87. package/dist/png.js +22 -0
  88. package/dist/portable-tokens.d.ts +22 -0
  89. package/dist/portable-tokens.js +331 -0
  90. package/dist/probe.d.ts +6 -2
  91. package/dist/probe.js +64 -11
  92. package/dist/project-input.d.ts +6 -0
  93. package/dist/project-input.js +65 -0
  94. package/dist/project-snippets.js +29 -12
  95. package/dist/project-status.d.ts +24 -0
  96. package/dist/project-status.js +188 -0
  97. package/dist/registry.d.ts +2 -1
  98. package/dist/registry.js +224 -53
  99. package/dist/run-file.d.ts +26 -0
  100. package/dist/run-file.js +199 -0
  101. package/dist/shots.d.ts +43 -0
  102. package/dist/shots.js +172 -0
  103. package/dist/skill-instructions.d.ts +13 -0
  104. package/dist/skill-instructions.js +144 -0
  105. package/dist/skills.d.ts +7 -0
  106. package/dist/skills.js +163 -46
  107. package/dist/snippet.d.ts +3 -2
  108. package/dist/snippet.js +33 -9
  109. package/dist/snippets/archive.d.ts +9 -0
  110. package/dist/snippets/archive.js +10 -4
  111. package/dist/snippets/read.d.ts +1 -1
  112. package/dist/snippets/read.js +78 -41
  113. package/dist/structure-snapshot.d.ts +85 -0
  114. package/dist/structure-snapshot.js +525 -0
  115. package/dist/token-edit.d.ts +53 -0
  116. package/dist/token-edit.js +352 -0
  117. package/dist/token-projection.d.ts +56 -0
  118. package/dist/token-projection.js +286 -0
  119. package/dist/tokens-diff.d.ts +19 -6
  120. package/dist/tokens-diff.js +182 -18
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
  124. package/skills/penpair-design/SKILL.md +20 -45
  125. package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
  126. package/skills/penpair-design/references/project-facts.md +3 -1
  127. package/skills/penpair-design/references/write-protocol.md +15 -3
  128. package/skills/penpair-handoff/SKILL.md +16 -0
  129. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  130. package/skills/penpair-handoff/references/handoff-flow.md +13 -0
  131. package/skills/penpair-recipes/SKILL.md +21 -0
  132. package/skills/penpair-recipes/references/build-flow.md +11 -0
  133. package/skills/penpair-setup/SKILL.md +8 -2
  134. package/dist/cli.d.ts +0 -2
  135. package/dist/cli.js +0 -3
  136. package/dist/docker/lifecycle.d.ts +0 -96
  137. package/dist/docker/lifecycle.js +0 -273
  138. package/dist/docker/secrets.d.ts +0 -11
  139. package/dist/docker/secrets.js +0 -38
  140. package/dist/docker/template.d.ts +0 -8
  141. package/dist/docker/template.js +0 -158
  142. package/dist/docker-profile.d.ts +0 -114
  143. package/dist/docker-profile.js +0 -18
  144. package/dist/instance/lifecycle.d.ts +0 -129
  145. package/dist/instance/lifecycle.js +0 -336
  146. package/dist/instance/secrets.d.ts +0 -11
  147. package/dist/instance/secrets.js +0 -38
  148. package/dist/instance/template.d.ts +0 -8
  149. package/dist/instance/template.js +0 -158
package/dist/operation.js CHANGED
@@ -1,55 +1,365 @@
1
+ import { waitForStableLayout } from "./layout.js";
2
+ import { commandSleep } from "./command-result.js";
1
3
  import { runSnippet } from "./snippet.js";
2
- import { switchPage } from "./snippets/read.js";
3
- import { archiveApply, archiveInspect, archiveReadback } from "./snippets/archive.js";
4
- const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
5
- /** Runs a snippet; if the editor shows another page, opens the right one, waits, and tries once more. */
6
- export async function runOnPage(client, s, page, { settleMs = 3_000, sleep = defaultSleep, onStep, ...execute } = {}) {
7
- const first = await runSnippet(client, s, execute);
8
- if (!first.wrongPage || !page)
9
- return first;
10
- onStep?.(`opening page ${page}`);
11
- await runSnippet(client, switchPage(page), execute);
12
- await sleep(settleMs);
13
- const second = await runSnippet(client, s, execute);
14
- if (second.wrongPage)
15
- throw new Error(`${s.name}: the editor still shows another page than ${page}`);
16
- return second;
4
+ import { withPageSession, pinPageSnippet, selectPage, rememberPage } from "./page-session.js";
5
+ import { archiveApply, archiveInspect, archiveReadback, } from "./snippets/archive.js";
6
+ import { PenpairError } from "./errors.js";
7
+ import { UncertainExecutionError } from "./client.js";
8
+ import { observationHash, readJournalTarget, runJournaledSteps, } from "./journal.js";
9
+ import { guardRevision, writeCoordinator } from "./coordination.js";
10
+ import { guardFile } from "./file-guard.js";
11
+ const defaultSleep = commandSleep;
12
+ /** Runs once on the expected page; page transitions require independently observed IDs. */
13
+ export async function runOnPage(client, s, page, { settleMs: _settleMs, sleep = defaultSleep, onStep, ...execute } = {}) {
14
+ return withPageSession(client, async () => {
15
+ const started = Date.now();
16
+ const remaining = () => ({
17
+ ...execute,
18
+ ...(execute.timeoutMs
19
+ ? {
20
+ timeoutMs: Math.max(1, execute.timeoutMs - (Date.now() - started)),
21
+ }
22
+ : {}),
23
+ });
24
+ const first = await runSnippet(client, pinPageSnippet(client, s), remaining());
25
+ if (!first.wrongPage || !page) {
26
+ if (typeof first.fileId === "string" && typeof first.pageId === "string") {
27
+ rememberPage(client, {
28
+ fileId: first.fileId,
29
+ pageId: first.pageId,
30
+ });
31
+ }
32
+ return first;
33
+ }
34
+ onStep?.(`opening page ${page}`);
35
+ await selectPage(client, {
36
+ name: page,
37
+ }, {
38
+ ...remaining(),
39
+ sleep,
40
+ });
41
+ const second = await runSnippet(client, pinPageSnippet(client, s), remaining());
42
+ if (second.wrongPage) {
43
+ throw new Error(`${s.name}: the editor still shows another page than ${page}`);
44
+ }
45
+ return second;
46
+ });
17
47
  }
18
48
  export async function runOperation(client, operation, params, options = {}) {
19
- const { settleMs = 10_000, sleep = defaultSleep, onStep } = options;
49
+ return withPageSession(client, () => runOperationInSession(client, operation, params, options));
50
+ }
51
+ async function runOperationInSession(client, operation, params, options) {
52
+ if (options.settleMs !== undefined &&
53
+ (!Number.isFinite(options.settleMs) || options.settleMs < 0 || options.settleMs > 30000)) {
54
+ throw new Error("Minimum settle delay must be from 0 to 30000 ms");
55
+ }
56
+ if (options.apply && options.journal) {
57
+ return journaledOperation(client, operation, params, options);
58
+ }
59
+ const { settleMs = 0, sleep = defaultSleep, onStep } = options;
20
60
  const page = operation.page(params);
21
61
  onStep?.(`${operation.name}: inspecting`);
22
- const inspect = await runOnPage(client, operation.inspect(params), page, options);
23
- if (!options.apply)
24
- return { operation: operation.name, phase: "inspected", inspect, problems: [], verified: false };
62
+ const inspect = await runOnPage(client, operation.inspect(params), page, {
63
+ ...options,
64
+ effect: "read",
65
+ });
66
+ if (inspect.ok !== true) {
67
+ throw new PenpairError("tool-failed", `${operation.name}: inspect did not return an explicit ok result; nothing was applied`);
68
+ }
69
+ if (!options.apply) {
70
+ return {
71
+ operation: operation.name,
72
+ outcome: "inspected",
73
+ phase: "inspected",
74
+ inspect,
75
+ problems: [],
76
+ verified: false,
77
+ };
78
+ }
25
79
  onStep?.(`${operation.name}: writing once`);
26
- const apply = await runOnPage(client, operation.apply(params), page, options);
27
- onStep?.(`${operation.name}: waiting ${Math.round(settleMs / 1000)} s before reading back`);
28
- await sleep(settleMs);
29
- const readback = await runOnPage(client, operation.readback(params), page, options);
30
- const problems = operation.verify({ inspect, apply, readback });
31
- return { operation: operation.name, phase: "applied", inspect, apply, readback, problems, verified: problems.length === 0 };
80
+ const apply = await runOnPage(client, operation.apply(params, inspect), page, {
81
+ ...options,
82
+ effect: "write",
83
+ });
84
+ if (apply.ok !== true) {
85
+ throw new UncertainExecutionError(String(apply.execution?.runId ?? "unknown"), `${operation.name}: apply returned no explicit acknowledgment`);
86
+ }
87
+ if (settleMs > 0) {
88
+ await sleep(settleMs);
89
+ }
90
+ let layout;
91
+ let readback;
92
+ let problems;
93
+ try {
94
+ layout = await settleOperation(client, options);
95
+ readback = await runOnPage(client, operation.readback(params), page, {
96
+ ...options,
97
+ effect: "read",
98
+ });
99
+ problems = verificationProblems(operation, {
100
+ inspect,
101
+ apply,
102
+ readback,
103
+ });
104
+ }
105
+ catch (error) {
106
+ problems = [
107
+ `Independent verification failed: ${error instanceof Error ? error.message : String(error)}`,
108
+ ];
109
+ }
110
+ return {
111
+ layout,
112
+ operation: operation.name,
113
+ phase: "applied",
114
+ outcome: problems.length === 0 ? "verified" : "failed",
115
+ inspect,
116
+ apply,
117
+ readback,
118
+ problems,
119
+ verified: problems.length === 0,
120
+ };
121
+ }
122
+ function verificationProblems(operation, results) {
123
+ const { readback } = results;
124
+ const observations = Object.keys(readback).filter((key) => ![
125
+ "ok",
126
+ "readOnly",
127
+ "execution",
128
+ "problems",
129
+ ].includes(key));
130
+ if (Array.isArray(readback.problems) &&
131
+ readback.problems.length > 0 &&
132
+ readback.problems.every((problem) => typeof problem === "string")) {
133
+ return readback.problems;
134
+ }
135
+ if (readback.ok !== true || observations.length === 0) {
136
+ return [
137
+ "Readback did not provide explicit observations of the resulting state",
138
+ ];
139
+ }
140
+ const result = operation.verify(results);
141
+ return Array.isArray(result) && result.every((problem) => typeof problem === "string")
142
+ ? result
143
+ : [
144
+ "Verifier must return a list of problems",
145
+ ];
146
+ }
147
+ async function settleOperation(client, options) {
148
+ if (options.layout === false) {
149
+ return undefined;
150
+ }
151
+ options.onStep?.("waiting for bounded layout completion");
152
+ const layout = await waitForStableLayout(client, {
153
+ sleep: options.sleep,
154
+ ...options.layout,
155
+ });
156
+ if (!layout.stable) {
157
+ throw new PenpairError("incomplete-output", `Layout did not settle: ${layout.reason ?? "unknown reason"}`);
158
+ }
159
+ return layout;
160
+ }
161
+ async function journaledOperation(client, operation, params, options) {
162
+ const page = operation.page(params);
163
+ await runOnPage(client, operation.inspect(params), page, {
164
+ ...options,
165
+ effect: "read",
166
+ });
167
+ await settleOperation(client, options);
168
+ const initialTarget = await readJournalTarget(client);
169
+ const guarded = guardFile(client, {
170
+ id: initialTarget.fileId,
171
+ });
172
+ const observe = async () => {
173
+ const inspection = await runOnPage(guarded, operation.inspect(params), page, {
174
+ ...options,
175
+ effect: "read",
176
+ });
177
+ const layout = await settleOperation(guarded, options);
178
+ const readback = await runOnPage(guarded, operation.readback(params), page, {
179
+ ...options,
180
+ effect: "read",
181
+ });
182
+ if (inspection.ok !== true || readback.ok !== true) {
183
+ throw new Error("Operation inspection/readback did not acknowledge its observations");
184
+ }
185
+ return {
186
+ inspection,
187
+ readback,
188
+ layout,
189
+ };
190
+ };
191
+ const verify = (current, before, result) => verificationProblems(operation, {
192
+ inspect: before.inspection,
193
+ apply: result ?? {
194
+ ok: true,
195
+ reconciled: true,
196
+ },
197
+ readback: current.readback,
198
+ }).length === 0;
199
+ const record = await runJournaledSteps([
200
+ {
201
+ key: "apply",
202
+ label: operation.name,
203
+ signature: [
204
+ operation.inspect(params).code,
205
+ operation.apply(params).code,
206
+ operation.readback(params).code,
207
+ operation.verify.toString(),
208
+ operation.reconcile?.toString(),
209
+ settleOperation.toString(),
210
+ waitForStableLayout.toString(),
211
+ ].join("\n"),
212
+ inspect: observe,
213
+ apply: async (current, target) => {
214
+ const result = await runOnPage(guardRevision(guarded, target), operation.apply(params, current.inspection), page, {
215
+ ...options,
216
+ effect: "write",
217
+ });
218
+ if (result.ok !== true) {
219
+ throw new Error("Apply returned no explicit acknowledgment");
220
+ }
221
+ await (options.sleep ?? defaultSleep)(options.settleMs ?? 0);
222
+ return result;
223
+ },
224
+ verify,
225
+ reconcile: (before, current, result) => {
226
+ if (verify(current, before, result)) {
227
+ return "complete";
228
+ }
229
+ if (operation.reconcile) {
230
+ return operation.reconcile({
231
+ before: before.inspection,
232
+ current: current.inspection,
233
+ readback: current.readback,
234
+ });
235
+ }
236
+ return observationHash(before.inspection, true) ===
237
+ observationHash(current.inspection, true)
238
+ ? "pending"
239
+ : "conflict";
240
+ },
241
+ },
242
+ ], {
243
+ ...options.journal,
244
+ coordinate: writeCoordinator(guarded, {
245
+ root: options.journal.root,
246
+ operation: operation.name,
247
+ forceLock: options.journal.forceLock,
248
+ warn: options.onStep,
249
+ }),
250
+ operation: operation.name,
251
+ input: {
252
+ params,
253
+ layout: options.layout ?? {},
254
+ minimumWaitMs: options.settleMs ?? 0,
255
+ },
256
+ payload: {
257
+ params,
258
+ },
259
+ target: () => readJournalTarget(guarded),
260
+ onStep: options.onStep ?? options.journal.onStep,
261
+ redact: options.redact ?? options.journal.redact,
262
+ });
263
+ const saved = record.steps[0];
264
+ return {
265
+ layout: saved.after.layout,
266
+ operation: operation.name,
267
+ phase: "applied",
268
+ outcome: record.redacted ? "failed" : "verified",
269
+ ...(record.redacted
270
+ ? {
271
+ redacted: true,
272
+ complete: false,
273
+ }
274
+ : {}),
275
+ inspect: saved.before.inspection,
276
+ apply: saved.result,
277
+ readback: saved.after.readback,
278
+ problems: record.redacted
279
+ ? [
280
+ "Journal evidence required credential redaction; completeness cannot be confirmed",
281
+ ]
282
+ : [],
283
+ verified: !record.redacted,
284
+ journal: {
285
+ runId: record.runId,
286
+ state: record.state,
287
+ },
288
+ };
32
289
  }
33
290
  /** Nests the frames that are not part of the live work into one archive board. Frames keep their names, ids and component links. */
34
291
  export const archiveOperation = {
35
292
  name: "archive",
36
293
  page: (params) => params.page,
37
294
  inspect: archiveInspect,
38
- apply: archiveApply,
295
+ apply: (params, inspected) => archiveApply({
296
+ ...params,
297
+ expectedPlan: inspected?.plan,
298
+ expectedArchiveId: inspected?.archiveId,
299
+ }),
39
300
  readback: archiveReadback,
40
301
  verify({ inspect, apply, readback }) {
41
302
  const problems = [];
303
+ if (!Number.isInteger(inspect.willMove) ||
304
+ Number(inspect.willMove) < 0 ||
305
+ !Array.isArray(inspect.plan) ||
306
+ !Array.isArray(readback.insideIds) ||
307
+ !Array.isArray(readback.loose)) {
308
+ return [
309
+ "Archive verification is missing movement counts or the independent loose-frame listing",
310
+ ];
311
+ }
42
312
  const expected = Number(inspect.willMove ?? 0);
43
313
  const archive = readback.archive;
44
- if (expected > 0 && Number(apply.moved ?? 0) !== expected)
45
- problems.push(`moved ${apply.moved} frames, expected ${expected}`);
46
- if (expected > 0 && !archive)
314
+ if (inspect.plan.length !== expected ||
315
+ inspect.plan.some((shape) => !shape.id || !readback.insideIds.includes(shape.id))) {
316
+ problems.push("the inspected frames are not all in the archive");
317
+ }
318
+ if (expected > 0 && !archive) {
47
319
  problems.push("the archive board is not on the page");
48
- if (archive && archive.children < expected)
320
+ }
321
+ if (archive && archive.children < expected) {
49
322
  problems.push(`the archive holds ${archive.children} frames, expected at least ${expected}`);
323
+ }
50
324
  const loose = readback.loose ?? [];
51
- if (loose.length > 0)
325
+ if (loose.length > 0) {
52
326
  problems.push(`${loose.length} frames are still loose on the page: ${loose.slice(0, 3).join(", ")}`);
327
+ }
53
328
  return problems;
54
329
  },
330
+ reconcile({ before, current, readback }) {
331
+ if (!Array.isArray(before.plan) ||
332
+ !Array.isArray(current.plan) ||
333
+ !Array.isArray(readback.insideIds)) {
334
+ return "conflict";
335
+ }
336
+ if (observationHash(before, true) === observationHash(current, true)) {
337
+ return "pending";
338
+ }
339
+ const original = before.plan;
340
+ const remaining = current.plan;
341
+ const intended = new Map(original.map((shape) => [
342
+ shape.id,
343
+ shape,
344
+ ]));
345
+ if (remaining.some((shape) => !intended.has(shape.id) ||
346
+ observationHash(shape) !== observationHash(intended.get(shape.id)))) {
347
+ return "conflict";
348
+ }
349
+ const accounted = new Set([
350
+ ...remaining.map((shape) => shape.id),
351
+ ...readback.insideIds,
352
+ ]);
353
+ if (original.some((shape) => !accounted.has(shape.id))) {
354
+ return "conflict";
355
+ }
356
+ if (remaining.length > 0 && (!before.archiveId || before.archiveId !== current.archiveId)) {
357
+ return "conflict";
358
+ }
359
+ if (Array.isArray(before.insideIds) &&
360
+ before.insideIds.some((id) => !readback.insideIds.includes(id))) {
361
+ return "conflict";
362
+ }
363
+ return remaining.length === 0 ? "complete" : "pending";
364
+ },
55
365
  };
@@ -0,0 +1,23 @@
1
+ import type { ExecuteOptions, PenpotToolClient } from "./client.js";
2
+ import { type Snippet } from "./snippet.js";
3
+ interface PageIdentity {
4
+ fileId: string;
5
+ pageId: string;
6
+ }
7
+ export declare function inheritPageSession<T extends PenpotToolClient>(derived: T, base: PenpotToolClient): T;
8
+ /** Nested collection steps share one session; independent callers on the same client queue. */
9
+ export declare function withPageSession<T>(client: PenpotToolClient, work: () => Promise<T>): Promise<T>;
10
+ export declare function rememberPage(client: PenpotToolClient, identity: PageIdentity): void;
11
+ export declare function pinPageSnippet(client: PenpotToolClient, source: Snippet): Snippet;
12
+ export declare function observePage(client: PenpotToolClient, options?: ExecuteOptions): Promise<PageIdentity>;
13
+ export interface PageSwitchOptions extends ExecuteOptions {
14
+ sleep?: (ms: number) => Promise<void>;
15
+ pageSwitchTimeoutMs?: number;
16
+ onStep?: (message: string) => void;
17
+ }
18
+ export declare function selectPage(client: PenpotToolClient, target: {
19
+ name: string;
20
+ } | {
21
+ id: string;
22
+ }, options?: PageSwitchOptions): Promise<PageIdentity>;
23
+ export {};
@@ -0,0 +1,142 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { commandSignal, commandSleep } from "./command-result.js";
3
+ import { runSnippet, snippet } from "./snippet.js";
4
+ const owners = new WeakMap();
5
+ const owner = (client) => owners.get(client) ?? client;
6
+ export function inheritPageSession(derived, base) {
7
+ owners.set(derived, owner(base));
8
+ return derived;
9
+ }
10
+ const active = new AsyncLocalStorage();
11
+ const queues = new WeakMap();
12
+ /** Nested collection steps share one session; independent callers on the same client queue. */
13
+ export async function withPageSession(client, work) {
14
+ client = owner(client);
15
+ if (active.getStore()?.client === client) {
16
+ return work();
17
+ }
18
+ const previous = queues.get(client) ?? Promise.resolve();
19
+ let release;
20
+ const gate = new Promise((resolve) => {
21
+ release = resolve;
22
+ });
23
+ const tail = previous.catch(() => undefined).then(() => gate);
24
+ queues.set(client, tail);
25
+ const signal = commandSignal();
26
+ let onAbort;
27
+ try {
28
+ await new Promise((resolve, reject) => {
29
+ onAbort = () => reject(signal?.reason);
30
+ signal?.addEventListener("abort", onAbort, {
31
+ once: true,
32
+ });
33
+ if (signal?.aborted) {
34
+ onAbort();
35
+ }
36
+ previous.then(resolve, reject);
37
+ });
38
+ signal?.throwIfAborted();
39
+ return await active.run({
40
+ client,
41
+ }, work);
42
+ }
43
+ finally {
44
+ if (onAbort) {
45
+ signal?.removeEventListener("abort", onAbort);
46
+ }
47
+ release();
48
+ // A cancelled waiter still follows its predecessor; removing it early would open a parallel queue.
49
+ void tail.then(() => {
50
+ if (queues.get(client) === tail) {
51
+ queues.delete(client);
52
+ }
53
+ });
54
+ }
55
+ }
56
+ export function rememberPage(client, identity) {
57
+ const session = active.getStore();
58
+ if (session?.client === owner(client)) {
59
+ session.expected = identity;
60
+ }
61
+ }
62
+ export function pinPageSnippet(client, source) {
63
+ const session = active.getStore();
64
+ if (session?.client !== owner(client) || !session.expected) {
65
+ return source;
66
+ }
67
+ const expected = JSON.stringify(session.expected);
68
+ return {
69
+ ...source,
70
+ code: `{ const expected = ${expected}; if (penpot.currentFile.id !== expected.fileId || penpot.currentPage.id !== expected.pageId) return {ok:false,message:"Unexpected external navigation during page session"}; }\n${source.code}`,
71
+ };
72
+ }
73
+ export async function observePage(client, options = {}) {
74
+ const result = await runSnippet(client, pinPageSnippet(client, snippet("page-state", "return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id};", {}, "read")), {
75
+ ...options,
76
+ effect: "read",
77
+ retries: 0,
78
+ });
79
+ if (typeof result.fileId !== "string" ||
80
+ !result.fileId ||
81
+ typeof result.pageId !== "string" ||
82
+ !result.pageId) {
83
+ throw new Error("Page identity was not observed");
84
+ }
85
+ return {
86
+ fileId: result.fileId,
87
+ pageId: result.pageId,
88
+ };
89
+ }
90
+ export async function selectPage(client, target, options = {}) {
91
+ const timeout = Math.min(options.pageSwitchTimeoutMs ?? 5000, options.timeoutMs ?? 5000);
92
+ if (!Number.isFinite(timeout) || timeout < 1 || timeout > 30000) {
93
+ throw new Error("Invalid page switch budget");
94
+ }
95
+ const deadline = Date.now() + timeout;
96
+ const execute = () => ({
97
+ ...options,
98
+ retries: 0,
99
+ effect: "read",
100
+ timeoutMs: Math.max(1, deadline - Date.now()),
101
+ });
102
+ const resolved = await runSnippet(client, pinPageSnippet(client, snippet("select-page", `
103
+ const matches = penpot.currentFile.pages.filter(p => CFG.id ? p.id === CFG.id : p.name === CFG.name);
104
+ if (matches.length !== 1) return {ok:false,message:"Page selection is missing or ambiguous"};
105
+ const target = matches[0], fileId = penpot.currentFile.id, previousPageId = penpot.currentPage.id;
106
+ if (target.id !== previousPageId) penpot.openPage(target);
107
+ return {ok:true,fileId,pageId:target.id,previousPageId};`, target, "read")), execute());
108
+ if (typeof resolved.fileId !== "string" ||
109
+ typeof resolved.pageId !== "string" ||
110
+ typeof resolved.previousPageId !== "string") {
111
+ throw new Error("Page selection returned no identity evidence");
112
+ }
113
+ const expected = {
114
+ fileId: resolved.fileId,
115
+ pageId: resolved.pageId,
116
+ };
117
+ let observed = resolved.previousPageId;
118
+ for (let count = 0; count <= Math.ceil(timeout / 100); count++) {
119
+ commandSignal()?.throwIfAborted();
120
+ // During a requested transition either the old or selected page is legitimate.
121
+ const current = await runSnippet(client, snippet("page-switch-observation", "return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id};", {}, "read"), execute());
122
+ if (current.fileId !== expected.fileId) {
123
+ throw new Error("File changed during page switch");
124
+ }
125
+ observed = String(current.pageId);
126
+ if (Date.now() >= deadline) {
127
+ break;
128
+ }
129
+ if (observed === expected.pageId) {
130
+ rememberPage(client, expected);
131
+ return expected;
132
+ }
133
+ if (observed !== resolved.previousPageId) {
134
+ throw new Error(`Unexpected external navigation during page switch: expected ${expected.pageId}, observed ${observed}`);
135
+ }
136
+ if (Date.now() >= deadline) {
137
+ break;
138
+ }
139
+ await (options.sleep ?? commandSleep)(Math.min(100, Math.max(0, deadline - Date.now())));
140
+ }
141
+ throw new Error(`Page switch timed out: expected ${expected.pageId}, observed ${observed}, stage=observe-page`);
142
+ }
package/dist/png.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Pixel size from a PNG's header (the IHDR chunk), or nothing when the bytes are not a PNG. */
2
+ export declare function pngSize(buffer: Buffer): {
3
+ width: number;
4
+ height: number;
5
+ } | undefined;
package/dist/png.js ADDED
@@ -0,0 +1,22 @@
1
+ /** Pixel size from a PNG's header (the IHDR chunk), or nothing when the bytes are not a PNG. */
2
+ export function pngSize(buffer) {
3
+ const signature = Buffer.from([
4
+ 0x89,
5
+ 0x50,
6
+ 0x4e,
7
+ 0x47,
8
+ 0x0d,
9
+ 0x0a,
10
+ 0x1a,
11
+ 0x0a,
12
+ ]);
13
+ if (buffer.length < 24 ||
14
+ !buffer.subarray(0, 8).equals(signature) ||
15
+ buffer.subarray(12, 16).toString("ascii") !== "IHDR") {
16
+ return undefined;
17
+ }
18
+ return {
19
+ width: buffer.readUInt32BE(16),
20
+ height: buffer.readUInt32BE(20),
21
+ };
22
+ }
@@ -0,0 +1,22 @@
1
+ /** Deliberately bounded subset of the published DTCG 2025.10 format; no Penpot arithmetic or native writes. */
2
+ export interface PortableToken {
3
+ path: string[];
4
+ type: string;
5
+ value: unknown;
6
+ description?: string;
7
+ extensions?: unknown;
8
+ }
9
+ export interface PortableTokens {
10
+ schemaVersion: 1;
11
+ kind: "portable-tokens";
12
+ format: "dtcg-2025.10-subset";
13
+ tokens: PortableToken[];
14
+ groups: Record<string, {
15
+ type?: string;
16
+ description?: string;
17
+ extensions?: unknown;
18
+ }>;
19
+ losses: string[];
20
+ }
21
+ export declare function importDtcg(raw: unknown): PortableTokens;
22
+ export declare function exportDtcg(raw: unknown): Record<string, unknown>;