@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.1792022492092.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 (124) hide show
  1. package/README.md +548 -98
  2. package/config/portal-route-roots.json +47 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +32901 -5934
  4. package/dist/base-skills/notis-apps/SKILL.md +76 -51
  5. package/dist/base-skills/notis-apps/references/architecture.md +116 -164
  6. package/dist/base-skills/notis-apps/references/design.md +26 -107
  7. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  8. package/dist/base-skills/notis-apps/references/reading.md +52 -74
  9. package/dist/base-skills/notis-apps/references/release.md +105 -96
  10. package/dist/base-skills/notis-apps/references/sdk.md +115 -60
  11. package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
  12. package/dist/base-skills/notis-apps/references/views.md +145 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +29 -12
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
  15. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  16. package/dist/base-skills/notis-cli/references/native-databases.md +6 -5
  17. package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
  18. package/dist/base-skills/notis-query/SKILL.md +34 -60
  19. package/dist/base-skills/notis-query/references/database-discovery.md +60 -59
  20. package/dist/base-skills/notis-query/references/documents.md +53 -39
  21. package/dist/base-skills/notis-query/references/query.md +57 -524
  22. package/dist/sdk/src/bundleModule.ts +79 -0
  23. package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
  24. package/dist/sdk/src/components/DocumentPage.tsx +144 -0
  25. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  26. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  27. package/dist/sdk/src/config.ts +4 -26
  28. package/dist/sdk/src/documents.ts +2 -0
  29. package/dist/sdk/src/hooks/useAction.ts +28 -0
  30. package/dist/sdk/src/hooks/useBackend.ts +3 -2
  31. package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
  32. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  33. package/dist/sdk/src/hooks/useCollectionInteractions.ts +4 -2
  34. package/dist/sdk/src/hooks/useHandover.ts +7 -7
  35. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  36. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  37. package/dist/sdk/src/hooks/useNotis.ts +3 -0
  38. package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
  39. package/dist/sdk/src/hooks/useQuery.ts +7 -4
  40. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  41. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  42. package/dist/sdk/src/index.ts +36 -0
  43. package/dist/sdk/src/interactions/shortcuts.tsx +42 -5
  44. package/dist/sdk/src/nativeData.ts +39 -0
  45. package/dist/sdk/src/presentation.tsx +82 -0
  46. package/dist/sdk/src/provider.tsx +9 -4
  47. package/dist/sdk/src/queryCache.ts +5 -3
  48. package/dist/sdk/src/queryErrors.ts +51 -0
  49. package/dist/sdk/src/runtime.ts +163 -13
  50. package/dist/sdk/src/space.ts +425 -0
  51. package/dist/sdk/src/styles.css +96 -72
  52. package/dist/sdk/src/vite.ts +11 -1
  53. package/dist/skill-sync/index.js +571 -80
  54. package/dist/skill-sync/index.js.map +4 -4
  55. package/dist/skill-sync-worker.mjs +858 -233
  56. package/dist/space-harness/host.js +66 -0
  57. package/dist/view-renderer/dist/host.css +1 -0
  58. package/dist/view-renderer/dist/host.js +1028 -0
  59. package/dist/view-renderer/dist/theme.css +73 -0
  60. package/dist/view-renderer/src/dom.js +180 -0
  61. package/dist/view-renderer/src/errors.js +8 -0
  62. package/dist/view-renderer/src/host.tsx +175 -0
  63. package/dist/view-renderer/src/index.js +4 -0
  64. package/dist/view-renderer/src/pool.js +76 -0
  65. package/dist/view-renderer/src/remote-broker.js +59 -0
  66. package/dist/view-renderer/src/renderer.js +201 -0
  67. package/package.json +16 -5
  68. package/skills/notis-apps/cli.md +323 -163
  69. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
  70. package/skills/notis-onboarding/BRIEF.md +7 -3
  71. package/src/command-specs/apps.js +2 -1
  72. package/src/command-specs/auth.js +2 -2
  73. package/src/command-specs/files.js +29 -0
  74. package/src/command-specs/handover.js +331 -79
  75. package/src/command-specs/helpers.js +2 -0
  76. package/src/command-specs/index.js +11 -5
  77. package/src/command-specs/profile.js +42 -1
  78. package/src/command-specs/skills.js +242 -1
  79. package/src/command-specs/spaces.js +645 -0
  80. package/src/command-specs/tools.js +0 -6
  81. package/src/command-specs/views.js +69 -0
  82. package/src/runtime/agent-browser.js +92 -29
  83. package/src/runtime/app-boundary-validator.js +7 -4
  84. package/src/runtime/app-data-literals.js +82 -0
  85. package/src/runtime/app-platform.js +321 -253
  86. package/src/runtime/computer-identity.d.ts +30 -0
  87. package/src/runtime/computer-identity.js +151 -0
  88. package/src/runtime/config-lock.js +135 -0
  89. package/src/runtime/confined-space-build.js +74 -0
  90. package/src/runtime/confined-space-verify.js +49 -0
  91. package/src/runtime/delegated-context.js +5 -21
  92. package/src/runtime/native-record-files.js +47 -0
  93. package/src/runtime/oauth.js +18 -1
  94. package/src/runtime/profiles.js +5 -107
  95. package/src/runtime/retired-sdk-files.js +20 -0
  96. package/src/runtime/screenshot-framing.js +85 -0
  97. package/src/runtime/skill-sync/cloud-client.ts +26 -4
  98. package/src/runtime/skill-sync/index.ts +230 -35
  99. package/src/runtime/skill-sync/local-scanner.ts +188 -29
  100. package/src/runtime/skill-sync/native-identity.ts +102 -0
  101. package/src/runtime/skill-sync/native-plan.ts +70 -0
  102. package/src/runtime/skill-sync/sync-plan.ts +16 -9
  103. package/src/runtime/skill-sync/types.ts +32 -6
  104. package/src/runtime/source-root.js +32 -0
  105. package/src/runtime/space-collection-scaffold.js +66 -0
  106. package/src/runtime/space-database-structures.js +229 -0
  107. package/src/runtime/space-fixture-broker.js +50 -0
  108. package/src/runtime/space-lock.js +337 -0
  109. package/src/runtime/space-platform.js +105 -0
  110. package/src/runtime/space-source.js +281 -0
  111. package/src/runtime/space-test-server.js +327 -0
  112. package/src/runtime/space-verification.js +89 -0
  113. package/src/runtime/space-verify-browser-worker.js +51 -0
  114. package/src/runtime/space-view-manifest.js +254 -0
  115. package/src/runtime/store-screenshot.js +10 -7
  116. package/src/runtime/sync-skills.d.ts +3 -1
  117. package/src/runtime/sync-skills.js +10 -2
  118. package/src/runtime/transport.js +8 -6
  119. package/src/runtime/view-render.js +49 -0
  120. package/src/skill-sync-worker.js +14 -2
  121. package/src/space-harness-host.ts +103 -0
  122. package/template/.harness/index.html.tmpl +7 -10
  123. package/template/app/globals.css +1 -0
  124. package/src/command-specs/reports.js +0 -97
@@ -2,7 +2,6 @@ import { accessSync, constants as fsConstants, createReadStream, existsSync, rea
2
2
  import { createHash } from 'node:crypto';
3
3
  import { basename } from 'node:path';
4
4
  import { usageError } from '../runtime/errors.js';
5
- import { assertToolNotDelegated } from '../runtime/delegated-context.js';
6
5
  import {
7
6
  COMPOSIO_GET_TOOL_SCHEMAS,
8
7
  COMPOSIO_MULTI_EXECUTE_TOOL,
@@ -386,9 +385,6 @@ async function toolsDescribeHandler(ctx) {
386
385
 
387
386
  async function toolsExecHandler(ctx) {
388
387
  const requestedToolName = localNotisToolSlug(ctx.args.toolName);
389
- // `tools exec` is the escape hatch around every first-class command, so the
390
- // hand-over guard has to live here too or it guards nothing.
391
- assertToolNotDelegated(requestedToolName);
392
388
  let targetMutating = classifyToolMutation(requestedToolName);
393
389
  ensureLongRunningToolTimeout(ctx, requestedToolName);
394
390
  ctx.output.emitProgress({
@@ -504,8 +500,6 @@ async function toolsExecParallelHandler(ctx) {
504
500
  if (!call.tool_name || typeof call.tool_name !== 'string') {
505
501
  throw usageError(`calls[${i}] missing required "tool_name" string`);
506
502
  }
507
- // A batch is just as good an escape hatch as a single exec.
508
- assertToolNotDelegated(localNotisToolSlug(call.tool_name));
509
503
  }
510
504
  ensureLongRunningToolTimeout(ctx, calls.map((call) => localNotisToolSlug(call.tool_name)));
511
505
 
@@ -0,0 +1,69 @@
1
+ import { httpRequest } from '../runtime/transport.js';
2
+ import { usageError } from '../runtime/errors.js';
3
+ import { renderViewWithCliRuntime } from '../runtime/view-render.js';
4
+ import { resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+
7
+ export function viewRenderCommandSpec({ screenshotOnly = false } = {}) {
8
+ const command_path = screenshotOnly ? ['spaces', 'screenshot'] : ['views', 'render'];
9
+ return {
10
+ command_path, summary: screenshotOnly ? 'Capture a full-height PNG of a live view with your current access.' : 'Render a live view as Markdown and a full-height PNG with your current access.',
11
+ when_to_use: 'Check the actual deployed view; writes are blocked and current access is checked again before any output is saved.',
12
+ args_schema: { arguments: [{ token: '<url>', key: 'url', description: 'Current view-qualified or record link.' }], options: [
13
+ ...(!screenshotOnly ? [{ flags: '--outputs <kinds>', description: 'Comma-separated markdown,screenshot (default both).' }] : []),
14
+ { flags: '--width <pixels>', description: '1440 (default) or 390.' },
15
+ { flags: '--output-dir <path>', description: 'New output directory; defaults to a unique .notis/renders directory. Existing files are never overwritten.' },
16
+ ] }, examples: [screenshotOnly ? 'notis spaces screenshot <view-link> --width 390' : 'notis views render <view-link> --outputs markdown,screenshot'],
17
+ mutates: false, idempotent: true, require_auth: true,
18
+ backend_call: { type: 'http', path: '/portal_view_render/prepare' },
19
+ async handler(ctx) {
20
+ const outputs = screenshotOnly ? ['screenshot'] : (ctx.options.outputs || 'markdown,screenshot').split(',');
21
+ if (!outputs.length || new Set(outputs).size !== outputs.length || outputs.some(value => !['markdown', 'screenshot'].includes(value))) {
22
+ throw usageError('Choose markdown, screenshot, or markdown,screenshot.');
23
+ }
24
+ const width = ctx.options.width === undefined ? 1440 : Number(ctx.options.width);
25
+ const directory = resolve(ctx.options.outputDir || `.notis/renders/${randomUUID()}`);
26
+ const controller = new AbortController(), cancel = () => controller.abort();
27
+ process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
28
+ try {
29
+ const { result, artifacts } = await renderViewWithCliRuntime({ runtime: ctx.runtime,
30
+ url: ctx.args.url, outputs, width, directory, signal: controller.signal });
31
+ const data = { ...result, artifacts };
32
+ if (data.screenshot) {
33
+ const { data_base64: _bytes, ...metadata } = data.screenshot;
34
+ data.screenshot = { ...metadata, path: artifacts.screenshot };
35
+ }
36
+ return ctx.output.emitSuccess({ command: command_path.join(' '), data,
37
+ humanSummary: `Rendered ${result.url}. Saved ${Object.values(artifacts).join(' and ')}.`, meta: { mutating: false } });
38
+ } finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
39
+ },
40
+ };
41
+ }
42
+
43
+ export const viewsCommandSpecs = [{
44
+ command_path: ['views', 'find'],
45
+ summary: 'Find the views you can open for a record, database, view link, Space or search.',
46
+ when_to_use: 'Get fresh view-qualified links and declared URL parameters before citing or opening a view.',
47
+ args_schema: { options: [
48
+ { flags: '--record-key <id>', description: 'Find views that show this exact native record.' },
49
+ { flags: '--database-id <id>', description: 'Find views over this database.' },
50
+ { flags: '--url <url>', description: 'Resolve this view link and its current parameters.' },
51
+ { flags: '--space-id <id>', description: 'Describe this Space view.' },
52
+ { flags: '--query <text>', description: 'Search accessible view names, descriptions and readable context.' },
53
+ ] },
54
+ examples: ['notis views find --record-key <record-key>', 'notis views find --url <view-link>', 'notis views find --query inbox'],
55
+ mutates: false, idempotent: true, require_auth: true,
56
+ backend_call: { type: 'http', path: '/portal_spaces/find-views' },
57
+ async handler(ctx) {
58
+ const options = { record_key: ctx.options.recordKey, database_id: ctx.options.databaseId,
59
+ url: ctx.options.url, space_id: ctx.options.spaceId, query: ctx.options.query };
60
+ const selected = Object.entries(options).filter(([, value]) => value !== undefined);
61
+ if (selected.length !== 1 || typeof selected[0][1] !== 'string' || !selected[0][1].trim()) {
62
+ throw usageError('Choose exactly one of --record-key, --database-id, --url, --space-id or --query.');
63
+ }
64
+ const result = await httpRequest({ runtime: ctx.runtime, method: 'POST', path: '/portal_spaces/find-views',
65
+ body: Object.fromEntries(selected), spacesProtocol: 1 });
66
+ return ctx.output.emitSuccess({ command: 'views find', data: result.payload,
67
+ humanSummary: JSON.stringify(result.payload, null, 2), meta: { mutating: false, request_id: result.requestId } });
68
+ },
69
+ }, viewRenderCommandSpec()];
@@ -1,19 +1,15 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
-
5
- export const HIDE_HARNESS_STATUS_SCRIPT =
6
- "(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
4
+ import { contentCapturePlan, MEASURE_SCREENSHOT_CONTENT_SCRIPT, SAFE_VIEWPORT_HEIGHT_SCRIPT } from './screenshot-framing.js';
7
5
 
8
6
  /**
9
- * Prepare the mounted harness page for a listing capture: remove the debug
10
- * banner and the layout the harness adds around the app (top padding for the
11
- * banner, 100vh min-height) so content height can be measured and framed
12
- * without artificial whitespace.
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.
13
10
  */
14
11
  const PREPARE_CAPTURE_SCRIPT =
15
- "(() => { const s = document.getElementById('harness-status'); if (s) s.style.display = 'none';" +
16
- " const r = document.getElementById('root'); if (r) { r.style.paddingTop = '0'; r.style.minHeight = '0'; }" +
12
+ "(() => { const r = document.getElementById('root'); if (r) r.style.minHeight = '0';" +
17
13
  " document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
18
14
 
19
15
  const FONTS_STATUS_SCRIPT =
@@ -135,9 +131,11 @@ function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
135
131
  let stdout = '';
136
132
  let stderr = '';
137
133
  let timedOut = false;
134
+ let hardStop;
138
135
  const timer = setTimeout(() => {
139
136
  timedOut = true;
140
137
  child.kill('SIGTERM');
138
+ hardStop = setTimeout(() => child.kill('SIGKILL'), 2000);
141
139
  }, timeoutMs);
142
140
 
143
141
  child.stdout.on('data', (chunk) => {
@@ -147,7 +145,7 @@ function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
147
145
  stderr += chunk.toString();
148
146
  });
149
147
  child.on('error', (error) => {
150
- clearTimeout(timer);
148
+ clearTimeout(timer); clearTimeout(hardStop);
151
149
  resolvePromise({
152
150
  exitCode: 1,
153
151
  stdout,
@@ -156,7 +154,7 @@ function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
156
154
  });
157
155
  });
158
156
  child.on('close', (code) => {
159
- clearTimeout(timer);
157
+ clearTimeout(timer); clearTimeout(hardStop);
160
158
  resolvePromise({
161
159
  exitCode: timedOut ? 124 : (code ?? 1),
162
160
  stdout,
@@ -229,10 +227,17 @@ export async function runHarnessRoute({
229
227
  snapshotPath = null,
230
228
  designViewports = DEFAULT_DESIGN_VIEWPORTS,
231
229
  waitForRuntime = false,
230
+ signal,
232
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();
233
235
  const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
234
236
  timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
235
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();
236
241
  if (opened.exitCode !== 0) {
237
242
  return {
238
243
  mounted: false,
@@ -246,7 +251,7 @@ export async function runHarnessRoute({
246
251
 
247
252
  const deadline = Date.now() + timeoutMs;
248
253
  let readySince = null;
249
- while (Date.now() < deadline) {
254
+ while (Date.now() < deadline && !signal?.aborted) {
250
255
  const lastRead = await readHarness(sessionName, 5000);
251
256
  if (!lastRead.ok) {
252
257
  readySince = null;
@@ -263,6 +268,8 @@ export async function runHarnessRoute({
263
268
  await delay(250);
264
269
  }
265
270
 
271
+ if (signal?.aborted) return cancelled();
272
+
266
273
  const finalRead = await readHarness(sessionName, 5000);
267
274
  if (!finalRead.ok) {
268
275
  return {
@@ -322,18 +329,18 @@ export async function runHarnessRoute({
322
329
 
323
330
  /**
324
331
  * Open a harness route, wait for it to mount, then capture a PNG screenshot of
325
- * the rendered app. Output is always exactly `width` x `height` physical
326
- * pixels (default 2000x1250, the 16:10 the listing validator expects), but the
327
- * CSS viewport is fitted to the rendered content height: short pages render
328
- * larger (no dead whitespace below the app), tall pages get more room before
329
- * cropping. The deviceScaleFactor maps the fitted CSS viewport back onto the
330
- * fixed output frame. Used by `notis apps screenshot`.
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`.
331
337
  */
332
338
  export async function captureHarnessScreenshot({
333
339
  url,
334
340
  sessionName,
335
341
  screenshotPath,
336
342
  focusSelector = null,
343
+ frameContent = false,
337
344
  width = 2000,
338
345
  height = 1250,
339
346
  timeoutMs = 15_000,
@@ -433,13 +440,50 @@ export async function captureHarnessScreenshot({
433
440
 
434
441
  // Let the route settle (async data, resize transitions) before the capture.
435
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
+ }
436
480
  const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
437
481
  const design = Array.isArray(designResult) ? designResult : [];
438
482
 
439
483
  mkdirSync(dirname(screenshotPath), { recursive: true });
440
484
  const screenshotArgs = ['--session', sessionName, 'screenshot'];
441
- if (focusSelector) {
442
- screenshotArgs.push(focusSelector);
485
+ if (captureFocus) {
486
+ screenshotArgs.push(captureFocus);
443
487
  }
444
488
  screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
445
489
  const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
@@ -451,7 +495,13 @@ export async function captureHarnessScreenshot({
451
495
  // capture came out wrong (e.g. the browser ignored the scale factor),
452
496
  // recapture once at a plain fixed viewport.
453
497
  const dimensions = pngDimensions(screenshotPath);
454
- if (!focusSelector && framing && (!dimensions || dimensions.width !== width || dimensions.height !== height)) {
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)) {
455
505
  await setViewport(sessionName, width, height);
456
506
  await delay(300);
457
507
  const retry = await runAgentBrowser(
@@ -464,10 +514,11 @@ export async function captureHarnessScreenshot({
464
514
  framing = null;
465
515
  }
466
516
 
467
- if (focusSelector) {
517
+ if (captureFocus) {
468
518
  framing = {
469
519
  ...(framing || {}),
470
- focus_selector: focusSelector,
520
+ focus_selector: captureFocus,
521
+ content_bounds: contentBounds,
471
522
  source_dimensions: dimensions,
472
523
  };
473
524
  }
@@ -485,10 +536,22 @@ export async function captureHarnessScreenshot({
485
536
  }
486
537
 
487
538
  export async function closeAgentBrowserSession(sessionName) {
488
- const result = await runAgentBrowser(['--session', sessionName, 'close'], {
489
- timeoutMs: 5000,
490
- });
491
- return result.exitCode === 0;
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 };
492
555
  }
493
556
 
494
557
  // ---------------------------------------------------------------------------
@@ -538,7 +601,7 @@ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
538
601
  };
539
602
  const all = Array.from(root.querySelectorAll('*'));
540
603
  for (const el of all) {
541
- if (el.id === 'harness-status' || !visible(el) || isControl(el)) continue;
604
+ if (!visible(el) || isControl(el)) continue;
542
605
  if (isBox(el)) {
543
606
  let p = el.parentElement; let depth = 0;
544
607
  while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
@@ -555,7 +618,7 @@ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
555
618
  const text = (node.textContent || '').trim();
556
619
  if (!text) continue;
557
620
  const el = node.parentElement;
558
- if (!el || el.closest('#harness-status') || !visible(el)) continue;
621
+ if (!el || !visible(el)) continue;
559
622
  const size = parseFloat(getComputedStyle(el).fontSize);
560
623
  if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
561
624
  if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
@@ -3,6 +3,7 @@ import { dirname, extname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
5
  import { usageError } from './errors.js';
6
+ import { maskSerializedJsonData } from './app-data-literals.js';
6
7
 
7
8
  const moduleDir = dirname(fileURLToPath(import.meta.url));
8
9
 
@@ -136,7 +137,7 @@ function validateTextFile(relPath, content) {
136
137
  return applyRules(content, rules.css, relPath);
137
138
  }
138
139
  if (extension === '.js' || extension === '.jsx' || extension === '.ts' || extension === '.tsx') {
139
- return applyRules(content, rules.javascript, relPath);
140
+ return applyRules(maskSerializedJsonData(content, extension), rules.javascript, relPath);
140
141
  }
141
142
  return [];
142
143
  }
@@ -311,7 +312,9 @@ export function collectDesignViolationsForFile(relPath, content, config = getCom
311
312
  if (!matchesAny(normalized, config.include)) return [];
312
313
  if (matchesAny(normalized, config.exclude)) return [];
313
314
 
314
- const lines = content.split('\n');
315
+ const checked = ['.js', '.jsx', '.ts', '.tsx'].includes(extname(normalized))
316
+ ? maskSerializedJsonData(content, extname(normalized)) : content;
317
+ const lines = checked.split('\n');
315
318
  const violations = [];
316
319
  for (const rule of config.rules) {
317
320
  if (rule.include && !matchesAny(normalized, rule.include)) continue;
@@ -319,12 +322,12 @@ export function collectDesignViolationsForFile(relPath, content, config = getCom
319
322
 
320
323
  const matches = [];
321
324
  if (rule.scope === 'file') {
322
- const match = rule.regex.exec(content);
325
+ const match = rule.regex.exec(checked);
323
326
  if (match) matches.push(match.index);
324
327
  } else {
325
328
  rule.regex.lastIndex = 0;
326
329
  let match;
327
- while ((match = rule.regex.exec(content)) !== null) {
330
+ while ((match = rule.regex.exec(checked)) !== null) {
328
331
  matches.push(match.index);
329
332
  if (match[0].length === 0) rule.regex.lastIndex += 1;
330
333
  }
@@ -0,0 +1,82 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ const require = createRequire(import.meta.url);
4
+ let babelParse;
5
+
6
+ function isJsonParse(node) {
7
+ const callee = node.callee;
8
+ return node.type === 'CallExpression' && callee?.type === 'MemberExpression' && !callee.computed
9
+ && callee.object?.type === 'Identifier' && callee.object.name === 'JSON'
10
+ && callee.property?.type === 'Identifier' && callee.property.name === 'parse';
11
+ }
12
+
13
+ function dataContext(ancestors) {
14
+ if (ancestors.some(node => node.type === 'JSXAttribute')) return false;
15
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
16
+ const node = ancestors[index];
17
+ if (node.type === 'JSXAttribute' || node.type === 'ImportDeclaration'
18
+ || node.type === 'ImportExpression' || node.type === 'TSImportType'
19
+ || node.type === 'ExportAllDeclaration' || node.type === 'ExportNamedDeclaration'
20
+ || node.type === 'NewExpression' || node.type === 'TaggedTemplateExpression'
21
+ || node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') return false;
22
+ if (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') {
23
+ if (!isJsonParse(node)) return false;
24
+ }
25
+ if (['VariableDeclarator', 'ExportDefaultDeclaration', 'ReturnStatement'].includes(node.type)) return true;
26
+ if (node.type === 'ExpressionStatement') return false;
27
+ }
28
+ return false;
29
+ }
30
+
31
+ /**
32
+ * Serialized JSON is data, not source: a review may quote forbidden host code
33
+ * or historical styles. Mask only complete object/array JSON literal values in
34
+ * data-expression positions. Keep ordinary strings, import/call/selector and
35
+ * JSX attribute contexts, and every template expression under the existing
36
+ * rules. Parsing errors (or a missing parser) retain the original checks.
37
+ * Source/archive bytes are never rewritten; offsets and line breaks survive.
38
+ */
39
+ export function maskSerializedJsonData(content, extension = '.js') {
40
+ let ast;
41
+ try {
42
+ babelParse ||= require('@babel/parser').parse;
43
+ ast = babelParse(content, {
44
+ sourceType: 'unambiguous',
45
+ allowReturnOutsideFunction: true,
46
+ plugins: [...(['.ts', '.tsx'].includes(extension) ? ['typescript'] : []),
47
+ ...(extension !== '.ts' ? ['jsx'] : [])],
48
+ });
49
+ } catch { return content; }
50
+ const ranges = [];
51
+ const pending = [[ast, []]];
52
+ while (pending.length) {
53
+ const [node, ancestors] = pending.pop();
54
+ if (!node || typeof node !== 'object') continue;
55
+ const literal = node.type === 'StringLiteral' ? node.value
56
+ : node.type === 'TemplateLiteral' && node.expressions.length === 0 ? node.quasis[0]?.value.cooked : null;
57
+ if (typeof literal === 'string' && dataContext(ancestors)) {
58
+ try {
59
+ const value = JSON.parse(literal);
60
+ if (value !== null && typeof value === 'object') {
61
+ ranges.push([node.start, node.end]);
62
+ continue;
63
+ }
64
+ } catch { /* Ordinary/non-JSON strings keep their original checks. */ }
65
+ }
66
+ const parents = [...ancestors, node];
67
+ for (const [key, value] of Object.entries(node)) {
68
+ if (['loc', 'extra', 'tokens', 'comments', 'errors'].includes(key)) continue;
69
+ if (Array.isArray(value)) {
70
+ for (const child of value) if (child?.type) pending.push([child, parents]);
71
+ } else if (value?.type) pending.push([value, parents]);
72
+ }
73
+ }
74
+ let cursor = 0;
75
+ const result = [];
76
+ for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) {
77
+ result.push(content.slice(cursor, start), content.slice(start, end).replace(/[^\r\n]/g, ' '));
78
+ cursor = end;
79
+ }
80
+ result.push(content.slice(cursor));
81
+ return result.join('');
82
+ }