akanjs 3.0.0-alpha.50 → 3.0.0-alpha.52

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 (53) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/constant/fieldInfo.ts +32 -0
  3. package/constant/mask.ts +11 -4
  4. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  5. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  6. package/package.json +1 -1
  7. package/server/akanApp.ts +15 -11
  8. package/server/akanServer.ts +13 -0
  9. package/server/mcp/McpDispatcher.ts +42 -2
  10. package/server/mcp/McpRouter.ts +2 -0
  11. package/signal/schema/JsonSchemaBuilder.ts +10 -6
  12. package/store/agent/ScreenReader.ts +36 -6
  13. package/store/agent/ScreenTarget.ts +13 -7
  14. package/store/agent/StoreSurfaceSource.ts +1 -1
  15. package/types/constant/fieldInfo.d.ts +10 -0
  16. package/types/constant/mask.d.ts +10 -3
  17. package/types/server/akanServer.d.ts +7 -0
  18. package/types/server/mcp/McpDispatcher.d.ts +7 -0
  19. package/types/server/mcp/McpRouter.d.ts +2 -0
  20. package/types/signal/schema/JsonSchemaBuilder.d.ts +9 -5
  21. package/types/store/agent/ScreenReader.d.ts +5 -0
  22. package/types/store/agent/ScreenTarget.d.ts +5 -4
  23. package/types/ui/Agent/Approval.d.ts +4 -3
  24. package/types/ui/Agent/Bubble.d.ts +5 -2
  25. package/types/ui/Agent/Chat.d.ts +17 -1
  26. package/types/ui/Agent/Composer.d.ts +6 -6
  27. package/types/ui/Agent/Launcher.d.ts +3 -3
  28. package/types/ui/Agent/Markdown.d.ts +11 -3
  29. package/types/ui/Agent/Menu.d.ts +4 -3
  30. package/types/ui/Agent/Question.d.ts +4 -3
  31. package/types/ui/Agent/Skip.d.ts +22 -0
  32. package/types/ui/Agent/index.d.ts +1 -0
  33. package/types/ui/Agent/markdownBlocks.d.ts +1 -0
  34. package/types/ui/Agent/useKeyboardInset.d.ts +8 -0
  35. package/types/ui/UiOverride/context.d.ts +15 -0
  36. package/types/ui/index.d.ts +13 -0
  37. package/ui/Agent/Approval.tsx +6 -3
  38. package/ui/Agent/Bubble.tsx +7 -2
  39. package/ui/Agent/Chat.tsx +73 -27
  40. package/ui/Agent/Composer.tsx +27 -10
  41. package/ui/Agent/Launcher.tsx +5 -2
  42. package/ui/Agent/Markdown.tsx +32 -15
  43. package/ui/Agent/Menu.tsx +6 -3
  44. package/ui/Agent/Question.tsx +6 -3
  45. package/ui/Agent/Skip.tsx +30 -0
  46. package/ui/Agent/index.ts +14 -1
  47. package/ui/Agent/markdownBlocks.ts +7 -5
  48. package/ui/Agent/useKeyboardInset.ts +26 -0
  49. package/ui/Popconfirm.tsx +7 -2
  50. package/ui/Select.tsx +8 -2
  51. package/ui/UiOverride/context.ts +16 -0
  52. package/ui/index.ts +15 -0
  53. package/vendor/use-agentic/AgentSession.ts +3 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,108 @@
1
1
  # akanjs
2
2
 
3
+ ## 2.4.2
4
+
5
+ ### Minor Changes
6
+
7
+ - 11aa655: feat(constant): remove a relation's target with its owner via `cascade: "remove"`
8
+
9
+ A relation field can now take its target down with it: `image: field(File, { cascade: "remove" })`, arrays
10
+ included. The removal runs through the **target's service**, so the target's own `_postRemove` runs with it —
11
+ that is how removing a model also deletes the file's stored blob or object, with no extra wiring in the owning
12
+ module.
13
+
14
+ Only a relation accepts the option. A `String`, an `ID`, a scalar, and a nested array each fail while the class
15
+ is being built, naming the field: none of them points at a document the framework could remove.
16
+
17
+ Target services resolve lazily, at removal time, so a cascade adds no boot-order edge between two services and a
18
+ cascade cycle cannot fail the boot. They resolve _before_ the parent is touched, so a model cascading into a
19
+ module the app never mounted fails with nothing half-removed.
20
+
21
+ Two limits worth knowing. Nothing checks whether another document still references the same target, so declaring
22
+ `cascade` asserts that the field owns its target exclusively. And query-level removal (`deleteManyByQuery` /
23
+ `updateManyByQuery`) stamps `removedAt` in one atomic update that fires no hooks and therefore no cascade —
24
+ remove documents one at a time when they cascade.
25
+
26
+ - 11aa655: feat(store): invalidate sibling slices on create and refetch them in `Load.Units`
27
+
28
+ A model with more than one parent is listed by more than one slice, and a create could only ever be spliced
29
+ into the slice it was issued from. Creating a `bizDoc` from `bizDocInOrg` left `bizDocListInProject` without
30
+ it, and an RSC navigation back to that page replayed a cached payload whose server-stamped
31
+ `bizDocInitAtInProject` still satisfied `Load.Units`' cache check — so the new document stayed invisible until
32
+ a full reload.
33
+
34
+ `create<Model>` and `create<Model>InForm` now stamp a new per-slice `<model>StaleAt<Suffix>` on every slice
35
+ _except_ the one named by `sliceName`. Whether the new document belongs to a sibling slice is a server-side
36
+ filter decision, so the siblings are marked for revalidation rather than patched optimistically. `Load.Units`
37
+ refetches through `refresh<Model><Suffix>` when its slice's `StaleAt` is newer than its `InitAt`, and a
38
+ completed refresh restamps `InitAt` past `StaleAt` to end the stale state. The refetch is skipped while the
39
+ list is already loading, which also dedups several `Load.Units` mounted on one slice.
40
+
41
+ `Load.Units` also takes a `staleTime` prop, in milliseconds, for age-based revalidation independent of any
42
+ create: `staleTime={0}` always refetches on mount, `staleTime={30_000}` refetches only when the cached data is
43
+ older than 30s. Omitting it leaves the component purely invalidation-driven.
44
+
45
+ Update and remove are unchanged — they already walk every slice, because a document they touch is one the
46
+ cached lists can be searched for by id.
47
+
48
+ - 11aa655: feat(server): add subRoute hosts at runtime with `AKAN_SUB_ROUTE_HOSTS`
49
+
50
+ An app resolved a request Host to a basePath through a map fixed at build time — the domains written into
51
+ `akan.config.ts` plus the generated `<basePath>-<branch>.<serveDomain>`. A platform that mints its hostnames when
52
+ a project is created cannot write either one into the repo, so its subRoute hosts fell through to the root app.
53
+
54
+ `AKAN_SUB_ROUTE_HOSTS` adds to that map at boot, in the same spirit as `AKAN_PUBLIC_BASE_PATHS`:
55
+
56
+ ```
57
+ AKAN_SUB_ROUTE_HOSTS="soft=soft-abc.try.akanjs.com,soft.acme.com;office=office-abc.try.akanjs.com"
58
+ ```
59
+
60
+ Hosts are matched lowercased and without a port, exactly as the built-in ones are. The env mapping is a union with
61
+ the built one, never a replacement, so a tenant's own domains keep working and removing the env restores the
62
+ previous behaviour byte for byte.
63
+
64
+ A basePath the build does not serve is dropped with a warning rather than honoured — the route tree is a build
65
+ output, so accepting one would answer every request under it with a 404 and nothing to explain why. A malformed
66
+ entry is skipped the same way: the value is rendered by a deployment platform, and one bad character must not turn
67
+ into a boot loop across every pod.
68
+
69
+ `x-base-path` is now checked against the basePaths the build serves before it is trusted, matching the check
70
+ `getBasePathFromPathname` already applied to it. An unrecognised value falls through to host matching instead of
71
+ selecting a basePath that resolves to nothing.
72
+
73
+ - 25d5b15: feat(devkit): sync lib `page` trees into apps and honor `pageConfig.devOnly`
74
+
75
+ Apps can opt in with `syncPageLibs` (`true` / lib list) so `akan sync` links each lib's routes under
76
+ `page/(libs)/(<lib>)` — once per basePath when the app has subRoutes. The link is generated and
77
+ gitignored; the lib source stays the edit target. Collision on the resolved route pattern is a
78
+ sync-time error.
79
+
80
+ `pageConfig.devOnly: true` (literal only) keeps a route out of `akan build` while `akan start` still
81
+ serves it. On a `_layout` it excludes the whole subtree. Symlink-aware file ops avoid wiping a synced
82
+ lib when cleaning an app link, and dangling lib links no longer break `getApps`.
83
+
84
+ - 25d5b15: feat(signal): refresh websocket credentials in-session and re-check pubsub rooms
85
+
86
+ Handshake credentials now live on `AppWsData` (headers/cookies only). Clients that hold the token in
87
+ memory send it with `fetch.setJwt(...)`, which forwards an `__auth` frame; the server swaps the
88
+ snapshot synchronously and re-runs each subscribed room's guards, unsubscribing the ones that fail.
89
+
90
+ Guards should read the caller with `context.get("account")` instead of branching on HTTP vs websocket
91
+ transport. Slice-level guards still only wrap generated query/mutation endpoints — a pubsub endpoint
92
+ needs its own `guards` if the room itself must be protected.
93
+
94
+ ### Patch Changes
95
+
96
+ - 6d58c7e: add search feature for sqlit database with fts5
97
+ - 25d5b15: fix(ui): refresh EditModal when the hydrated edit payload is stale
98
+
99
+ RSC navigation can replay a cached page tree, and an edit shell hydrated from that payload can show
100
+ arbitrarily old form data. EditModal now treats a cache replay (or a `modelViewAt` older than 60s) as
101
+ stale and refetches via `edit<Model>` while keeping the modal open on the last known id.
102
+
103
+ - 42cf7a2: Normalize unsendable relayed WebSocket close codes to 1001 so Bun's global client WebSocket.close no longer throws InvalidAccessError at the gateway's client-to-upstream relay when a peer disappears without a close frame (code 1006). The upstream-to-client relay applies the same normalization defensively.
104
+ - 04cb46d: Allow the documented wsConnect export during source validation for root layouts, including base-path and grouped root boundaries, while continuing to reject it on nested layouts.
105
+
3
106
  ## 2.4.1
4
107
 
5
108
  ### Patch Changes
@@ -106,6 +106,13 @@ export interface ConstantFieldProps<
106
106
  validate?: (value: FieldValue, model: any) => boolean;
107
107
  text?: TextFieldRole;
108
108
  cascade?: CascadeAction;
109
+ /**
110
+ * Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
111
+ * in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
112
+ * placeholder still ships to `<Image>`. Unlike `hidden`/`secret` this is about cost, not secrecy: a field
113
+ * nothing can answer a question with is pure spend on every turn it rides.
114
+ */
115
+ visual?: boolean;
109
116
  meta?: Metadata;
110
117
  }
111
118
  export const fieldPresets = ["email", "password", "url"] as const;
@@ -197,6 +204,7 @@ interface ConstantFieldBuildProps<
197
204
  validate?: (value: FieldValue, model: any) => boolean;
198
205
  text?: TextFieldRole;
199
206
  cascade?: CascadeAction;
207
+ visual: boolean;
200
208
  modelRef: ConstantModelRef;
201
209
  arrDepth: number;
202
210
  optArrDepth: number;
@@ -321,6 +329,7 @@ export class ConstantField<
321
329
  readonly validate?: (value: FieldValue, model: any) => boolean;
322
330
  readonly text?: TextFieldRole;
323
331
  readonly cascade?: CascadeAction;
332
+ readonly visual: boolean;
324
333
  readonly modelRef: ConstantModelRef;
325
334
  readonly arrDepth: number;
326
335
  readonly optArrDepth: number;
@@ -353,6 +362,7 @@ export class ConstantField<
353
362
  this.validate = props.validate;
354
363
  this.text = props.text;
355
364
  this.cascade = props.cascade;
365
+ this.visual = props.visual;
356
366
  this.modelRef = props.modelRef;
357
367
  this.arrDepth = props.arrDepth;
358
368
  this.optArrDepth = props.optArrDepth;
@@ -432,6 +442,7 @@ export class ConstantField<
432
442
  validate: option.validate,
433
443
  text: option.text,
434
444
  cascade: option.cascade,
445
+ visual: option.visual ?? false,
435
446
  modelRef,
436
447
  arrDepth: arrDepth,
437
448
  optArrDepth: optArrDepth,
@@ -478,6 +489,7 @@ export class ConstantField<
478
489
  validate: this.validate,
479
490
  text: this.text,
480
491
  cascade: this.cascade,
492
+ visual: this.visual,
481
493
  modelRef: this.modelRef,
482
494
  arrDepth: this.arrDepth,
483
495
  optArrDepth: this.optArrDepth,
@@ -530,6 +542,26 @@ export const field = <
530
542
  fieldType: "property",
531
543
  });
532
544
 
545
+ /**
546
+ * A stored property the page renders and an agent never sees — `field(value, { visual: true })`, spelled short
547
+ * because the reason to reach for it is always the same one. A blur placeholder, a rendered HTML body, a
548
+ * serialized geometry: real data the screen needs, and hundreds of tokens per record that no question is answered
549
+ * from. It stays a plain `property` everywhere else, so persistence, search, forms and the page response are
550
+ * untouched.
551
+ */
552
+ field.visual = <
553
+ ExplicitType,
554
+ Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>,
555
+ MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never,
556
+ >(
557
+ value: Value,
558
+ option: FieldOption<Value, MapValue> = {},
559
+ ) =>
560
+ new FieldInfo<"property", Value, ExplicitType, MapValue>(value, {
561
+ ...option,
562
+ fieldType: "property",
563
+ visual: true,
564
+ });
533
565
  field.hidden = <
534
566
  ExplicitType,
535
567
  Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>,
package/constant/mask.ts CHANGED
@@ -15,6 +15,7 @@ interface MaskField {
15
15
  fieldType?: string;
16
16
  isClass?: boolean;
17
17
  modelRef?: MaskModel;
18
+ visual?: boolean;
18
19
  }
19
20
 
20
21
  export const maskFieldsOf = (model: MaskModel): Record<string, MaskField> | null => {
@@ -22,7 +23,12 @@ export const maskFieldsOf = (model: MaskModel): Record<string, MaskField> | null
22
23
  return fields && typeof fields === "object" ? (fields as Record<string, MaskField>) : null;
23
24
  };
24
25
 
25
- /** The `hidden` and `secret` field names of `model` that `value` still carries populated. */
26
+ /**
27
+ * The `hidden` and `secret` field names of `model` that `value` still carries populated.
28
+ *
29
+ * `visual` is deliberately not among them. A refusal here means a value must not be published at all, and a blur
30
+ * placeholder is not a secret — it is merely not worth its tokens, which masking answers by dropping it.
31
+ */
26
32
  export const leakingFieldsOf = (model: MaskModel, value: Record<string, unknown>): string[] => {
27
33
  const fields = maskFieldsOf(model);
28
34
  if (!fields) return [];
@@ -32,8 +38,9 @@ export const leakingFieldsOf = (model: MaskModel, value: Record<string, unknown>
32
38
  };
33
39
 
34
40
  /**
35
- * Strips what a model marks `hidden` or `secret`, by the model the caller names rather than by the one the value
36
- * happens to still carry.
41
+ * Strips what a model marks `hidden`, `secret`, or `visual`, by the model the caller names rather than by the one
42
+ * the value happens to still carry. The first two are secrecy and the third is cost, but the answer is the same
43
+ * one — leave the field out — and this is the only place every AI-facing read already passes through.
37
44
  *
38
45
  * That distinction is the whole point. A check that reads the class off the value can only mask what arrives as an
39
46
  * instance, so a `{ ...doc }` spread, a `toJSON()`, an `immerify()`, or a round-trip through `JSON.stringify` reaches
@@ -53,7 +60,7 @@ export const mask = (model: MaskModel, value: unknown): unknown => {
53
60
  const source = value as Record<string, unknown>;
54
61
  const masked: Record<string, unknown> = {};
55
62
  for (const [key, field] of Object.entries(fields)) {
56
- if (field.fieldType === "hidden" || field.fieldType === "secret" || !(key in source)) continue;
63
+ if (field.fieldType === "hidden" || field.fieldType === "secret" || field.visual || !(key in source)) continue;
57
64
  masked[key] = field.isClass && field.modelRef ? mask(field.modelRef, source[key]) : source[key];
58
65
  }
59
66
  return masked;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.50",
3
+ "version": "3.0.0-alpha.52",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -45,6 +45,19 @@ type GatewayUpstream = {
45
45
  ws?: Extract<AkanUpstream, { type: "tcp" }>;
46
46
  };
47
47
 
48
+ /**
49
+ * Received-only close codes cannot be sent in a close frame. The gateway deliberately normalizes
50
+ * every unsendable code, including semantically distinct 1005 and 1006 events, to 1001 in both
51
+ * relay directions. In particular, Bun's global client `WebSocket.close()` throws an
52
+ * InvalidAccessError for these codes at the client-to-upstream relay; normalization at the
53
+ * upstream-to-client `Bun.ServerWebSocket.close()` relay is defensive and keeps behavior symmetric.
54
+ */
55
+ const relayableCloseCode = (code: number): number => {
56
+ if (code >= 3000 && code <= 4999) return code;
57
+ if (code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
58
+ return 1001;
59
+ };
60
+
48
61
  /** Options for the Akan gateway that launches child server replicas and listens for traffic. */
49
62
  export interface AkanAppOptions {
50
63
  replica?: number | string;
@@ -602,9 +615,7 @@ export class AkanApp {
602
615
  if (result === 0) upstream.close();
603
616
  });
604
617
  upstream.addEventListener("close", (event) => {
605
- const code = AkanApp.#sendableCloseCode(event.code);
606
- if (code === undefined) ws.close();
607
- else ws.close(code, event.reason);
618
+ ws.close(relayableCloseCode(event.code), event.reason);
608
619
  });
609
620
  upstream.addEventListener("error", () => ws.close(1011, "upstream websocket error"));
610
621
  Object.assign(ws.data, { pending });
@@ -625,15 +636,8 @@ export class AkanApp {
625
636
  else ws.data.pending?.push(payload as string | ArrayBuffer);
626
637
  }
627
638
 
628
- static #sendableCloseCode(code: number): number | undefined {
629
- const sendable = (code >= 1000 && code <= 1014 && (code < 1004 || code > 1006)) || (code >= 3000 && code <= 4999);
630
- return sendable ? code : undefined;
631
- }
632
-
633
639
  #handleWsClose(ws: Bun.ServerWebSocket<GatewayWsData>, code: number, reason: string) {
634
- const upstreamCode = AkanApp.#sendableCloseCode(code);
635
- if (upstreamCode === undefined) ws.data.upstream.close();
636
- else ws.data.upstream.close(upstreamCode, reason);
640
+ ws.data.upstream.close(relayableCloseCode(code), reason);
637
641
  const child = this.#children.get(ws.data.childIdx);
638
642
  if (child) child.metrics.activeWebSockets = Math.max(0, (child.metrics.activeWebSockets ?? 1) - 1);
639
643
  }
@@ -80,6 +80,13 @@ export interface McpServerOption {
80
80
  * the document is built once at boot and cached by clients, and it is read by a model rather than by a person.
81
81
  */
82
82
  language?: string;
83
+ /**
84
+ * Whether a structured result also ships as serialized JSON in the text block, `true` by default because that
85
+ * is what the spec asks of a server for clients that predate `structuredContent`. It is also a flat doubling:
86
+ * every model-returning tool sends its whole payload twice, so a deployment whose clients read the structured
87
+ * half turns this off and halves what each of those calls costs the model. `AKAN_MCP_LEGACY_TEXT=false`.
88
+ */
89
+ legacyTextBlock?: boolean;
83
90
  /** OAuth resource-server identity: which issuers a client may authenticate with, and the scopes to demand. */
84
91
  auth?: McpAuthOption;
85
92
  }
@@ -622,6 +629,11 @@ export class AkanServer {
622
629
  return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
623
630
  }
624
631
 
632
+ /** Named rather than defaulted: an absent env must leave the option unset so a value written in code still wins. */
633
+ static #isEnvOff(...names: string[]) {
634
+ return names.some((name) => process.env[name] === "false" || process.env[name] === "0");
635
+ }
636
+
625
637
  /** Both differ per environment, so they belong in env rather than in the app's source alongside the switch. */
626
638
  static #mcpAuthFromEnv(): McpAuthOption {
627
639
  const authorizationServers = AkanServer.#envList("AKAN_MCP_AUTH_SERVERS");
@@ -657,6 +669,7 @@ export class AkanServer {
657
669
  ...(allowedOrigins?.length ? { allowedOrigins } : {}),
658
670
  ...(Number.isInteger(pageSize) && pageSize > 0 ? { pageSize } : {}),
659
671
  ...(process.env.AKAN_MCP_LANGUAGE ? { language: process.env.AKAN_MCP_LANGUAGE } : {}),
672
+ ...(AkanServer.#isEnvOff("AKAN_MCP_LEGACY_TEXT") ? { legacyTextBlock: false } : {}),
660
673
  };
661
674
  }
662
675
 
@@ -1,5 +1,6 @@
1
1
  import { type BackendEnv, ENDPOINT_META } from "akanjs/base";
2
2
  import { Logger } from "akanjs/common";
3
+ import { ConstantRegistry, mask } from "akanjs/constant";
3
4
  import { DictionaryLookup } from "akanjs/dictionary";
4
5
  import { NoDocumentError } from "akanjs/document";
5
6
  import type { InjectRegistry, LiveRegistry } from "akanjs/service";
@@ -42,12 +43,20 @@ interface McpDispatcherProps {
42
43
  middleware: Map<string, MiddlewareCls>;
43
44
  /** The one language error text is resolved in, matching the catalogue the client was handed. */
44
45
  language?: string;
46
+ /**
47
+ * Whether a structured result also ships as serialized JSON in the text block. `true` by default, which is what
48
+ * the spec asks for; `false` halves what every model-returning tool costs.
49
+ */
50
+ legacyTextBlock?: boolean;
45
51
  }
46
52
 
47
53
  /** Executes one MCP tool call or resource read through the ordinary signal pipeline. */
48
54
  export class McpDispatcher {
49
55
  static readonly logger = new Logger("McpDispatcher");
50
56
 
57
+ /** What the text block says when the serialized duplicate is off. Read by a model, so English. */
58
+ static readonly structuredNote = "The result is in this call's structuredContent.";
59
+
51
60
  readonly #props: McpDispatcherProps;
52
61
  #endpoints: Map<string, { endpointInfo: EndpointInfo; endpoint: Endpoint }> | null = null;
53
62
  /**
@@ -68,10 +77,10 @@ export class McpDispatcher {
68
77
  const found = this.#index().get(exposed.key);
69
78
  if (!found) return McpDispatcher.#failure(`Tool "${exposed.key}" is declared but not mounted on this server.`);
70
79
  try {
71
- const value = await this.#exec(exposed.key, found, args, req);
80
+ const value = McpDispatcher.#readable(exposed, await this.#exec(exposed.key, found, args, req));
72
81
  const structuredContent = McpDocument.structuredContent(exposed.endpoint, value);
73
82
  return {
74
- content: [{ type: "text", text: McpDispatcher.#text(structuredContent, value) }],
83
+ content: this.#content(structuredContent, value),
75
84
  ...(structuredContent === undefined ? {} : { structuredContent }),
76
85
  isError: false,
77
86
  };
@@ -223,6 +232,19 @@ export class McpDispatcher {
223
232
  return status && status < 500 ? McpErrorCode.invalidParams : McpErrorCode.internal;
224
233
  }
225
234
 
235
+ /**
236
+ * A structured result rides twice by default: once as `structuredContent`, once as the same JSON here, which is
237
+ * what the spec asks of a server for clients that predate the structured field. Every model return therefore
238
+ * costs the model twice what it carries, and `legacyTextBlock: false` is the deployment that has decided its
239
+ * clients read the structured half — the pointer keeps `content` non-empty, since a client that renders
240
+ * `content[0].text` and finds nothing shows an empty answer rather than a missing one.
241
+ */
242
+ #content(structuredContent: unknown, value: unknown): McpToolResult["content"] {
243
+ if (structuredContent !== undefined && this.#props.legacyTextBlock === false)
244
+ return [{ type: "text", text: McpDispatcher.structuredNote }];
245
+ return [{ type: "text", text: McpDispatcher.#text(structuredContent, value) }];
246
+ }
247
+
226
248
  /**
227
249
  * The text block every result carries, whether or not a structured one goes with it.
228
250
  *
@@ -239,6 +261,24 @@ export class McpDispatcher {
239
261
  return JSON.stringify(structuredContent ?? value) ?? "null";
240
262
  }
241
263
 
264
+ /**
265
+ * Strips what the return model marks `visual` — a field the page renders and no question is answered from.
266
+ *
267
+ * Done here rather than in `resolveReturn`, which every ordinary HTTP response also passes through: the point of
268
+ * a `visual` field is that a browser still receives it. MCP results reach an agent and nothing else, so this is
269
+ * where the model's own declaration is honoured, and it is the same `mask` the in-page agent's reads use.
270
+ */
271
+ static #readable(exposed: McpExposedEndpoint, value: unknown): unknown {
272
+ const { refName, modelType } = exposed.endpoint.returns;
273
+ if (!modelType) return value;
274
+ try {
275
+ return mask(ConstantRegistry.getModelRef(refName, modelType), value);
276
+ } catch {
277
+
278
+ return value;
279
+ }
280
+ }
281
+
242
282
  static #failure(message: string): McpToolResult {
243
283
  return { content: [{ type: "text", text: message }], isError: true };
244
284
  }
@@ -45,6 +45,8 @@ export interface McpRouterProps {
45
45
  * rarely sees. Defaults to `en`, falling back to the first registered language when the app has no `en`.
46
46
  */
47
47
  language?: string;
48
+ /** Whether a structured result also ships as serialized JSON in the text block. Default `true`. */
49
+ legacyTextBlock?: boolean;
48
50
  auth?: McpAuthOption;
49
51
  }
50
52
 
@@ -14,11 +14,15 @@ export interface JsonSchemaBuilderOptions {
14
14
 
15
15
  export interface JsonSchemaModelOptions {
16
16
  /**
17
- * Drops `hidden` and `secret` fields. `SignalContext.resolveReturn` strips both from every response, so naming
18
- * them describes a value the caller can never read — and on a model like `user` the names are themselves the
19
- * leak: `password`, `accountId`, `phone` published as readable properties of the model. This is the one place a
20
- * field the framework blocks on every value path is still visible, so it is scoped to schemas that describe a
21
- * *response*. A request body is a different shape and legitimately carries both.
17
+ * Drops `hidden`, `secret`, and `visual` fields. `SignalContext.resolveReturn` strips the first two from every
18
+ * response, so naming them describes a value the caller can never read — and on a model like `user` the names
19
+ * are themselves the leak: `password`, `accountId`, `phone` published as readable properties of the model. This
20
+ * is the one place a field the framework blocks on every value path is still visible, so it is scoped to schemas
21
+ * that describe a *response*. A request body is a different shape and legitimately carries all three.
22
+ *
23
+ * `visual` is dropped here because it is dropped from the payload an AI caller receives, and a schema that
24
+ * promises a field the value omits is worse than one that never named it: a non-optional visual field would be
25
+ * listed `required` and a validating client would refuse the whole result.
22
26
  */
23
27
  readable?: boolean;
24
28
  }
@@ -56,7 +60,7 @@ export class JsonSchemaBuilder {
56
60
  const required: string[] = [];
57
61
  for (const [key, field] of Object.entries(fields)) {
58
62
  const props = field.getProps();
59
- if (readable && (props.fieldType === "hidden" || props.fieldType === "secret")) continue;
63
+ if (readable && (props.fieldType === "hidden" || props.fieldType === "secret" || props.visual)) continue;
60
64
  properties[key] = this.#field(field);
61
65
  if (!props.nullable) required.push(key);
62
66
  }
@@ -51,6 +51,11 @@ const blockTags = new Set([
51
51
  * agent's own UI is marked `data-agent-ui` and skipped, so a turn never re-reads its own transcript, and a
52
52
  * password value is never read — the screen shows dots, so the DOM holds more than the user sees.
53
53
  *
54
+ * A region the app marks `data-agent-skip` — what `Agent.Skip` renders — costs a `[skipped: <name>]` line instead
55
+ * of its text. It stands in the output rather than vanishing because a deleted region reads as an absent one, and
56
+ * an agent asked about a footer it never saw answers that the page has none. Naming the region as `section` reads
57
+ * it: the marker is what the default read leaves out, not a wall.
58
+ *
54
59
  * A heading carries `(#anchor)` whenever it opens a container that has an id or a scope path, because that is the
55
60
  * name `readScreen({ section })` and `highlight` take: printing the text without the name leaves an agent
56
61
  * guessing at a slug. For the same reason a truncated read ends with the headings below the cut instead of only
@@ -64,7 +69,7 @@ export class ScreenReader {
64
69
  const reader = new ScreenReader();
65
70
  const title = document.title.trim();
66
71
  if (title) reader.#lines.push(`Page: ${title}`);
67
- reader.#walk(root ?? document.body);
72
+ reader.#walk(root ?? document.body, true);
68
73
  reader.#flush();
69
74
  return reader.#text() || "The page is rendering nothing readable.";
70
75
  }
@@ -78,10 +83,10 @@ export class ScreenReader {
78
83
  if (typeof document === "undefined") return "No rendered document is available.";
79
84
  const reader = new ScreenReader();
80
85
  const container = ScreenReader.#sectionOf(heading, root);
81
- if (container) reader.#walk(container);
86
+ if (container) reader.#walk(container, true);
82
87
  else {
83
88
  const level = headingLevels[heading.tagName.toUpperCase() as keyof typeof headingLevels] ?? 1;
84
- reader.#walk(heading);
89
+ reader.#walk(heading, true);
85
90
  let next = heading.nextElementSibling;
86
91
  while (next && !ScreenReader.#stops(next, level)) {
87
92
  reader.#walk(next);
@@ -123,6 +128,17 @@ export class ScreenReader {
123
128
  return "";
124
129
  }
125
130
 
131
+ /**
132
+ * `checkVisibility()` reports whether the element has a layout box, and a `display: contents` wrapper has none
133
+ * while its children do — so Chrome answers false for a wrapper the user is looking straight at, and skipping it
134
+ * would drop that whole subtree (happy-dom answers true, so no DOM test sees the difference). Reading through it
135
+ * is safe only because the walk is top-down: a `display: none` ancestor bails on its own computed display first.
136
+ */
137
+ static #rendered(el: HTMLElement) {
138
+ if (typeof el.checkVisibility !== "function" || el.checkVisibility()) return true;
139
+ return getComputedStyle(el).display === "contents";
140
+ }
141
+
126
142
  static #stops(el: Element, level: number) {
127
143
  const found = headingLevels[el.tagName.toUpperCase() as keyof typeof headingLevels];
128
144
  return !!found && found <= level;
@@ -141,7 +157,7 @@ export class ScreenReader {
141
157
  if (node.nodeType !== Node.ELEMENT_NODE) return;
142
158
  const el = node as HTMLElement;
143
159
  const tag = el.tagName.toUpperCase();
144
- if (skipTags.has(tag) || el.hasAttribute("data-agent-ui")) return;
160
+ if (skipTags.has(tag) || el.hasAttribute("data-agent-ui") || el.hasAttribute("data-agent-skip")) return;
145
161
  const level = headingLevels[tag as keyof typeof headingLevels];
146
162
  if (level) {
147
163
  const anchor = ScreenReader.anchorOf(el);
@@ -163,7 +179,15 @@ export class ScreenReader {
163
179
  : `${kept}${note}`;
164
180
  }
165
181
 
166
- #walk(node: Node): void {
182
+ /** What stands where a skipped region was: its own name, and the anchor `section` takes to read it on request. */
183
+ #mark(el: HTMLElement, label: string) {
184
+ const anchor = el.getAttribute("data-agent-scope") ?? el.getAttribute("data-agent-zone") ?? el.id;
185
+ this.#flush();
186
+ this.#buffer = `[skipped: ${label || el.tagName.toLowerCase()}${anchor ? ` (#${anchor})` : ""}]`;
187
+ this.#flush();
188
+ }
189
+
190
+ #walk(node: Node, isRoot = false): void {
167
191
  if (this.#length > ScreenReader.limit * 2) {
168
192
  this.#outline(node);
169
193
  return;
@@ -179,7 +203,13 @@ export class ScreenReader {
179
203
  if (skipTags.has(tag)) return;
180
204
  if (el.hasAttribute("data-agent-ui") || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true")
181
205
  return;
182
- if (typeof el.checkVisibility === "function" && !el.checkVisibility()) return;
206
+ if (!ScreenReader.#rendered(el)) return;
207
+
208
+ const skipped = isRoot ? null : el.getAttribute("data-agent-skip");
209
+ if (skipped !== null) {
210
+ this.#mark(el, skipped);
211
+ return;
212
+ }
183
213
  if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
184
214
  this.#control(el, tag);
185
215
  return;
@@ -1,6 +1,6 @@
1
1
  import { ScreenReader } from "./ScreenReader";
2
2
 
3
- const containerAttrs = ["data-agent-zone", "data-agent-scope"] as const;
3
+ const containerAttrs = ["data-agent-zone", "data-agent-scope", "data-agent-skip"] as const;
4
4
  const controlAttrs = ["data-akan-action", "data-akan-state"] as const;
5
5
  const headingSelector = "h1, h2, h3, h4, h5, h6";
6
6
  const nameCap = 40;
@@ -9,9 +9,9 @@ const nameCap = 40;
9
9
  * Resolves a name the agent read on screen to the element it names.
10
10
  *
11
11
  * Four vocabularies, every one of them something already on the screen rather than a selector the model invented:
12
- * the `data-akan-action` / `data-akan-state` annotation a control carries and `readScreen` prints beside it, an
13
- * `Agent.Zone` or `useScreenScope` container, a plain element id what a docs slide is addressed by — and, last, a
14
- * **heading by its own text**.
12
+ * the `data-akan-action` / `data-akan-state` annotation a control carries and `readScreen` prints beside it, a
13
+ * container — an `Agent.Zone`, a `useScreenScope` scope, or an `Agent.Skip` region named by the marker left in its
14
+ * place — a plain element id, what a docs slide is addressed by, and, last, a **heading by its own text**.
15
15
  *
16
16
  * A heading is matched on letters and digits alone, so the slug an agent naturally writes for a heading it read
17
17
  * ("images-and-public-env") finds "Images And Public Env". That tolerance is for headings only: a heading is a
@@ -20,7 +20,8 @@ const nameCap = 40;
20
20
  *
21
21
  * Nothing hidden ever resolves, by the same rule `readScreen` skips it. A collapsed panel or an off-variant
22
22
  * duplicate is not what the user is looking at, and scrolling to one flashes a ring nobody can see — which reads
23
- * as the tool being broken rather than as a miss.
23
+ * as the tool being broken rather than as a miss. A `display: contents` wrapper does not resolve either, even
24
+ * though the reader now reads through it: it has no box, so `scrollIntoView` has nothing to scroll to.
24
25
  */
25
26
  export class ScreenTarget {
26
27
  static container(name: string, root?: HTMLElement | null): HTMLElement | null {
@@ -28,8 +29,13 @@ export class ScreenTarget {
28
29
  if (!scope || !name) return null;
29
30
  if (ScreenTarget.#named(scope, containerAttrs, name)) return scope;
30
31
  const escaped = CSS.escape(name);
31
- const selector = [...containerAttrs.map((attr) => `[${attr}="${escaped}"]`), `#${escaped}`].join(", ");
32
- return ScreenTarget.#first(scope, selector);
32
+ const selector = containerAttrs.map((attr) => `[${attr}="${escaped}"]`).join(", ");
33
+
34
+ return (
35
+ ScreenTarget.#first(scope, selector) ??
36
+ [...scope.querySelectorAll<HTMLElement>("[id]")].find((el) => el.id === name && ScreenTarget.#visible(el)) ??
37
+ null
38
+ );
33
39
  }
34
40
 
35
41
  static control(name: string, root?: HTMLElement | null): HTMLElement | null {
@@ -107,7 +107,7 @@ export class StoreSurfaceSource implements SurfaceSource {
107
107
  section: {
108
108
  type: "string",
109
109
  description:
110
- "One region of the screen: a heading's anchor as readScreen prints it, the heading's own text, or a scope path from the screen context. Omit to read all of it.",
110
+ "One region of the screen: a heading's anchor as readScreen prints it, the heading's own text, a scope path from the screen context, or the name in a `[skipped: name]` marker. Omit to read all of it.",
111
111
  },
112
112
  },
113
113
  additionalProperties: false,