@penpair/core 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1645 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-check-entry.d.ts +17 -0
  66. package/dist/skill-check-entry.js +89 -0
  67. package/dist/skill-check-instructions.d.ts +7 -0
  68. package/dist/skill-check-instructions.js +39 -0
  69. package/dist/skill-check.d.ts +15 -0
  70. package/dist/skill-check.js +36 -0
  71. package/dist/skill-file.d.ts +2 -0
  72. package/dist/skill-file.js +31 -0
  73. package/dist/skill-instructions.d.ts +4 -0
  74. package/dist/skill-instructions.js +97 -22
  75. package/dist/skill-metadata.d.ts +8 -0
  76. package/dist/skill-metadata.js +52 -0
  77. package/dist/skill-routing.d.ts +2 -0
  78. package/dist/skill-routing.js +36 -0
  79. package/dist/skills.js +131 -41
  80. package/dist/snippet.js +27 -7
  81. package/dist/snippets/read.js +8 -2
  82. package/dist/structure-snapshot.d.ts +12 -0
  83. package/dist/structure-snapshot.js +439 -85
  84. package/dist/token-edit.js +219 -64
  85. package/dist/token-projection.d.ts +56 -0
  86. package/dist/token-projection.js +286 -0
  87. package/dist/tokens-diff.js +125 -32
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +3 -2
  91. package/skills/design-judgment/SKILL.md +1 -1
  92. package/skills/design-judgment/agents/openai.yaml +6 -0
  93. package/skills/penpair-design/SKILL.md +14 -1
  94. package/skills/penpair-design/agents/openai.yaml +6 -0
  95. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  96. package/skills/penpair-design/references/project-facts.md +2 -0
  97. package/skills/penpair-handoff/SKILL.md +14 -1
  98. package/skills/penpair-handoff/agents/openai.yaml +6 -0
  99. package/skills/penpair-handoff/references/comparison-companion.md +16 -0
  100. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  101. package/skills/penpair-recipes/SKILL.md +13 -1
  102. package/skills/penpair-recipes/agents/openai.yaml +6 -0
  103. package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
  104. package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
  105. package/skills/penpair-setup/SKILL.md +9 -3
  106. package/skills/penpair-setup/agents/openai.yaml +6 -0
  107. package/skills/penpair-verify/SKILL.md +49 -0
  108. package/skills/penpair-verify/agents/openai.yaml +6 -0
  109. package/skills/penpair-verify/references/browser-evidence.md +33 -0
  110. package/dist/cli.d.ts +0 -2
  111. package/dist/cli.js +0 -3
  112. package/dist/docker/lifecycle.d.ts +0 -96
  113. package/dist/docker/lifecycle.js +0 -273
  114. package/dist/docker/secrets.d.ts +0 -11
  115. package/dist/docker/secrets.js +0 -38
  116. package/dist/docker/template.d.ts +0 -8
  117. package/dist/docker/template.js +0 -158
  118. package/dist/docker-profile.d.ts +0 -114
  119. package/dist/docker-profile.js +0 -18
  120. package/dist/instance/lifecycle.d.ts +0 -129
  121. package/dist/instance/lifecycle.js +0 -336
  122. package/dist/instance/secrets.d.ts +0 -11
  123. package/dist/instance/secrets.js +0 -38
  124. package/dist/instance/template.d.ts +0 -8
  125. package/dist/instance/template.js +0 -158
package/dist/operation.js CHANGED
@@ -1,155 +1,365 @@
1
1
  import { waitForStableLayout } from "./layout.js";
2
2
  import { commandSleep } from "./command-result.js";
3
3
  import { runSnippet } from "./snippet.js";
4
- import { switchPage } from "./snippets/read.js";
5
- import { archiveApply, archiveInspect, archiveReadback } from "./snippets/archive.js";
4
+ import { withPageSession, pinPageSnippet, selectPage, rememberPage } from "./page-session.js";
5
+ import { archiveApply, archiveInspect, archiveReadback, } from "./snippets/archive.js";
6
6
  import { PenpairError } from "./errors.js";
7
7
  import { UncertainExecutionError } from "./client.js";
8
- import { observationHash, readJournalTarget, runJournaledSteps } from "./journal.js";
8
+ import { observationHash, readJournalTarget, runJournaledSteps, } from "./journal.js";
9
9
  import { guardRevision, writeCoordinator } from "./coordination.js";
10
10
  import { guardFile } from "./file-guard.js";
11
11
  const defaultSleep = commandSleep;
12
- /** Runs a snippet; if the editor shows another page, opens the right one, waits, and tries once more. */
13
- export async function runOnPage(client, s, page, { settleMs = 3_000, sleep = defaultSleep, onStep, ...execute } = {}) {
14
- const first = await runSnippet(client, s, execute);
15
- if (!first.wrongPage || !page)
16
- return first;
17
- onStep?.(`opening page ${page}`);
18
- await runSnippet(client, switchPage(page), execute);
19
- await sleep(settleMs);
20
- const second = await runSnippet(client, s, execute);
21
- if (second.wrongPage)
22
- throw new Error(`${s.name}: the editor still shows another page than ${page}`);
23
- return second;
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
+ });
24
47
  }
25
48
  export async function runOperation(client, operation, params, options = {}) {
26
- if (options.settleMs !== undefined && (!Number.isFinite(options.settleMs) || options.settleMs < 0 || options.settleMs > 30000))
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)) {
27
54
  throw new Error("Minimum settle delay must be from 0 to 30000 ms");
28
- if (options.apply && options.journal)
55
+ }
56
+ if (options.apply && options.journal) {
29
57
  return journaledOperation(client, operation, params, options);
58
+ }
30
59
  const { settleMs = 0, sleep = defaultSleep, onStep } = options;
31
60
  const page = operation.page(params);
32
61
  onStep?.(`${operation.name}: inspecting`);
33
- const inspect = await runOnPage(client, operation.inspect(params), page, { ...options, effect: "read" });
34
- if (inspect.ok !== true)
62
+ const inspect = await runOnPage(client, operation.inspect(params), page, {
63
+ ...options,
64
+ effect: "read",
65
+ });
66
+ if (inspect.ok !== true) {
35
67
  throw new PenpairError("tool-failed", `${operation.name}: inspect did not return an explicit ok result; nothing was applied`);
36
- if (!options.apply)
37
- return { operation: operation.name, outcome: "inspected", phase: "inspected", inspect, problems: [], verified: false };
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
+ }
38
79
  onStep?.(`${operation.name}: writing once`);
39
- const apply = await runOnPage(client, operation.apply(params, inspect), page, { ...options, effect: "write" });
40
- if (apply.ok !== true)
80
+ const apply = await runOnPage(client, operation.apply(params, inspect), page, {
81
+ ...options,
82
+ effect: "write",
83
+ });
84
+ if (apply.ok !== true) {
41
85
  throw new UncertainExecutionError(String(apply.execution?.runId ?? "unknown"), `${operation.name}: apply returned no explicit acknowledgment`);
42
- if (settleMs > 0)
86
+ }
87
+ if (settleMs > 0) {
43
88
  await sleep(settleMs);
89
+ }
44
90
  let layout;
45
91
  let readback;
46
92
  let problems;
47
93
  try {
48
94
  layout = await settleOperation(client, options);
49
- readback = await runOnPage(client, operation.readback(params), page, { ...options, effect: "read" });
50
- problems = verificationProblems(operation, { inspect, apply, readback });
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
+ });
51
104
  }
52
105
  catch (error) {
53
- problems = [`Independent verification failed: ${error instanceof Error ? error.message : String(error)}`];
106
+ problems = [
107
+ `Independent verification failed: ${error instanceof Error ? error.message : String(error)}`,
108
+ ];
54
109
  }
55
- return { layout, operation: operation.name, phase: "applied", outcome: problems.length === 0 ? "verified" : "failed", inspect, apply, readback, problems, verified: problems.length === 0 };
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
+ };
56
121
  }
57
122
  function verificationProblems(operation, results) {
58
123
  const { readback } = results;
59
- const observations = Object.keys(readback).filter((key) => !["ok", "readOnly", "execution", "problems"].includes(key));
60
- if (Array.isArray(readback.problems) && readback.problems.length > 0 && readback.problems.every((problem) => typeof problem === "string"))
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")) {
61
133
  return readback.problems;
62
- if (readback.ok !== true || observations.length === 0)
63
- return ["Readback did not provide explicit observations of the resulting state"];
134
+ }
135
+ if (readback.ok !== true || observations.length === 0) {
136
+ return [
137
+ "Readback did not provide explicit observations of the resulting state",
138
+ ];
139
+ }
64
140
  const result = operation.verify(results);
65
- return Array.isArray(result) && result.every((problem) => typeof problem === "string") ? result : ["Verifier must return a list of problems"];
141
+ return Array.isArray(result) && result.every((problem) => typeof problem === "string")
142
+ ? result
143
+ : [
144
+ "Verifier must return a list of problems",
145
+ ];
66
146
  }
67
147
  async function settleOperation(client, options) {
68
- if (options.layout === false)
148
+ if (options.layout === false) {
69
149
  return undefined;
150
+ }
70
151
  options.onStep?.("waiting for bounded layout completion");
71
- const layout = await waitForStableLayout(client, { sleep: options.sleep, ...options.layout });
72
- if (!layout.stable)
152
+ const layout = await waitForStableLayout(client, {
153
+ sleep: options.sleep,
154
+ ...options.layout,
155
+ });
156
+ if (!layout.stable) {
73
157
  throw new PenpairError("incomplete-output", `Layout did not settle: ${layout.reason ?? "unknown reason"}`);
158
+ }
74
159
  return layout;
75
160
  }
76
161
  async function journaledOperation(client, operation, params, options) {
77
162
  const page = operation.page(params);
78
- await runOnPage(client, operation.inspect(params), page, { ...options, effect: "read" });
163
+ await runOnPage(client, operation.inspect(params), page, {
164
+ ...options,
165
+ effect: "read",
166
+ });
79
167
  await settleOperation(client, options);
80
168
  const initialTarget = await readJournalTarget(client);
81
- const guarded = guardFile(client, { id: initialTarget.fileId });
169
+ const guarded = guardFile(client, {
170
+ id: initialTarget.fileId,
171
+ });
82
172
  const observe = async () => {
83
- const inspection = await runOnPage(guarded, operation.inspect(params), page, { ...options, effect: "read" });
173
+ const inspection = await runOnPage(guarded, operation.inspect(params), page, {
174
+ ...options,
175
+ effect: "read",
176
+ });
84
177
  const layout = await settleOperation(guarded, options);
85
- const readback = await runOnPage(guarded, operation.readback(params), page, { ...options, effect: "read" });
86
- if (inspection.ok !== true || readback.ok !== true)
178
+ const readback = await runOnPage(guarded, operation.readback(params), page, {
179
+ ...options,
180
+ effect: "read",
181
+ });
182
+ if (inspection.ok !== true || readback.ok !== true) {
87
183
  throw new Error("Operation inspection/readback did not acknowledge its observations");
88
- return { inspection, readback, layout };
184
+ }
185
+ return {
186
+ inspection,
187
+ readback,
188
+ layout,
189
+ };
89
190
  };
90
- const verify = (current, before, result) => verificationProblems(operation, { inspect: before.inspection, apply: result ?? { ok: true, reconciled: true }, readback: current.readback }).length === 0;
91
- const record = await runJournaledSteps([{ key: "apply", label: operation.name,
92
- signature: [operation.inspect(params).code, operation.apply(params).code, operation.readback(params).code, operation.verify.toString(), operation.reconcile?.toString(), settleOperation.toString(), waitForStableLayout.toString()].join("\n"),
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"),
93
212
  inspect: observe,
94
213
  apply: async (current, target) => {
95
- const result = await runOnPage(guardRevision(guarded, target), operation.apply(params, current.inspection), page, { ...options, effect: "write" });
96
- if (result.ok !== true)
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) {
97
219
  throw new Error("Apply returned no explicit acknowledgment");
220
+ }
98
221
  await (options.sleep ?? defaultSleep)(options.settleMs ?? 0);
99
222
  return result;
100
- }, verify,
223
+ },
224
+ verify,
101
225
  reconcile: (before, current, result) => {
102
- if (verify(current, before, result))
226
+ if (verify(current, before, result)) {
103
227
  return "complete";
104
- if (operation.reconcile)
105
- return operation.reconcile({ before: before.inspection, current: current.inspection, readback: current.readback });
106
- return observationHash(before.inspection, true) === observationHash(current.inspection, true) ? "pending" : "conflict";
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";
107
240
  },
108
- }], { ...options.journal, coordinate: writeCoordinator(guarded, { root: options.journal.root, operation: operation.name, forceLock: options.journal.forceLock, warn: options.onStep }), operation: operation.name, input: { params, layout: options.layout ?? {}, minimumWaitMs: options.settleMs ?? 0 }, payload: { params }, target: () => readJournalTarget(guarded), onStep: options.onStep ?? options.journal.onStep, redact: options.redact ?? options.journal.redact });
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
+ });
109
263
  const saved = record.steps[0];
110
- return { layout: saved.after.layout, operation: operation.name, phase: "applied", outcome: record.redacted ? "failed" : "verified", ...(record.redacted ? { redacted: true, complete: false } : {}), inspect: saved.before.inspection, apply: saved.result, readback: saved.after.readback, problems: record.redacted ? ["Journal evidence required credential redaction; completeness cannot be confirmed"] : [], verified: !record.redacted, journal: { runId: record.runId, state: record.state } };
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
+ };
111
289
  }
112
290
  /** Nests the frames that are not part of the live work into one archive board. Frames keep their names, ids and component links. */
113
291
  export const archiveOperation = {
114
292
  name: "archive",
115
293
  page: (params) => params.page,
116
294
  inspect: archiveInspect,
117
- apply: (params, inspected) => archiveApply({ ...params, expectedPlan: inspected?.plan, expectedArchiveId: inspected?.archiveId }),
295
+ apply: (params, inspected) => archiveApply({
296
+ ...params,
297
+ expectedPlan: inspected?.plan,
298
+ expectedArchiveId: inspected?.archiveId,
299
+ }),
118
300
  readback: archiveReadback,
119
301
  verify({ inspect, apply, readback }) {
120
302
  const problems = [];
121
- if (!Number.isInteger(inspect.willMove) || Number(inspect.willMove) < 0 || !Array.isArray(inspect.plan) || !Array.isArray(readback.insideIds) || !Array.isArray(readback.loose))
122
- return ["Archive verification is missing movement counts or the independent loose-frame listing"];
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
+ }
123
312
  const expected = Number(inspect.willMove ?? 0);
124
313
  const archive = readback.archive;
125
- if (inspect.plan.length !== expected || inspect.plan.some((shape) => !shape.id || !readback.insideIds.includes(shape.id)))
314
+ if (inspect.plan.length !== expected ||
315
+ inspect.plan.some((shape) => !shape.id || !readback.insideIds.includes(shape.id))) {
126
316
  problems.push("the inspected frames are not all in the archive");
127
- if (expected > 0 && !archive)
317
+ }
318
+ if (expected > 0 && !archive) {
128
319
  problems.push("the archive board is not on the page");
129
- if (archive && archive.children < expected)
320
+ }
321
+ if (archive && archive.children < expected) {
130
322
  problems.push(`the archive holds ${archive.children} frames, expected at least ${expected}`);
323
+ }
131
324
  const loose = readback.loose ?? [];
132
- if (loose.length > 0)
325
+ if (loose.length > 0) {
133
326
  problems.push(`${loose.length} frames are still loose on the page: ${loose.slice(0, 3).join(", ")}`);
327
+ }
134
328
  return problems;
135
329
  },
136
330
  reconcile({ before, current, readback }) {
137
- if (!Array.isArray(before.plan) || !Array.isArray(current.plan) || !Array.isArray(readback.insideIds))
331
+ if (!Array.isArray(before.plan) ||
332
+ !Array.isArray(current.plan) ||
333
+ !Array.isArray(readback.insideIds)) {
138
334
  return "conflict";
139
- if (observationHash(before, true) === observationHash(current, true))
335
+ }
336
+ if (observationHash(before, true) === observationHash(current, true)) {
140
337
  return "pending";
338
+ }
141
339
  const original = before.plan;
142
340
  const remaining = current.plan;
143
- const intended = new Map(original.map((shape) => [shape.id, shape]));
144
- if (remaining.some((shape) => !intended.has(shape.id) || observationHash(shape) !== observationHash(intended.get(shape.id))))
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)))) {
145
347
  return "conflict";
146
- const accounted = new Set([...remaining.map((shape) => shape.id), ...readback.insideIds]);
147
- if (original.some((shape) => !accounted.has(shape.id)))
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))) {
148
354
  return "conflict";
149
- if (remaining.length > 0 && (!before.archiveId || before.archiveId !== current.archiveId))
355
+ }
356
+ if (remaining.length > 0 && (!before.archiveId || before.archiveId !== current.archiveId)) {
150
357
  return "conflict";
151
- if (Array.isArray(before.insideIds) && before.insideIds.some((id) => !readback.insideIds.includes(id)))
358
+ }
359
+ if (Array.isArray(before.insideIds) &&
360
+ before.insideIds.some((id) => !readback.insideIds.includes(id))) {
152
361
  return "conflict";
362
+ }
153
363
  return remaining.length === 0 ? "complete" : "pending";
154
364
  },
155
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.js CHANGED
@@ -1,7 +1,22 @@
1
1
  /** Pixel size from a PNG's header (the IHDR chunk), or nothing when the bytes are not a PNG. */
2
2
  export function pngSize(buffer) {
3
- const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
4
- if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature) || buffer.subarray(12, 16).toString("ascii") !== "IHDR")
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") {
5
16
  return undefined;
6
- return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
17
+ }
18
+ return {
19
+ width: buffer.readUInt32BE(16),
20
+ height: buffer.readUInt32BE(20),
21
+ };
7
22
  }