@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.196.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 (216) hide show
  1. package/README.md +822 -135
  2. package/bin/check-runtime.js +15 -0
  3. package/bin/notis.js +2 -0
  4. package/config/notis_app_boundary_rules.json +50 -0
  5. package/config/notis_app_design_rules.json +135 -0
  6. package/config/portal-route-roots.json +48 -0
  7. package/dist/agent-hooks/notis-agent-hook.mjs +45587 -0
  8. package/dist/base-skills/notis-apps/SKILL.md +93 -0
  9. package/dist/base-skills/notis-apps/references/architecture.md +114 -0
  10. package/dist/base-skills/notis-apps/references/context.md +81 -0
  11. package/dist/base-skills/notis-apps/references/design.md +97 -0
  12. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  13. package/dist/base-skills/notis-apps/references/reading.md +67 -0
  14. package/dist/base-skills/notis-apps/references/release.md +107 -0
  15. package/dist/base-skills/notis-apps/references/sdk.md +116 -0
  16. package/dist/base-skills/notis-apps/references/troubleshooting.md +24 -0
  17. package/dist/base-skills/notis-apps/references/views.md +142 -0
  18. package/dist/base-skills/notis-cli/SKILL.md +154 -0
  19. package/dist/base-skills/notis-cli/references/app-delivery.md +19 -0
  20. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  21. package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
  22. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  23. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  24. package/dist/base-skills/notis-query/SKILL.md +41 -0
  25. package/dist/base-skills/notis-query/references/database-discovery.md +60 -0
  26. package/dist/base-skills/notis-query/references/documents.md +63 -0
  27. package/dist/base-skills/notis-query/references/query.md +76 -0
  28. package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
  29. package/dist/sdk/src/agentContext.ts +36 -0
  30. package/dist/sdk/src/bundleModule.ts +79 -0
  31. package/dist/sdk/src/components/Dialog.tsx +46 -0
  32. package/dist/sdk/src/components/DocumentEditor.tsx +106 -0
  33. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  34. package/dist/sdk/src/components/Markdown.tsx +60 -0
  35. package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
  36. package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  37. package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  38. package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  39. package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  40. package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  41. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  42. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  43. package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
  44. package/dist/sdk/src/components/Skeleton.tsx +24 -0
  45. package/dist/sdk/src/config.ts +239 -0
  46. package/dist/sdk/src/documents.ts +258 -0
  47. package/dist/sdk/src/hooks/useAction.ts +28 -0
  48. package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
  49. package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
  50. package/dist/sdk/src/hooks/useCloudComputer.ts +95 -0
  51. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  52. package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
  53. package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  54. package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  55. package/dist/sdk/src/hooks/useDocument.ts +43 -0
  56. package/dist/sdk/src/hooks/useDocuments.ts +84 -0
  57. package/dist/sdk/src/hooks/useHandover.ts +78 -0
  58. package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
  59. package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
  60. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  61. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  62. package/dist/sdk/src/hooks/useNotis.ts +42 -0
  63. package/dist/sdk/src/hooks/useNotisNavigation.ts +67 -0
  64. package/dist/sdk/src/hooks/useQuery.ts +74 -0
  65. package/dist/sdk/src/hooks/useTool.ts +65 -0
  66. package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
  67. package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
  68. package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
  69. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  70. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  71. package/dist/sdk/src/index.ts +200 -0
  72. package/dist/sdk/src/interactions/actions.ts +59 -0
  73. package/dist/sdk/src/interactions/shortcuts.tsx +740 -0
  74. package/dist/sdk/src/interactions/visibility.ts +13 -0
  75. package/dist/sdk/src/interactions.ts +45 -0
  76. package/dist/sdk/src/nativeData.ts +39 -0
  77. package/dist/sdk/src/presentation.tsx +82 -0
  78. package/dist/sdk/src/provider.tsx +49 -0
  79. package/dist/sdk/src/queryCache.ts +172 -0
  80. package/dist/sdk/src/queryErrors.ts +51 -0
  81. package/dist/sdk/src/runtime.ts +603 -0
  82. package/dist/sdk/src/space.ts +425 -0
  83. package/dist/sdk/src/styles.css +290 -0
  84. package/dist/sdk/src/tailwind.ts +66 -0
  85. package/dist/sdk/src/vite.ts +83 -0
  86. package/dist/skill-sync/index.js +2143 -0
  87. package/dist/skill-sync/index.js.map +7 -0
  88. package/dist/skill-sync-worker.mjs +3487 -0
  89. package/dist/space-harness/host.js +66 -0
  90. package/dist/view-renderer/dist/host.css +1 -0
  91. package/dist/view-renderer/dist/host.js +1042 -0
  92. package/dist/view-renderer/dist/theme.css +73 -0
  93. package/dist/view-renderer/src/dom.js +180 -0
  94. package/dist/view-renderer/src/errors.js +8 -0
  95. package/dist/view-renderer/src/host.tsx +173 -0
  96. package/dist/view-renderer/src/index.js +4 -0
  97. package/dist/view-renderer/src/pool.js +76 -0
  98. package/dist/view-renderer/src/remote-broker.js +59 -0
  99. package/dist/view-renderer/src/renderer.js +201 -0
  100. package/package.json +25 -8
  101. package/skills/notis-apps/cli.md +406 -0
  102. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  103. package/skills/notis-onboarding/BRIEF.md +133 -0
  104. package/skills/notis-query/cli.md +39 -0
  105. package/src/agent-hook-entry.js +5 -0
  106. package/src/cli.js +294 -25
  107. package/src/command-specs/agents.js +392 -0
  108. package/src/command-specs/apps.js +1476 -203
  109. package/src/command-specs/auth.js +114 -137
  110. package/src/command-specs/diagnostics.js +730 -0
  111. package/src/command-specs/files.js +29 -0
  112. package/src/command-specs/handover.js +374 -0
  113. package/src/command-specs/helpers.js +86 -82
  114. package/src/command-specs/index.js +31 -6
  115. package/src/command-specs/meta.js +150 -18
  116. package/src/command-specs/onboarding.js +290 -0
  117. package/src/command-specs/profile.js +358 -0
  118. package/src/command-specs/skills.js +304 -0
  119. package/src/command-specs/smoke.js +386 -0
  120. package/src/command-specs/spaces.js +572 -0
  121. package/src/command-specs/tools.js +455 -139
  122. package/src/command-specs/views.js +69 -0
  123. package/src/runtime/agent-browser.js +700 -0
  124. package/src/runtime/agent-memory-state.js +126 -0
  125. package/src/runtime/agent-setup.js +383 -0
  126. package/src/runtime/app-boundary-validator.js +407 -0
  127. package/src/runtime/app-changelog.js +79 -0
  128. package/src/runtime/app-data-literals.js +82 -0
  129. package/src/runtime/app-platform.js +2752 -237
  130. package/src/runtime/app-registry-scaffolds.js +367 -0
  131. package/src/runtime/app-test-server.js +293 -0
  132. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  133. package/src/runtime/auth-recovery.js +110 -0
  134. package/src/runtime/base-skills.d.ts +20 -0
  135. package/src/runtime/base-skills.js +167 -0
  136. package/src/runtime/channel.js +133 -0
  137. package/src/runtime/confined-space-build.js +74 -0
  138. package/src/runtime/confined-space-verify.js +49 -0
  139. package/src/runtime/delegated-context.js +68 -0
  140. package/src/runtime/errors.js +1 -0
  141. package/src/runtime/git.js +233 -0
  142. package/src/runtime/login-listener.js +15 -0
  143. package/src/runtime/native-record-files.js +47 -0
  144. package/src/runtime/oauth.js +2622 -0
  145. package/src/runtime/output.js +37 -5
  146. package/src/runtime/ports.js +31 -0
  147. package/src/runtime/profiles.js +906 -55
  148. package/src/runtime/retired-sdk-files.js +20 -0
  149. package/src/runtime/screenshot-framing.js +85 -0
  150. package/src/runtime/skill-sync/cloud-client.ts +121 -0
  151. package/src/runtime/skill-sync/index.ts +905 -0
  152. package/src/runtime/skill-sync/local-scanner.ts +1259 -0
  153. package/src/runtime/skill-sync/native-identity.ts +102 -0
  154. package/src/runtime/skill-sync/native-plan.ts +59 -0
  155. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  156. package/src/runtime/skill-sync/sync-plan.ts +54 -0
  157. package/src/runtime/skill-sync/types.ts +157 -0
  158. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  159. package/src/runtime/skill-sync-service.js +109 -0
  160. package/src/runtime/source-root.js +32 -0
  161. package/src/runtime/space-collection-scaffold.js +66 -0
  162. package/src/runtime/space-database-structures.js +229 -0
  163. package/src/runtime/space-fixture-broker.js +50 -0
  164. package/src/runtime/space-lock.js +337 -0
  165. package/src/runtime/space-platform.js +105 -0
  166. package/src/runtime/space-source.js +281 -0
  167. package/src/runtime/space-test-server.js +327 -0
  168. package/src/runtime/space-verification.js +89 -0
  169. package/src/runtime/space-verify-browser-worker.js +51 -0
  170. package/src/runtime/space-view-manifest.js +254 -0
  171. package/src/runtime/store-screenshot.js +146 -0
  172. package/src/runtime/sync-skills.d.ts +37 -0
  173. package/src/runtime/sync-skills.js +231 -0
  174. package/src/runtime/telemetry.js +92 -0
  175. package/src/runtime/transport.js +331 -45
  176. package/src/runtime/view-render.js +49 -0
  177. package/src/skill-sync-worker-entry.js +2 -0
  178. package/src/skill-sync-worker.js +50 -0
  179. package/src/space-harness-host.ts +103 -0
  180. package/template/.harness/index.html.tmpl +435 -0
  181. package/template/CHANGELOG.md +5 -0
  182. package/template/app/globals.css +29 -3
  183. package/template/app/layout.tsx +6 -3
  184. package/template/app/page.tsx +49 -42
  185. package/template/components/page-heading.tsx +23 -0
  186. package/template/components/ui/badge.tsx +7 -4
  187. package/template/components/ui/button.tsx +1 -1
  188. package/template/components/ui/card.tsx +24 -11
  189. package/template/components/ui/native-select.tsx +24 -0
  190. package/template/notis.config.ts +24 -6
  191. package/template/package-lock.json +3642 -0
  192. package/template/package.json +19 -16
  193. package/template/postcss.config.mjs +1 -1
  194. package/template/tailwind.config.ts +1 -6
  195. package/template/tsconfig.json +1 -0
  196. package/src/command-specs/db.js +0 -163
  197. package/src/runtime/app-preview-server.js +0 -312
  198. package/template/packages/notis-sdk/src/config.ts +0 -48
  199. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  200. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  201. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  202. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  203. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  204. package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
  205. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
  206. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  207. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  208. package/template/packages/notis-sdk/src/index.ts +0 -47
  209. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  210. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  211. package/template/packages/notis-sdk/src/styles.css +0 -123
  212. package/template/packages/notis-sdk/src/vite.ts +0 -54
  213. package/template/packages/notis-sdk/tsconfig.json +0 -15
  214. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
  215. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
  216. /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
@@ -0,0 +1,700 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { contentCapturePlan, MEASURE_SCREENSHOT_CONTENT_SCRIPT, SAFE_VIEWPORT_HEIGHT_SCRIPT } from './screenshot-framing.js';
5
+
6
+ /**
7
+ * Prepare the mounted harness page for a listing capture: remove the 100vh
8
+ * min-height the harness adds around the app so content height can be
9
+ * measured and framed without artificial whitespace.
10
+ */
11
+ const PREPARE_CAPTURE_SCRIPT =
12
+ "(() => { const r = document.getElementById('root'); if (r) r.style.minHeight = '0';" +
13
+ " document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
14
+
15
+ const FONTS_STATUS_SCRIPT =
16
+ "(() => (document.fonts ? document.fonts.status : 'loaded'))()";
17
+
18
+ const CONTENT_HEIGHT_SCRIPT =
19
+ '(() => { const r = document.getElementById(\'root\');' +
20
+ ' if (!r) return Math.ceil(document.documentElement.scrollHeight);' +
21
+ ' return Math.ceil(Math.max(r.scrollHeight, r.getBoundingClientRect().height)); })()';
22
+
23
+ // CSS viewport bounds for content-aware framing. Content shorter than the
24
+ // minimum gets a little breathing room; content taller than the maximum is
25
+ // cropped at the frame rather than shrunk into unreadably small text.
26
+ const MIN_CSS_VIEWPORT_HEIGHT = 720;
27
+ const MAX_CSS_VIEWPORT_HEIGHT = 1500;
28
+
29
+ function pngDimensions(path) {
30
+ try {
31
+ const buffer = readFileSync(path);
32
+ if (buffer.length < 24 || buffer.readUInt32BE(12) !== 0x49484452) {
33
+ return null;
34
+ }
35
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Pick a CSS viewport that frames `contentHeight` at the output aspect ratio,
43
+ * with a deviceScaleFactor that maps it back to exactly outputWidth x
44
+ * outputHeight physical pixels. Width snaps to a multiple of 8 so the height
45
+ * stays integral for the common 16:10 target.
46
+ */
47
+ function fitCssViewport(contentHeight, outputWidth, outputHeight) {
48
+ const aspect = outputWidth / outputHeight;
49
+ const cssH = Math.min(
50
+ MAX_CSS_VIEWPORT_HEIGHT,
51
+ Math.max(MIN_CSS_VIEWPORT_HEIGHT, Math.round(contentHeight)),
52
+ );
53
+ const cssW = Math.round((cssH * aspect) / 8) * 8;
54
+ return {
55
+ width: cssW,
56
+ height: Math.round(cssW / aspect),
57
+ scale: outputWidth / cssW,
58
+ };
59
+ }
60
+
61
+ async function evalNumber(sessionName, script, fallback = null) {
62
+ const result = await runAgentBrowser(
63
+ ['--session', sessionName, '--json', 'eval', script],
64
+ { timeoutMs: 5000 },
65
+ );
66
+ if (result.exitCode !== 0) {
67
+ return fallback;
68
+ }
69
+ try {
70
+ const payload = parseAgentBrowserJson(result.stdout);
71
+ const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
72
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
73
+ return Number.isFinite(value) ? value : fallback;
74
+ } catch {
75
+ return fallback;
76
+ }
77
+ }
78
+
79
+ async function waitForFonts(sessionName, timeoutMs = 3000) {
80
+ const deadline = Date.now() + timeoutMs;
81
+ while (Date.now() < deadline) {
82
+ const result = await runAgentBrowser(
83
+ ['--session', sessionName, '--json', 'eval', FONTS_STATUS_SCRIPT],
84
+ { timeoutMs: 5000 },
85
+ );
86
+ if (result.exitCode !== 0) {
87
+ return;
88
+ }
89
+ try {
90
+ const payload = parseAgentBrowserJson(result.stdout);
91
+ const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
92
+ const status = typeof raw === 'string' && raw.startsWith('"') ? JSON.parse(raw) : raw;
93
+ if (status !== 'loading') {
94
+ return;
95
+ }
96
+ } catch {
97
+ return;
98
+ }
99
+ await delay(150);
100
+ }
101
+ }
102
+
103
+ async function setViewport(sessionName, width, height, scale = null) {
104
+ const args = ['--session', sessionName, 'set', 'viewport', String(width), String(height)];
105
+ if (scale != null) {
106
+ args.push(String(scale));
107
+ }
108
+ const result = await runAgentBrowser(args, { timeoutMs: 5000 });
109
+ return result.exitCode === 0;
110
+ }
111
+
112
+ function delay(ms) {
113
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
114
+ }
115
+
116
+ function commandError(phase, result) {
117
+ return {
118
+ phase,
119
+ exit_code: result.exitCode,
120
+ stdout: result.stdout.trim(),
121
+ stderr: result.stderr.trim(),
122
+ };
123
+ }
124
+
125
+ function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
126
+ return new Promise((resolvePromise) => {
127
+ const child = spawn('agent-browser', args, {
128
+ stdio: ['ignore', 'pipe', 'pipe'],
129
+ env: process.env,
130
+ });
131
+ let stdout = '';
132
+ let stderr = '';
133
+ let timedOut = false;
134
+ let hardStop;
135
+ const timer = setTimeout(() => {
136
+ timedOut = true;
137
+ child.kill('SIGTERM');
138
+ hardStop = setTimeout(() => child.kill('SIGKILL'), 2000);
139
+ }, timeoutMs);
140
+
141
+ child.stdout.on('data', (chunk) => {
142
+ stdout += chunk.toString();
143
+ });
144
+ child.stderr.on('data', (chunk) => {
145
+ stderr += chunk.toString();
146
+ });
147
+ child.on('error', (error) => {
148
+ clearTimeout(timer); clearTimeout(hardStop);
149
+ resolvePromise({
150
+ exitCode: 1,
151
+ stdout,
152
+ stderr: stderr || error.message,
153
+ timedOut,
154
+ });
155
+ });
156
+ child.on('close', (code) => {
157
+ clearTimeout(timer); clearTimeout(hardStop);
158
+ resolvePromise({
159
+ exitCode: timedOut ? 124 : (code ?? 1),
160
+ stdout,
161
+ stderr,
162
+ timedOut,
163
+ });
164
+ });
165
+ });
166
+ }
167
+
168
+ function parseAgentBrowserJson(stdout) {
169
+ const trimmed = stdout.trim();
170
+ if (!trimmed) {
171
+ return null;
172
+ }
173
+ return JSON.parse(trimmed);
174
+ }
175
+
176
+ function parseHarnessFromEval(stdout) {
177
+ const payload = parseAgentBrowserJson(stdout);
178
+ const rawResult = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
179
+ if (rawResult == null || rawResult === 'null') {
180
+ return null;
181
+ }
182
+ return typeof rawResult === 'string' ? JSON.parse(rawResult) : rawResult;
183
+ }
184
+
185
+ async function readHarness(sessionName, timeoutMs) {
186
+ const result = await runAgentBrowser(
187
+ [
188
+ '--session',
189
+ sessionName,
190
+ '--json',
191
+ 'eval',
192
+ 'JSON.stringify(window.__harness || null)',
193
+ ],
194
+ { timeoutMs },
195
+ );
196
+ if (result.exitCode !== 0) {
197
+ return { ok: false, toolError: commandError('eval', result) };
198
+ }
199
+
200
+ try {
201
+ return { ok: true, harness: parseHarnessFromEval(result.stdout) };
202
+ } catch (error) {
203
+ return {
204
+ ok: false,
205
+ toolError: {
206
+ phase: 'eval_parse',
207
+ message: error instanceof Error ? error.message : String(error),
208
+ stdout: result.stdout.trim(),
209
+ stderr: result.stderr.trim(),
210
+ },
211
+ };
212
+ }
213
+ }
214
+
215
+ export function isAgentBrowserAvailable() {
216
+ const result = spawnSync('agent-browser', ['--version'], {
217
+ stdio: 'ignore',
218
+ env: process.env,
219
+ });
220
+ return result.status === 0;
221
+ }
222
+
223
+ export async function runHarnessRoute({
224
+ url,
225
+ sessionName,
226
+ timeoutMs = 10_000,
227
+ snapshotPath = null,
228
+ designViewports = DEFAULT_DESIGN_VIEWPORTS,
229
+ waitForRuntime = false,
230
+ signal,
231
+ }) {
232
+ const cancelled = () => ({ mounted: false, renderStarted: false, runtimeCalls: [],
233
+ errors: [{ phase: 'browser', message: 'Verification cancelled.' }], snapshotPath: null, tool_error: null });
234
+ if (signal?.aborted) return cancelled();
235
+ const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
236
+ timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
237
+ });
238
+ // Let an in-flight open settle before the owner closes its browser. Closing
239
+ // concurrently with open can leave a newly launched browser behind.
240
+ if (signal?.aborted) return cancelled();
241
+ if (opened.exitCode !== 0) {
242
+ return {
243
+ mounted: false,
244
+ renderStarted: false,
245
+ errors: [],
246
+ runtimeCalls: [],
247
+ snapshotPath: null,
248
+ tool_error: commandError('open', opened),
249
+ };
250
+ }
251
+
252
+ const deadline = Date.now() + timeoutMs;
253
+ let readySince = null;
254
+ while (Date.now() < deadline && !signal?.aborted) {
255
+ const lastRead = await readHarness(sessionName, 5000);
256
+ if (!lastRead.ok) {
257
+ readySince = null;
258
+ await delay(250);
259
+ continue;
260
+ }
261
+ const harness = lastRead.harness;
262
+ if (Array.isArray(harness?.errors) && harness.errors.length > 0) break;
263
+ const pending = (harness?.runtimeCalls || []).some(call => call?.ok == null);
264
+ if (harness?.mounted && (!waitForRuntime || !pending)) {
265
+ readySince ??= Date.now();
266
+ if (Date.now() - readySince >= 250) break;
267
+ } else readySince = null;
268
+ await delay(250);
269
+ }
270
+
271
+ if (signal?.aborted) return cancelled();
272
+
273
+ const finalRead = await readHarness(sessionName, 5000);
274
+ if (!finalRead.ok) {
275
+ return {
276
+ mounted: false,
277
+ renderStarted: false,
278
+ errors: [],
279
+ runtimeCalls: [],
280
+ snapshotPath: null,
281
+ tool_error: finalRead.toolError,
282
+ };
283
+ }
284
+
285
+ const harness = finalRead.harness || {};
286
+ let savedSnapshotPath = null;
287
+ if (snapshotPath) {
288
+ const snapshot = await runAgentBrowser(['--session', sessionName, 'snapshot', '-i'], {
289
+ timeoutMs: 10_000,
290
+ });
291
+ if (snapshot.exitCode === 0) {
292
+ mkdirSync(dirname(snapshotPath), { recursive: true });
293
+ writeFileSync(snapshotPath, snapshot.stdout);
294
+ savedSnapshotPath = snapshotPath;
295
+ }
296
+ }
297
+
298
+ const runtimeCalls = Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [];
299
+ const timedOut = (!harness.mounted || (waitForRuntime && runtimeCalls.some(call => call?.ok == null))) && Date.now() >= deadline;
300
+
301
+ // Design assertions only mean something on a mounted route. Loading
302
+ // placeholders are excused while a runtime call is still in flight.
303
+ let design = [];
304
+ let designToolError = null;
305
+ if (harness.mounted && Array.isArray(designViewports) && designViewports.length > 0) {
306
+ await delay(500);
307
+ const pending = runtimeCalls.some((call) => call && call.ok == null);
308
+ const collected = await collectDesignFindings(sessionName, {
309
+ viewports: designViewports,
310
+ skipLoadingCheck: pending,
311
+ });
312
+ design = collected.findings;
313
+ designToolError = collected.tool_error;
314
+ }
315
+
316
+ return {
317
+ mounted: Boolean(harness.mounted),
318
+ renderStarted: Boolean(harness.renderStarted),
319
+ errors: Array.isArray(harness.errors) ? harness.errors : [],
320
+ runtimeCalls,
321
+ snapshotPath: savedSnapshotPath,
322
+ timed_out: timedOut,
323
+ tool_error: null,
324
+ design,
325
+ design_tool_error: designToolError,
326
+ raw: harness,
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Open a harness route, wait for it to mount, then capture a PNG screenshot of
332
+ * the rendered app. Raw captures retain exact requested pixel dimensions.
333
+ * Framed captures can select the complete bounded app or a width-fitted first
334
+ * screen; the Store compositor normalizes them to the final listing size.
335
+ * Device scaling keeps text sharp at that size without changing app styles.
336
+ * Used by `notis apps screenshot`.
337
+ */
338
+ export async function captureHarnessScreenshot({
339
+ url,
340
+ sessionName,
341
+ screenshotPath,
342
+ focusSelector = null,
343
+ frameContent = false,
344
+ width = 2000,
345
+ height = 1250,
346
+ timeoutMs = 15_000,
347
+ }) {
348
+ const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
349
+ timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
350
+ });
351
+ if (opened.exitCode !== 0) {
352
+ return { ok: false, mounted: false, screenshotPath: null, tool_error: commandError('open', opened) };
353
+ }
354
+
355
+ // Initial fixed frame so mount/layout happen at a deterministic size; the
356
+ // scale keeps physical output at width x height from the very first paint.
357
+ const initial = fitCssViewport(900, width, height);
358
+ let scaledViewport = await setViewport(
359
+ sessionName,
360
+ initial.width,
361
+ initial.height,
362
+ initial.scale,
363
+ );
364
+ if (!scaledViewport) {
365
+ // Older agent-browser without deviceScaleFactor support: fall back to a
366
+ // plain fixed viewport at output size.
367
+ await setViewport(sessionName, width, height);
368
+ }
369
+
370
+ const deadline = Date.now() + timeoutMs;
371
+ let lastErrors = [];
372
+ let lastReadError = null;
373
+ let mounted = false;
374
+ while (Date.now() < deadline) {
375
+ const read = await readHarness(sessionName, 5000);
376
+ if (read.ok) {
377
+ const harness = read.harness || {};
378
+ mounted = Boolean(harness.mounted);
379
+ lastErrors = Array.isArray(harness.errors) ? harness.errors : [];
380
+ if (mounted || lastErrors.length > 0) {
381
+ break;
382
+ }
383
+ } else {
384
+ lastReadError = read.toolError;
385
+ }
386
+ await delay(250);
387
+ }
388
+
389
+ if (lastErrors.length > 0) {
390
+ return { ok: false, mounted, screenshotPath: null, errors: lastErrors, tool_error: null };
391
+ }
392
+ if (!mounted) {
393
+ return {
394
+ ok: false,
395
+ mounted: false,
396
+ screenshotPath: null,
397
+ errors: [],
398
+ timed_out: true,
399
+ tool_error: lastReadError || {
400
+ phase: 'harness',
401
+ message: 'Timed out waiting for window.__harness.mounted.',
402
+ },
403
+ };
404
+ }
405
+
406
+ // Strip harness chrome (debug banner, banner padding, 100vh min-height) so
407
+ // the frame contains only the app, then wait for webfonts before measuring.
408
+ await runAgentBrowser(
409
+ ['--session', sessionName, 'eval', PREPARE_CAPTURE_SCRIPT],
410
+ { timeoutMs: 5000 },
411
+ );
412
+ await waitForFonts(sessionName);
413
+
414
+ // Fit the CSS viewport to the rendered content. Content height depends on
415
+ // viewport width, so refine once after the first resize reflows the page.
416
+ let framing = null;
417
+ if (scaledViewport) {
418
+ let contentHeight = await evalNumber(sessionName, CONTENT_HEIGHT_SCRIPT);
419
+ for (let pass = 0; contentHeight != null && pass < 2; pass += 1) {
420
+ const fitted = fitCssViewport(contentHeight, width, height);
421
+ if (framing && fitted.width === framing.width) {
422
+ break;
423
+ }
424
+ scaledViewport = await setViewport(sessionName, fitted.width, fitted.height, fitted.scale);
425
+ if (!scaledViewport) {
426
+ framing = null;
427
+ await setViewport(sessionName, width, height);
428
+ break;
429
+ }
430
+ framing = { ...fitted, content_height: contentHeight };
431
+ await delay(150);
432
+ const remeasured = await evalNumber(sessionName, CONTENT_HEIGHT_SCRIPT);
433
+ if (remeasured == null || Math.abs(remeasured - contentHeight) <= 32) {
434
+ framing.content_height = remeasured ?? contentHeight;
435
+ break;
436
+ }
437
+ contentHeight = remeasured;
438
+ }
439
+ }
440
+
441
+ // Let the route settle (async data, resize transitions) before the capture.
442
+ await delay(500);
443
+
444
+ let contentBounds = null;
445
+ if (frameContent && !focusSelector) {
446
+ const measured = await runAgentBrowser(
447
+ ['--session', sessionName, '--json', 'eval', MEASURE_SCREENSHOT_CONTENT_SCRIPT],
448
+ { timeoutMs: 5000 },
449
+ );
450
+ if (measured.exitCode === 0) {
451
+ try {
452
+ const bounds = parseHarnessFromEval(measured.stdout);
453
+ if (bounds?.selector === '[data-notis-screenshot-content]'
454
+ && Number.isFinite(bounds.width) && bounds.width > 0
455
+ && Number.isFinite(bounds.height) && bounds.height > 0) {
456
+ contentBounds = bounds;
457
+ }
458
+ } catch { /* Keep the complete viewport if layout measurement is unavailable. */ }
459
+ }
460
+ }
461
+ let captureFocus = focusSelector || contentBounds?.selector || null;
462
+ if (contentBounds && scaledViewport) {
463
+ const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
464
+ if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
465
+ await delay(300);
466
+ if (!plan.focus) {
467
+ const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
468
+ if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
469
+ plan.height = safeHeight;
470
+ }
471
+ }
472
+ captureFocus = plan.focus ? contentBounds.selector : null;
473
+ framing = {
474
+ ...(framing || {}), ...plan, content_bounds: contentBounds,
475
+ capture_mode: plan.focus ? 'content' : 'width-fit-viewport',
476
+ };
477
+ await delay(300);
478
+ }
479
+ }
480
+ const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
481
+ const design = Array.isArray(designResult) ? designResult : [];
482
+
483
+ mkdirSync(dirname(screenshotPath), { recursive: true });
484
+ const screenshotArgs = ['--session', sessionName, 'screenshot'];
485
+ if (captureFocus) {
486
+ screenshotArgs.push(captureFocus);
487
+ }
488
+ screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
489
+ const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
490
+ if (shot.exitCode !== 0) {
491
+ return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError('screenshot', shot) };
492
+ }
493
+
494
+ // The listing validator requires exact output dimensions. If the fitted
495
+ // capture came out wrong (e.g. the browser ignored the scale factor),
496
+ // recapture once at a plain fixed viewport.
497
+ const dimensions = pngDimensions(screenshotPath);
498
+ const expectedDimensions = framing?.capture_mode === 'width-fit-viewport'
499
+ ? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) }
500
+ : { width, height };
501
+ // Fractional browser scaling can round one pixel either way. Framed output
502
+ // is normalized by the compositor; raw captures still require exact pixels.
503
+ const tolerance = frameContent ? 1 : 0;
504
+ if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
505
+ await setViewport(sessionName, width, height);
506
+ await delay(300);
507
+ const retry = await runAgentBrowser(
508
+ ['--session', sessionName, 'screenshot', screenshotPath, '--screenshot-format', 'png'],
509
+ { timeoutMs: 15_000 },
510
+ );
511
+ if (retry.exitCode !== 0) {
512
+ return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError('screenshot', retry) };
513
+ }
514
+ framing = null;
515
+ }
516
+
517
+ if (captureFocus) {
518
+ framing = {
519
+ ...(framing || {}),
520
+ focus_selector: captureFocus,
521
+ content_bounds: contentBounds,
522
+ source_dimensions: dimensions,
523
+ };
524
+ }
525
+
526
+ return {
527
+ design,
528
+ ok: true,
529
+ mounted: true,
530
+ screenshotPath,
531
+ errors: lastErrors,
532
+ timed_out: false,
533
+ tool_error: null,
534
+ framing,
535
+ };
536
+ }
537
+
538
+ export async function closeAgentBrowserSession(sessionName) {
539
+ return (await closeAgentBrowserSessionResult(sessionName)).ok;
540
+ }
541
+
542
+ export async function closeAgentBrowserSessionResult(sessionName) {
543
+ const started = Date.now();
544
+ // agent-browser waits up to 30s for CDP Browser.close and then up to 5s
545
+ // for its owned Chrome process. Do not kill the client at the start of the
546
+ // provider's shutdown grace period and lose its actual close acknowledgement.
547
+ const result = await runAgentBrowser(['--session', sessionName, '--json', 'close'], { timeoutMs: 40_000 });
548
+ let acknowledged = false;
549
+ try {
550
+ const reply = parseAgentBrowserJson(result.stdout);
551
+ acknowledged = reply?.success === true && reply?.data?.closed === true;
552
+ } catch { /* Missing acknowledgement is a failed cleanup, never a pass. */ }
553
+ return { ok: result.exitCode === 0 && acknowledged, exit_code: result.exitCode,
554
+ timed_out: Boolean(result.timedOut), duration_ms: Date.now() - started };
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // Runtime design assertions.
559
+ //
560
+ // The static design lint (app-boundary-validator.js) catches banned class
561
+ // names. This script runs in the mounted harness page and catches what a
562
+ // regex cannot: a bordered box nested in a bordered box across files, tinted
563
+ // panels stacked three deep, text that computes below 12px, horizontal
564
+ // overflow at a phone width, and loading placeholders that outlive the data.
565
+ // Palette colors are deliberately not checked here: at runtime a token and a
566
+ // hardcoded hue resolve to the same RGB, so that stays with the static lint.
567
+ // ---------------------------------------------------------------------------
568
+ export const DESIGN_ASSERTIONS_MARKER = '__notisDesignAssertions';
569
+
570
+ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
571
+ /* ${DESIGN_ASSERTIONS_MARKER} */
572
+ const root = document.getElementById('root') || document.body;
573
+ const out = [];
574
+ const seen = new Set();
575
+ const push = (kind, el, extra) => {
576
+ const cls = String(el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className || '').trim().slice(0, 80);
577
+ const text = (el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 60);
578
+ const snippet = el.tagName.toLowerCase() + (cls ? '.' + cls.split(/\\s+/).slice(0, 6).join('.') : '') + (text ? ' "' + text + '"' : '');
579
+ const key = kind + '|' + snippet;
580
+ if (seen.has(key)) return;
581
+ seen.add(key);
582
+ out.push(Object.assign({ kind, snippet }, extra || {}));
583
+ };
584
+ const controls = new Set(['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'A', 'SUMMARY']);
585
+ const isControl = (el) => controls.has(el.tagName) || /^(button|checkbox|switch|radio|combobox|textbox|tab|menuitem)$/.test(el.getAttribute('role') || '');
586
+ const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
587
+ const isBox = (el) => {
588
+ const cs = getComputedStyle(el);
589
+ if (!['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'].every((k) => parseFloat(cs[k]) > 0)) return false;
590
+ if (/rgba\\(\\d+, \\d+, \\d+, 0\\)|transparent/.test(cs.borderTopColor)) return false;
591
+ const r = el.getBoundingClientRect();
592
+ return r.width > 40 && r.height > 20;
593
+ };
594
+ const bg = (el) => getComputedStyle(el).backgroundColor;
595
+ const transparent = (c) => !c || c === 'transparent' || /rgba\\(\\d+, \\d+, \\d+, 0\\)/.test(c);
596
+ const isPanel = (el) => {
597
+ if (transparent(bg(el))) return false;
598
+ if (parseFloat(getComputedStyle(el).borderRadius) <= 0) return false;
599
+ const r = el.getBoundingClientRect();
600
+ return r.width > 160 && r.height > 48;
601
+ };
602
+ const all = Array.from(root.querySelectorAll('*'));
603
+ for (const el of all) {
604
+ if (!visible(el) || isControl(el)) continue;
605
+ if (isBox(el)) {
606
+ let p = el.parentElement; let depth = 0;
607
+ while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
608
+ }
609
+ if (isPanel(el)) {
610
+ let p = el.parentElement; let panels = 0;
611
+ while (p && p !== root) { if (isPanel(p)) panels += 1; p = p.parentElement; }
612
+ if (panels >= 2) push('nested_tinted_panel', el, { depth: panels + 1 });
613
+ }
614
+ }
615
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
616
+ let node;
617
+ while ((node = walker.nextNode())) {
618
+ const text = (node.textContent || '').trim();
619
+ if (!text) continue;
620
+ const el = node.parentElement;
621
+ if (!el || !visible(el)) continue;
622
+ const size = parseFloat(getComputedStyle(el).fontSize);
623
+ if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
624
+ if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
625
+ }
626
+ if (root.querySelector('[data-notis-content-skeleton]')) push('loading_placeholder_after_mount', root.querySelector('[data-notis-content-skeleton]'));
627
+ const doc = document.documentElement;
628
+ if (doc.scrollWidth > doc.clientWidth + 1) out.push({ kind: 'horizontal_overflow', snippet: 'document', scroll_width: doc.scrollWidth, client_width: doc.clientWidth });
629
+ return JSON.stringify(out.slice(0, 40));
630
+ })()`;
631
+
632
+ export const DEFAULT_DESIGN_VIEWPORTS = [
633
+ { name: 'desktop', width: 1280, height: 900 },
634
+ { name: 'mobile', width: 390, height: 844 },
635
+ ];
636
+
637
+ const DESIGN_KIND_MESSAGES = {
638
+ nested_border_box: 'a bordered box sits inside another bordered box',
639
+ nested_tinted_panel: 'tinted panels are nested three deep',
640
+ text_below_12px: 'visible text renders below 12px',
641
+ loading_placeholder_after_mount: 'a loading placeholder is still visible after the route mounted with its data',
642
+ horizontal_overflow: 'the page overflows horizontally',
643
+ };
644
+
645
+ export function describeDesignFinding(finding) {
646
+ const base = DESIGN_KIND_MESSAGES[finding.kind] || finding.kind;
647
+ const where = finding.viewport ? ` at ${finding.viewport}` : '';
648
+ const detail = finding.snippet && finding.snippet !== 'document' ? `: ${finding.snippet}` : '';
649
+ return `${base}${where}${detail}`;
650
+ }
651
+
652
+ /**
653
+ * Evaluate the design assertions in the current page. Returns an array of
654
+ * findings, or `{ tool_error }` when agent-browser could not run the script.
655
+ */
656
+ export async function evalDesignAssertions(sessionName, { viewport = null, timeoutMs = 8000 } = {}) {
657
+ const result = await runAgentBrowser(
658
+ ['--session', sessionName, '--json', 'eval', DESIGN_ASSERTIONS_SCRIPT],
659
+ { timeoutMs },
660
+ );
661
+ if (result.exitCode !== 0) {
662
+ return { tool_error: commandError('design_eval', result) };
663
+ }
664
+ try {
665
+ const payload = parseAgentBrowserJson(result.stdout);
666
+ const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
667
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
668
+ if (!Array.isArray(value)) throw new Error('Design evaluation did not return a findings array.');
669
+ return value.map((finding) => ({ ...finding, viewport: viewport || finding.viewport || null }));
670
+ } catch (error) {
671
+ return { tool_error: { phase: 'design_eval_parse', message: error.message } };
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Run every requested viewport. Missing browser checks are incomplete proof,
677
+ * not a passing design result, even when static source lint passed.
678
+ */
679
+ export async function collectDesignFindings(sessionName, { viewports = DEFAULT_DESIGN_VIEWPORTS, settleMs = 350, skipLoadingCheck = false } = {}) {
680
+ const findings = [];
681
+ let toolError = null;
682
+ for (const viewport of viewports) {
683
+ const applied = await setViewport(sessionName, viewport.width, viewport.height);
684
+ if (!applied) {
685
+ toolError ||= { phase: 'design_viewport', message: `Could not set ${viewport.name} viewport.` };
686
+ continue;
687
+ }
688
+ await delay(settleMs);
689
+ const result = await evalDesignAssertions(sessionName, { viewport: viewport.name });
690
+ if (Array.isArray(result)) {
691
+ for (const finding of result) {
692
+ if (skipLoadingCheck && finding.kind === 'loading_placeholder_after_mount') continue;
693
+ findings.push(finding);
694
+ }
695
+ } else if (!toolError) {
696
+ toolError = result.tool_error;
697
+ }
698
+ }
699
+ return { findings, tool_error: toolError };
700
+ }