@foldspace_npm/harness 0.1.38 → 0.1.40

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.
package/CLAUDE.md CHANGED
@@ -362,8 +362,53 @@ to what they know. The action handler is yours: call it "the code behind it".
362
362
  | **Task agent** | *how* to do one reasoning job | Agent Studio | instructions + response schema |
363
363
  | **Navigation route** | where the agent can send the user | Agent Studio | a URL template + what the page is for |
364
364
 
365
- Knowledge search, navigation and sharing the current tab are platform
366
- built-ins — no code, and a first experience can lean on them.
365
+ **Built into every agent - never build your own.** These need no action and no
366
+ code. A copy of one competes with the built-in: the agent may pick yours, and
367
+ yours lacks what the built-in does (consent, routes, settings). A first
368
+ experience can lean on them. Lint warns (`no-native-lookalike`) when an action
369
+ looks like one.
370
+
371
+ | Built-in | What the agent gets | Turn it on | Never build |
372
+ |---|---|---|---|
373
+ | **Knowledge search** | Answers from the agent's knowledge | Add a knowledge source or an internal article | An action that searches docs, help or FAQ |
374
+ | **Navigation** | Opens a page of the app | A navigation route (`create_navigation_route`) per page | A URL builder, or an action whose job is opening a page. Finding an item by name is an action; opening its page is a route |
375
+ | **Share current tab** | A screenshot and/or the HTML of the page, once, when the agent needs it (the user agrees first) | Agent settings → Customize → Data Access & Handoff | An action that reads, summarises or screenshots the page: no "what's on this page", no handler that returns `document.body`, `innerHTML` or page text |
376
+ | **Share screen** | The page with every message while sharing is on | The same setting; a developer can also set `screenSharingSettings: { active: true }` in `setConfiguration` | The same |
377
+ | **Shared State** | The app's own state: an open form, filters, the selection | `shareState()` in the agent code | A "current view" or "what is selected" action |
378
+ | **Voice** | Voice chat and dictation in the panel | Agent settings | Microphone, speech-to-text or text-to-speech code |
379
+ | **Ask the user** | A question card with choices | Below | A generic "ask the user", "clarify" or "choose an option" action |
380
+ | **Live-agent handoff, support ticket** | Hands the chat to a person, or opens a ticket | Agent settings → Customize → Data Access & Handoff | Your own handoff - unless the built-in doesn't cover their support tool: ask the human first |
381
+
382
+ When the experience needs one of these and it's off, ask the human to turn it
383
+ on; it changes the live agent. If an existing action already copies one (a
384
+ URL builder, a page reader), say so to the human and propose moving to the
385
+ built-in - don't delete it unasked.
386
+
387
+ **Asking the user a question is a built-in too: `ask_user_question`.** It shows
388
+ a card with 1-4 questions, 2-4 choices each plus "Other", and waits for the
389
+ answers. **Never build an action or a card that does the same** - a generic
390
+ "ask the user", "clarify" or "choose an option". It needs no action, handler
391
+ or code. It may be off for this agent: when the agent needs it, ask the human
392
+ to turn on **Ask questions with choices** (agent settings → **Customize → Data
393
+ access**, then Save); it changes the live agent, and takes about a minute to
394
+ reach it. Build your own input card only when the choices come from the app's
395
+ data or the card does the work, as `pick-from-a-list` and `create-a-record` do.
396
+
397
+ Change the built-in only when the human asks:
398
+
399
+ - **A different look:** set `--fs-ask-*` variables (`--fs-ask-primary`,
400
+ `--fs-ask-surface`, `--fs-ask-border`, `--fs-ask-radius`...) in the agent's
401
+ `customCss`. Never a new card for a new look, and never style its class names.
402
+ - **Their own card:** an action handler under the key `ask_user_question`,
403
+ with no Studio action. It runs instead of the built-in: `execute` gets
404
+ `params.questions`, and the card calls `callback({ status: "ANSWERED",
405
+ answers })` with one `{ header, selected, otherText?, skipped? }` per question,
406
+ or `cancel()` when everything is skipped - the same result, so the agent's
407
+ instructions still fit.
408
+ - **Their own instructions too:** only then a Studio action named
409
+ `ask_user_question`. It replaces the built-in completely, text included.
410
+ - **Removing one:** unpublish an `ask_user_question` action before deleting
411
+ it. A deleted published action still keeps the built-in off.
367
412
 
368
413
  1. `create_action` as a **draft**. `generate_action_handler` works from a draft
369
414
  schema; do not publish yet. Generating the handler from the schema is what
@@ -613,11 +658,14 @@ Do not report success without all six:
613
658
  5. The action behaves against the real workflow: `[actions]
614
659
  local-handler:execute` in the attach log after `foldspace ask` (or the
615
660
  human) exercises the visible agent. A card also needs
616
- `local-handler:render` from a real chat turn - not a hand-built `render()`
617
- call. A view card leaves the chat free: in `ask`'s screenshot the panel's
618
- message box does not read "Provide input, or cancel" beside a Cancel
619
- button. If it does, `render` uses `callback` or `cancel` somewhere, or the
620
- handler sets `awaitUserInput` - take them out. Then ask a follow-up
661
+ `local-handler:render` from a real chat turn (`renderComponent` logs as
662
+ `render` too) - not a hand-built call. A view card leaves the chat free: `ask`
663
+ returns a reply, and in its screenshot the panel shows no stop button and its
664
+ message box does not read "Provide input, or cancel" beside a Cancel button.
665
+ If `ask` returns `ok: false` with `hasCard: true`, or the panel shows either of
666
+ those, the agent is waiting for the card: it uses `callback` or `cancel`
667
+ somewhere, or the SDK can't read it and it never calls `readyState`
668
+ (**Agentic UI components**). Take them out, or destructure the keys. Then ask a follow-up
621
669
  question about the card (`foldspace ask` again): the reply quotes its
622
670
  numbers exactly as the card shows them. If it does not, make `execute`
623
671
  return them as the card shows them (rule 2). An input card holds the chat
@@ -772,17 +820,37 @@ wins**. <https://docs.foldspace.ai/customize/configuration/>
772
820
 
773
821
  ## Agentic UI components: what bites
774
822
 
775
- An Agentic UI component is the card an action draws in the chat (`render`).
776
- Each of these cost a real build.
777
-
778
- - **A card is a view card or an input card.** `render(data, host, header,
779
- callback, cancel, readyState)` decides which: a `render` that uses
780
- `callback`, `cancel` or `readyState` is an input card, and the agent waits
781
- until the card reports through one of them; one that uses none of them is
782
- a view card.
823
+ An Agentic UI component is the card an action draws in the chat
824
+ (`renderComponent`, or the older `render`). Each of these cost a real build.
825
+
826
+ - **Write new cards with `renderComponent`.**
827
+ `renderComponent({ data, host, header, callback, cancel, readyState, theme })`
828
+ takes one object, so a card names only what it uses and no argument can land
829
+ in the wrong position. Destructure the keys in the parameter: the SDK and lint
830
+ read them to tell the two kinds of card apart. Nested keys are fine
831
+ (`{ data: { items }, host }`), and so is `const { data, host } = ctx`. The SDK
832
+ can't read the object passed on (`mount(ctx)`) or aliased, `...rest` at the top
833
+ of the pattern, `arguments`, or a bound function. The agent then waits until
834
+ that card calls `readyState`, `callback` or `cancel`, so a view card that calls
835
+ none of them never gets a reply until the user presses stop. Destructure the
836
+ keys; where you can't, the view card calls `readyState` once it is drawn. Lint
837
+ warns on these shapes, since it can't check them either.
838
+ `render(data, host, header, callback, cancel,
839
+ readyState, theme)` still works, and `renderComponent` wins if an action has
840
+ both. Leave a working `render` alone unless you are changing that card. The
841
+ recipes still use `render`: when you copy one, convert it, so
842
+ `render: (result, host, header) =>` becomes
843
+ `renderComponent: ({ data: result, host, header }) =>`, and an input card's
844
+ `render: (result, host, header, callback, cancel) =>` becomes
845
+ `renderComponent: ({ data: result, host, header, callback, cancel }) =>`.
846
+ - **A card is a view card or an input card.** What it uses decides: a card
847
+ that uses `callback`, `cancel` or `readyState` is an input card, and the
848
+ agent waits until the card reports through one of them; one that uses none
849
+ of them is a view card. Foldspace's docs call these **read-only** and **input**
850
+ cards; they mean the same thing.
783
851
  - **A view card shows something** - an account, a list, deals at risk -
784
- with `render(data, host, header)`: no `callback`, no `cancel`. As soon as
785
- `render` returns, the agent gets "Shown to the user. The user has not
852
+ with `renderComponent({ data, host, header })`: no `callback`, no `cancel`.
853
+ As soon as it returns, the agent gets "Shown to the user. The user has not
786
854
  acted on it." plus exactly what `execute` returned, and answers questions
787
855
  about the card from that. So `execute` returns the facts the card shows,
788
856
  as fields, and nothing else - or the failure the card draws with
@@ -801,23 +869,30 @@ Each of these cost a real build.
801
869
  draws a default "Cancel" control on a card; not yet checked in the live
802
870
  product.
803
871
  - **When the conversation should go on while the user decides,** an input
804
- card calls `readyState(value)` (`render`'s 6th argument) once it is
805
- drawn: the agent gets `value` as what is shown and carries on. ⚠️ The
872
+ card calls `readyState(value)` once it is drawn: the agent gets `value` as what is shown and carries on. ⚠️ The
806
873
  user's answer then arrives as a follow-up turn - seen in the SDK's
807
874
  headless mode (`mode: "HEADLESS"`, the customer's own chat UI), not yet
808
875
  checked in the chat panel. No recipe uses it yet.
809
- - **Never set `awaitUserInput`** on either kind: `render` decides whether
810
- the agent waits. An input card does not need it. The chat panel ignores
876
+ - **Never set `awaitUserInput`** on either kind: it is deprecated. What the
877
+ card uses decides whether the agent waits. An input card does not need it. The chat panel ignores
811
878
  it; in headless mode `awaitUserInput: true` on a view card leaves the
812
879
  agent waiting for a callback that never comes, until the user presses
813
880
  stop. Lint stops the build on `awaitUserInput: true` on a view card, and
814
- warns on any other `awaitUserInput` and on a `render` that declares
815
- `callback`, `cancel` or `readyState` but uses none of them (the SDK then
816
- treats it as a view card).
881
+ warns on any other `awaitUserInput`, on a card that declares `callback`,
882
+ `cancel` or `readyState` but uses none of them (the SDK then treats it as a
883
+ view card), and on a `renderComponent` the SDK can't read.
817
884
  - **What reaches the agent is the tool response: the data and the user's
818
885
  choice, as fields**, with the state (chosen, declined, cancelled, failed)
819
886
  and nothing about what to do next (**Rules**, 1). A view card needs no
820
887
  state of its own: the SDK tells the agent it was shown.
888
+ - **Never report a view card through `callback`.** Calling `callback(data)`
889
+ right after drawing (the old way to get a card's data to the agent) tells
890
+ the agent the user submitted that data, and it replies as if they had.
891
+ A view card takes `renderComponent({ data, host, header })`, or calls
892
+ `readyState(value)` to send less than `execute` returned.
893
+ - **The agent only knows what the card reports, not what it draws.** A
894
+ figure or date on the card that is not in that data is unknown to the
895
+ agent, so put every fact a follow-up question could need in it.
821
896
  - **A failed or signed-out call is never drawn as zeros.** A signed-out response
822
897
  often looks like a valid empty one: a 200 with zeros, nulls or an empty list.
823
898
  Before you draw numbers, check that the response belongs to a signed-in user -
@@ -834,11 +909,8 @@ Each of these cost a real build.
834
909
  code, from the API's own numbers - in `execute` for a view card, since its
835
910
  result is what the agent gets - show them on the card, and hand the agent
836
911
  the same figures. In the action's Studio `instructions`, tell the agent to
837
- answer follow-up questions from the data it receives, to quote money and
838
- counts exactly as given, never to add them up itself (a model that adds up
839
- line items will eventually add them up wrong), and, right after a view
840
- card, to say one short sentence at most - never repeat what the card
841
- shows.
912
+ quote money and counts exactly as given and never to add them up itself (a
913
+ model that adds up line items will eventually add them up wrong).
842
914
  - **A card matches the panel, not the page.** The card sits in the agent's
843
915
  panel - white by default (the agent's theme: `backgroundColor #FFFFFF`,
844
916
  `primaryColor` for accents; a `darkTheme` when the panel is dark, and the
@@ -851,6 +923,21 @@ Each of these cost a real build.
851
923
  white, so that falls back to greys and the file says so. Record where the
852
924
  values came from in the profile's Brand section. Never copy colours by eye:
853
925
  that is how a dark card ends up in a white panel.
926
+ - **Every new card follows the panel's mode.** The SDK exposes the mode and
927
+ colours and paints nothing, so a card with fixed colours stays light in a dark
928
+ panel. Take surface, text and border from the `--fs-*` variables, with
929
+ `brand.ts` as the fallback: `background: var(--fs-paper, ${brand.surface})`,
930
+ `color: var(--fs-text, ${brand.text})`,
931
+ `border-color: var(--fs-border, ${brand.border})`. Put
932
+ `color-scheme: var(--fs-color-scheme)` on the card's own element for native
933
+ controls, and use `[data-fs-mode="dark"]` for what has no variable, such as a
934
+ shadow. What CSS can't reach, such as a canvas, reads `theme` (`theme.mode`,
935
+ `theme.primary`) and redraws on `theme.onChange(fn)`. A mode change repaints:
936
+ the card does not run again and nothing reaches the agent. The colours change
937
+ only if the agent has a `darkTheme`; without one only the mode changes, so the
938
+ variables stay light. Look at the card in both modes
939
+ (`agent.setThemeMode("DARK")`) before you call it done. Fields:
940
+ <https://docs.foldspace.ai/guides/in-chat-ui/#support-dark-mode>
854
941
 
855
942
  ## Layout
856
943
 
package/README.md CHANGED
@@ -256,13 +256,13 @@ v1 flags:
256
256
 
257
257
  - **error** `no-static-agent-prompt` - `execute`, a card's `callback` or its `readyState` handing the agent instructions (`directive`, “tell the user”, …)
258
258
  - **error** `no-runtask-prompt` — `runTask({ data: { prompt } })` and the same keys; a customer API field named `prompt` is allowed
259
- - **error** `card-contract` - `awaitUserInput: true` on a view card (its `render` uses no `callback`, `cancel` or `readyState`): the chat panel ignores it, but in headless mode the agent waits for a callback that never comes
259
+ - **error** `card-contract` - `awaitUserInput: true` on a view card (its `renderComponent` or `render` uses no `callback`, `cancel` or `readyState`): the chat panel ignores it, but in headless mode the agent waits for a callback that never comes
260
260
  - **error** `no-test-mode-in-bundle` - `armAllInstances` used anywhere in `agent/` (called, or handed on to be called later), or `setTestMode(...)` with anything but a literal `false`. A code project's own copy of the runtime in `agent/runtime/` is not refused for the `setTestMode` inside its `armAllInstances`
261
261
  - **error** `no-fixed-customer-address` - on a product with a subdomain per customer (`PER_CUSTOMER_SUBDOMAIN` in `agent/constants.ts`), one customer's address in `API_BASE` or anywhere in `agent/`; while that is undecided, an address whose first part looks like a customer's name (`acme.` - not `app.`, `eu1.`, `reports.`)
262
262
  - **error** `token-stays-home` - the user's sign-in token (`getAuthToken`, a session-named storage key, `document.cookie`, followed through variables and helper functions) returned from `execute` or a card, handed to Foldspace, logged, stored again, or sent to a host that is not the product's backend
263
263
  - **error** `no-secrets` - API keys, JWTs, bearer tokens, cookie values, secret-named strings, `.har` captures, storage dumps and `.env` files anywhere in the project (not `node_modules/`, `dist/`, `.foldspace-dev/`, `agent/runtime/`)
264
264
  - **warning** `known-hosts-only` - `fetch`, `sendBeacon`, `WebSocket`, `EventSource`, `XMLHttpRequest`, `publicFetch` or `import()` to an address written out whose host is neither Foldspace's (`*.foldspace.ai`), the product's (`foldspace.dev.json` hosts, `API_BASE`, the app's own domain) nor written in `agent/constants.ts`
265
- - **warning** `card-contract` - any other `awaitUserInput` on a view card, `awaitUserInput` on an input card (not needed), or a `render` that declares `callback`, `cancel` or `readyState` and uses none of them (the SDK treats it as a view card)
265
+ - **warning** `card-contract` - any other `awaitUserInput` on a view card, `awaitUserInput` on an input card (not needed), a card that declares `callback`, `cancel` or `readyState` and uses none of them (the SDK treats it as a view card), or a `renderComponent` the SDK can't read (the object passed on or aliased, `...rest`, `arguments`): the agent waits until it calls `readyState`, `callback` or `cancel`, so a view card that calls none of them never gets a reply
266
266
  - **warning** `no-emoji` — emoji in handler or `agent/views` string literals
267
267
  - **warning** `registry-integrity` — duplicate keys, `{ execute }` not registered, registry value missing `execute`
268
268
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
package/recipes/README.md CHANGED
@@ -40,12 +40,16 @@ those builds learned the hard way. **What is taken is the shape** — the order
40
40
  calls, what is returned to the agent, where the traps are. **What is not taken**
41
41
  is any customer's name, endpoint, identifier, styling or code. Contracts are the
42
42
  SDK's: `execute(params)` and
43
- `render(data, host, header, callback, cancel, readyState)`
44
- (<https://docs.foldspace.ai/guides/in-chat-ui/>), `foldspace.identify(context)`
43
+ `renderComponent({ data, host, header, callback, cancel, readyState, theme })`
44
+ for a new card, or the older
45
+ `render(data, host, header, callback, cancel, readyState, theme)` these recipes
46
+ use (<https://docs.foldspace.ai/guides/in-chat-ui/>; convert when you copy, see
47
+ CLAUDE.md), `foldspace.identify(context)`
45
48
  (<https://docs.foldspace.ai/start/user-context/>), and the harness runtime.
46
- Where the in-chat UI page says to set `awaitUserInput`, do not - the SDK waits
47
- when `render` uses `callback` or `cancel` (see the card bullet below). That
48
- page does not list `readyState`; CLAUDE.md describes it, and no recipe uses it.
49
+ Never set `awaitUserInput` - the SDK waits when a card uses `callback`,
50
+ `cancel` or `readyState` (see the card bullet below). What the agent receives
51
+ for each is in CLAUDE.md (**Agentic UI components**); no recipe uses
52
+ `readyState` yet.
49
53
 
50
54
  Error handling is **not** taken from those builds. They predate `reason`: one
51
55
  turns every failure into "unexpected error", another reports a missing session
@@ -8,7 +8,7 @@ This card is the base layout. Copy it, then replace the `/__observe_me` routes w
8
8
 
9
9
  A view card: `render` takes no `callback` and the action sets no `awaitUserInput`. As soon as the card is drawn, the agent gets "Shown to the user. The user has not acted on it." plus exactly what `execute` returned - the account, the totals and every row behind the tabs, or the failure (`success: false`, `error` and `reason`) - and it answers questions about the account from that. So `execute` returns the overview the card draws and nothing else. One tab is on screen at a time; an empty tab says so. A signed-out or failed call is drawn with `renderFailure`, never as zeros. A 200 is signed out unless the account has both an id and a name.
10
10
 
11
- In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`, described as "The account's id. Never taken from the user's words: get it from `<lookup action key>`." Replace `<lookup action key>` with the key of this agent's action that returns account ids; if the agent has none, build that action first - `find-by-name` has the shape. Its `instructions`: "Answer follow-up questions about this account from the data you receive: quote money and counts exactly as given, and never add them up yourself. Right after the card, one short sentence at most - do not repeat what it shows. On a failure, the card already says why: say so in one short sentence and stop. No emoji."
11
+ In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`, described as "The account's id. Never taken from the user's words: get it from `<lookup action key>`." Replace `<lookup action key>` with the key of this agent's action that returns account ids; if the agent has none, build that action first - `find-by-name` has the shape. Its `instructions`: "Quote money and counts exactly as given, and never add them up yourself. No emoji."
12
12
 
13
13
  ## Adapt it
14
14
 
@@ -11,5 +11,5 @@
11
11
  }
12
12
  ],
13
13
  "confirmation_required": false,
14
- "instructions": "The result is the data the card was drawn from - every open deal, person and open case, not only the ones it lists - or a failure: success false, with the reason. Answer follow-up questions about this account from the data you receive: quote money and counts exactly as given, and never add them up yourself. Right after the card, one short sentence at most - do not repeat what it shows. On a failure, the card already says why: say so in one short sentence and stop. No emoji."
14
+ "instructions": "The result is the data the card was drawn from - every open deal, person and open case, not only the ones it lists - or a failure: success false, with the reason. Quote money and counts exactly as given, and never add them up yourself. No emoji."
15
15
  }
@@ -8,7 +8,7 @@ The app decides what "at risk" means, on the server. In that build it was: stall
8
8
 
9
9
  A view card: `render` takes no `callback` and the action sets no `awaitUserInput`. As soon as the card is drawn, the agent gets "Shown to the user. The user has not acted on it." plus exactly what `execute` returned - the count, the total, the average and the rows (no rows when the card says "No opportunities are at risk."), or the failure (`success: false`, `error` and `reason`). So `execute` returns what the card draws, with each row's id, and nothing else. The count, the total and the average are computed in code from the rows.
10
10
 
11
- In Agent Studio: an action with key `show_opportunities_at_risk` and no parameters. Its `instructions`: "Answer follow-up questions about these opportunities from the data you receive: quote money and counts exactly as given, and never add them up yourself. Right after the card, one short sentence at most - do not repeat what it shows. On a failure, the card already says why: say so in one short sentence and stop. No emoji."
11
+ In Agent Studio: an action with key `show_opportunities_at_risk` and no parameters. Its `instructions`: "Quote money and counts exactly as given, and never add them up yourself. No emoji."
12
12
 
13
13
  ## Adapt it
14
14
 
@@ -8,7 +8,7 @@ A view card: `render` takes no `callback` and the action sets no `awaitUserInput
8
8
 
9
9
  `brand.ts` here is a placeholder: keep the `agent/views/brand.ts` that `npx foldspace observe styles --write` wrote - it has every key the recipe cards read. If the project has none yet, run that command first; do not copy this one.
10
10
 
11
- In Agent Studio: an action with key `list_accounts` and no parameters. Its `instructions`: "Answer follow-up questions about these accounts from the data you receive: quote money and counts exactly as given, and never add them up yourself. Right after the card, one short sentence at most - do not repeat what it shows. On a failure, the card already says why: say so in one short sentence and stop. No emoji."
11
+ In Agent Studio: an action with key `list_accounts` and no parameters. Its `instructions`: "Quote money and counts exactly as given, and never add them up yourself. No emoji."
12
12
 
13
13
  ## Adapt it
14
14
 
@@ -65,17 +65,22 @@ export function buildActionObserverScript({
65
65
  catch { return Date.now(); }
66
66
  };
67
67
 
68
- const wrapFunction = (actionName, phase, fn) =>
68
+ const errorName = (error) =>
69
+ typeof error?.name === "string" ? error.name.slice(0, 100) : "Error";
70
+
71
+ // Only execute is wrapped. The SDK reads a card's source to tell a view card
72
+ // from an input card, and a wrapper would hide it: the agent would then wait
73
+ // on every view card. recordRender logs cards from the SDK's own events.
74
+ const wrapExecute = (actionName, fn) =>
69
75
  function observedActionPhase(...args) {
70
76
  const startedAt = now();
77
+ const phase = "execute";
71
78
  record({
72
79
  source: "local-handler",
73
80
  actionName,
74
81
  phase,
75
82
  status: "started",
76
- ...(phase === "execute"
77
- ? { parameterKeys: parameterKeys(args[0]) }
78
- : {}),
83
+ parameterKeys: parameterKeys(args[0]),
79
84
  });
80
85
 
81
86
  const succeeded = (value) => {
@@ -95,10 +100,7 @@ export function buildActionObserverScript({
95
100
  phase,
96
101
  status: "failed",
97
102
  durationMs: Math.max(0, Math.round(now() - startedAt)),
98
- errorName:
99
- typeof error?.name === "string"
100
- ? error.name.slice(0, 100)
101
- : "Error",
103
+ errorName: errorName(error),
102
104
  });
103
105
  throw error;
104
106
  };
@@ -132,18 +134,16 @@ export function buildActionObserverScript({
132
134
  continue;
133
135
  }
134
136
  const actionDescriptors = Object.getOwnPropertyDescriptors(definition);
135
- for (const phase of ["execute", "render"]) {
136
- const phaseDescriptor = actionDescriptors[phase];
137
- if (
138
- phaseDescriptor &&
139
- "value" in phaseDescriptor &&
140
- typeof phaseDescriptor.value === "function"
141
- ) {
142
- actionDescriptors[phase] = {
143
- ...phaseDescriptor,
144
- value: wrapFunction(name, phase, phaseDescriptor.value),
145
- };
146
- }
137
+ const executeDescriptor = actionDescriptors.execute;
138
+ if (
139
+ executeDescriptor &&
140
+ "value" in executeDescriptor &&
141
+ typeof executeDescriptor.value === "function"
142
+ ) {
143
+ actionDescriptors.execute = {
144
+ ...executeDescriptor,
145
+ value: wrapExecute(name, executeDescriptor.value),
146
+ };
147
147
  }
148
148
  if (!descriptor || !("value" in descriptor)) continue;
149
149
  const observedDefinition = Object.create(
@@ -207,6 +207,37 @@ export function buildActionObserverScript({
207
207
  };
208
208
  ensureActionCapture();
209
209
 
210
+ // The SDK emits "rendered" just before it calls the card, and "failed" if that
211
+ // call throws, in the same task. Still pending on the next task means it drew.
212
+ const pendingRenders = new Map();
213
+ const recordRender = (event) => {
214
+ const actionName = event?.actionKey;
215
+ if (!state.expectedActionNames.includes(actionName)) return;
216
+ const phase = "render";
217
+ const source = "local-handler";
218
+ const durationMs = () =>
219
+ Math.max(0, Math.round(now() - pendingRenders.get(actionName)));
220
+ if (event.status === "rendered") {
221
+ pendingRenders.set(actionName, now());
222
+ record({ source, actionName, phase, status: "started" });
223
+ setTimeout(() => {
224
+ if (!pendingRenders.has(actionName)) return;
225
+ record({ source, actionName, phase, status: "succeeded", durationMs: durationMs() });
226
+ pendingRenders.delete(actionName);
227
+ }, 0);
228
+ } else if (event.status === "failed" && pendingRenders.has(actionName)) {
229
+ record({
230
+ source,
231
+ actionName,
232
+ phase,
233
+ status: "failed",
234
+ durationMs: durationMs(),
235
+ errorName: errorName(event.payload),
236
+ });
237
+ pendingRenders.delete(actionName);
238
+ }
239
+ };
240
+
210
241
  const observedAgents = new WeakSet();
211
242
  const subscribe = () => {
212
243
  ensureActionCapture();
@@ -237,6 +268,7 @@ export function buildActionObserverScript({
237
268
  ? event.status.slice(0, 100)
238
269
  : "unknown",
239
270
  });
271
+ recordRender(event);
240
272
  };
241
273
  agent.on?.("action.callback", recordSdkAction);
242
274
  agent.on?.("remoteActions.loaded", ensureActionCapture);
package/src/lint/ast.mjs CHANGED
@@ -71,7 +71,7 @@ function initializerOf(node) {
71
71
  }
72
72
 
73
73
  /**
74
- * Call `visit(fn, name)` for execute / render / callback function-likes.
74
+ * Call `visit(fn, name)` for execute / render / renderComponent / callback function-likes.
75
75
  */
76
76
  export function forEachNamedFunction(sourceFile, names, visit) {
77
77
  const wanted = names instanceof Set ? names : new Set(names);
@@ -102,6 +102,101 @@ export function callbackParamName(fn) {
102
102
  return "callback";
103
103
  }
104
104
 
105
+ const REPORT_KEYS = ["callback", "cancel", "readyState"];
106
+
107
+ /** Every default inside a nested pattern. */
108
+ function collectBindingDefaults(pattern, defaults) {
109
+ for (const element of pattern.elements) {
110
+ if (!ts.isBindingElement(element)) continue;
111
+ if (element.initializer) defaults.push(element.initializer);
112
+ if (!ts.isIdentifier(element.name)) collectBindingDefaults(element.name, defaults);
113
+ }
114
+ }
115
+
116
+ function readComponentPattern(pattern, read) {
117
+ for (const element of pattern.elements) {
118
+ const keyName = element.propertyName;
119
+ if (element.dotDotDotToken || (keyName && !ts.isIdentifier(keyName))) {
120
+ read.unknown = true;
121
+ continue;
122
+ }
123
+ const key = (keyName || element.name).getText();
124
+ const reports = REPORT_KEYS.includes(key);
125
+ if (element.initializer) read.defaults.push(element.initializer);
126
+ if (ts.isIdentifier(element.name)) {
127
+ if (reports) read.locals.set(element.name.text, key);
128
+ } else if (reports) {
129
+ read.unknown = true;
130
+ } else {
131
+ collectBindingDefaults(element.name, read.defaults);
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * How a `renderComponent` reads its one object, the way the SDK reads it.
138
+ * `locals` maps the name each report function goes by to its key:
139
+ * `{ callback: done }` gives done -> callback, from the parameter or from
140
+ * `const { callback } = ctx`. `context` is the parameter's name when it is not
141
+ * destructured. `defaults` are the pattern's defaults, which run like the body.
142
+ * `unknown` is what the SDK cannot read either: a `...rest` at the top of the
143
+ * pattern, a report key taken apart, or a key that is not a plain name. Nested
144
+ * patterns under any other key are read.
145
+ */
146
+ export function readComponentParam(fn) {
147
+ const params = (fn.parameters || []).filter((param) => !(ts.isIdentifier(param.name) && param.name.text === "this"));
148
+ const first = params[0];
149
+ const read = { locals: new Map(), context: null, defaults: [], unknown: false };
150
+ if (!first) return read;
151
+ if (first.dotDotDotToken) return { ...read, unknown: true };
152
+ if (first.initializer) read.defaults.push(first.initializer);
153
+ if (ts.isIdentifier(first.name)) {
154
+ read.context = first.name.text;
155
+ const walk = (node) => {
156
+ if (
157
+ ts.isVariableDeclaration(node) &&
158
+ ts.isObjectBindingPattern(node.name) &&
159
+ node.initializer &&
160
+ ts.isIdentifier(node.initializer) &&
161
+ node.initializer.text === read.context
162
+ ) {
163
+ readComponentPattern(node.name, read);
164
+ }
165
+ ts.forEachChild(node, walk);
166
+ };
167
+ if (fn.body) walk(fn.body);
168
+ return read;
169
+ }
170
+ if (!ts.isObjectBindingPattern(first.name)) return { ...read, unknown: true };
171
+ readComponentPattern(first.name, read);
172
+ return read;
173
+ }
174
+
175
+ /**
176
+ * Call `visit(call, key)` for each call a `renderComponent` makes to a report
177
+ * function: `callback(x)` from the pattern, or `ctx.callback(x)`.
178
+ */
179
+ export function forEachComponentReport(fn, visit) {
180
+ const { locals, context } = readComponentParam(fn);
181
+ function walk(node) {
182
+ if (ts.isCallExpression(node)) {
183
+ const callee = node.expression;
184
+ if (ts.isIdentifier(callee) && locals.has(callee.text)) visit(node, locals.get(callee.text));
185
+ if (
186
+ context &&
187
+ ts.isPropertyAccessExpression(callee) &&
188
+ ts.isIdentifier(callee.expression) &&
189
+ callee.expression.text === context &&
190
+ REPORT_KEYS.includes(callee.name.text)
191
+ ) {
192
+ visit(node, callee.name.text);
193
+ }
194
+ }
195
+ ts.forEachChild(node, walk);
196
+ }
197
+ if (fn.body) walk(fn.body);
198
+ }
199
+
105
200
  export function collectConstBindings(scopeNode) {
106
201
  const map = new Map();
107
202
 
@@ -5,6 +5,7 @@ import {
5
5
  collectModuleBindings,
6
6
  finding,
7
7
  forEachCallNamed,
8
+ forEachComponentReport,
8
9
  forEachNamedFunction,
9
10
  forEachReturn,
10
11
  forEachStringLiteral,
@@ -18,6 +19,7 @@ import {
18
19
  resolveNode,
19
20
  shouldSkipActionFile,
20
21
  stringText,
22
+ readComponentParam,
21
23
  } from "./ast.mjs";
22
24
  import { ts } from "./ts.mjs";
23
25
 
@@ -113,9 +115,17 @@ function inspectReturnedObject(obj, bindings, sourceFile, relative, findings, or
113
115
  }
114
116
 
115
117
  export function ruleNoStaticAgentPrompt(sourceFile, relative, findings) {
116
- forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn, name) => {
118
+ forEachNamedFunction(sourceFile, ["execute", "render", "renderComponent", "callback"], (fn, name) => {
117
119
  const bindings = mergeBindings(fn, sourceFile);
118
120
 
121
+ if (name === "renderComponent") {
122
+ forEachComponentReport(fn, (call, key) => {
123
+ const arg = call.arguments[0];
124
+ if (!arg || key === "cancel") return;
125
+ inspectReturnedObject(resolveNode(arg, bindings), bindings, sourceFile, relative, findings, key);
126
+ });
127
+ }
128
+
119
129
  if (name === "execute" || name === "callback") {
120
130
  forEachReturn(fn, (expression) => {
121
131
  inspectReturnedObject(
@@ -235,7 +245,7 @@ export function ruleNoEmoji(sourceFile, relative, findings, { wholeFile = false
235
245
  return;
236
246
  }
237
247
 
238
- forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn) => {
248
+ forEachNamedFunction(sourceFile, ["execute", "render", "renderComponent", "callback"], (fn) => {
239
249
  const bindings = mergeBindings(fn, sourceFile);
240
250
  forEachStringLiteral(
241
251
  fn,
@@ -479,7 +489,7 @@ export function ruleRegistryIntegrity({ indexFile, indexSource, actionFiles, pro
479
489
  // `this` parameter is erased, so it takes no position.
480
490
  const CARD_PARAMS = ["callback", "cancel", "readyState"];
481
491
 
482
- function mentions(fn, test) {
492
+ function mentions(fn, test, { also = [], bindings = true } = {}) {
483
493
  let found = false;
484
494
  function walk(node) {
485
495
  if (found) return;
@@ -495,6 +505,7 @@ function mentions(fn, test) {
495
505
  ts.isSetAccessorDeclaration(parent)) &&
496
506
  parent.name === node;
497
507
  const isKey = parent && ts.isBindingElement(parent) && parent.propertyName === node;
508
+ const isBinding = !bindings && parent && ts.isBindingElement(parent) && parent.name === node;
498
509
  let inType = false;
499
510
  for (let up = parent; up && up !== fn; up = up.parent) {
500
511
  if (ts.isTypeNode(up) && !ts.isExpressionWithTypeArguments(up)) {
@@ -502,14 +513,14 @@ function mentions(fn, test) {
502
513
  break;
503
514
  }
504
515
  }
505
- if (!isName && !isKey && !inType) {
516
+ if (!isName && !isKey && !isBinding && !inType) {
506
517
  found = true;
507
518
  return;
508
519
  }
509
520
  }
510
521
  ts.forEachChild(node, walk);
511
522
  }
512
- if (fn.body) walk(fn.body);
523
+ for (const root of [fn.body, ...also]) if (root) walk(root);
513
524
  return found;
514
525
  }
515
526
 
@@ -525,6 +536,44 @@ function readRender(fn) {
525
536
  return { waits, declaredUnused: !waits && names.length > 0 ? names : [] };
526
537
  }
527
538
 
539
+ // A renderComponent takes one object, and only the keys it uses tell the two
540
+ // kinds of card apart. What the SDK can't read (`...rest`, `arguments`, the
541
+ // object passed on or aliased) is settled by `waitsForUser`, which defaults to
542
+ // waiting: with no flag, a view card written that way never releases the agent.
543
+ function readComponent(fn, members) {
544
+ const { locals, context, defaults, unknown: unreadable } = readComponentParam(fn);
545
+ let unknown = unreadable || mentions(fn, (name) => name === "arguments", { also: defaults });
546
+ let waits = false;
547
+ if (context) {
548
+ const walk = (node) => {
549
+ if (ts.isIdentifier(node) && node.text === context) {
550
+ const parent = node.parent;
551
+ const destructured =
552
+ ts.isVariableDeclaration(parent) && parent.initializer === node && ts.isObjectBindingPattern(parent.name);
553
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
554
+ if (CARD_PARAMS.includes(parent.name.text)) waits = true;
555
+ } else if (!destructured && !(ts.isPropertyAccessExpression(parent) && parent.name === node)) {
556
+ unknown = true;
557
+ }
558
+ }
559
+ ts.forEachChild(node, walk);
560
+ };
561
+ if (fn.body) walk(fn.body);
562
+ }
563
+ const names = [...locals.keys()];
564
+ if (names.some((name) => mentions(fn, (text) => text === name, { also: defaults, bindings: false }))) waits = true;
565
+ if (waits) return { waits: true, declaredUnused: [] };
566
+ if (unknown) {
567
+ const flag = members.find((member) => propertyName(member) === "waitsForUser");
568
+ return {
569
+ waits: !(flag && ts.isPropertyAssignment(flag) && flag.initializer.kind === ts.SyntaxKind.FalseKeyword),
570
+ declaredUnused: [],
571
+ unsettled: !flag,
572
+ };
573
+ }
574
+ return { waits: false, declaredUnused: names.map((name) => locals.get(name)) };
575
+ }
576
+
528
577
  // The function `render` names: inline, a method, or - for `render` and
529
578
  // `render: renderCard` - a `function` or `const` of that name in the same file.
530
579
  function declaredFunction(sourceFile, name) {
@@ -567,14 +616,19 @@ export function ruleCardContract(sourceFile, relative, findings) {
567
616
  function walk(node) {
568
617
  if (ts.isObjectLiteralExpression(node)) {
569
618
  const members = node.properties;
570
- const render = members.find(
571
- (member) =>
572
- propertyName(member) === "render" &&
573
- (ts.isPropertyAssignment(member) || ts.isMethodDeclaration(member) || ts.isShorthandPropertyAssignment(member)),
574
- );
619
+ const named = (name) =>
620
+ members.find(
621
+ (member) =>
622
+ propertyName(member) === name &&
623
+ (ts.isPropertyAssignment(member) || ts.isMethodDeclaration(member) || ts.isShorthandPropertyAssignment(member)),
624
+ );
625
+ // If an action has both, the SDK draws renderComponent.
626
+ const component = named("renderComponent");
627
+ const render = component || named("render");
628
+ const drawn = component ? "renderComponent" : "render";
575
629
  const fn = render ? renderFunction(sourceFile, render) : null;
576
630
  if (fn) {
577
- const { waits, declaredUnused } = readRender(fn);
631
+ const { waits, declaredUnused, unsettled } = component ? readComponent(fn, members) : readRender(fn);
578
632
  const flag = members.find((member) => propertyName(member) === "awaitUserInput");
579
633
  const flagTrue = flag && ts.isPropertyAssignment(flag) && flag.initializer.kind === ts.SyntaxKind.TrueKeyword;
580
634
  if (flagTrue && !waits) {
@@ -586,7 +640,7 @@ export function ruleCardContract(sourceFile, relative, findings) {
586
640
  node: flag.name,
587
641
  file: relative,
588
642
  message:
589
- "A view card (render uses neither callback nor cancel) sets awaitUserInput: true. The chat panel ignores it, but in headless mode the agent then waits for a callback that never comes.",
643
+ `A view card (${drawn} uses none of callback, cancel or readyState) sets awaitUserInput: true. The chat panel ignores it, but in headless mode the agent then waits for a callback that never comes.`,
590
644
  fix: "Remove awaitUserInput. A view card is reported to the agent as shown, with what execute returned.",
591
645
  }),
592
646
  );
@@ -600,7 +654,7 @@ export function ruleCardContract(sourceFile, relative, findings) {
600
654
  node: flag.name,
601
655
  file: relative,
602
656
  message:
603
- "A view card does not need awaitUserInput: render uses neither callback nor cancel, so the agent is told the card was shown, with what execute returned.",
657
+ `A view card does not need awaitUserInput: ${drawn} uses none of callback, cancel or readyState, so the agent is told the card was shown, with what execute returned.`,
604
658
  fix: "Remove awaitUserInput.",
605
659
  }),
606
660
  );
@@ -614,11 +668,25 @@ export function ruleCardContract(sourceFile, relative, findings) {
614
668
  node: flag.name,
615
669
  file: relative,
616
670
  message:
617
- "An input card does not need awaitUserInput: render uses callback, cancel or readyState, and that is what makes the agent wait.",
671
+ `An input card does not need awaitUserInput: ${drawn} uses callback, cancel or readyState, and that is what makes the agent wait.`,
618
672
  fix: 'Remove awaitUserInput. Then check that every path calls callback or cancel - choosing, "none of these", closing the card, failing. When the conversation should go on while the user decides, call readyState(value) once the card is drawn.',
619
673
  }),
620
674
  );
621
675
  }
676
+ if (unsettled && !flag) {
677
+ findings.push(
678
+ finding({
679
+ rule: "card-contract",
680
+ severity: "warning",
681
+ sourceFile,
682
+ node: render.name,
683
+ file: relative,
684
+ message:
685
+ "The SDK can't read this renderComponent (the object passed on or aliased, ...rest, arguments), so the agent waits until it calls readyState, callback or cancel. A view card that calls none of them never gets a reply, until the user presses stop.",
686
+ fix: "Destructure the keys in the parameter: renderComponent({ data, host, header, readyState }). A view card calls readyState once it is drawn; an input card calls callback or cancel.",
687
+ }),
688
+ );
689
+ }
622
690
  if (declaredUnused.length > 0) {
623
691
  const list =
624
692
  declaredUnused.length === 1
@@ -632,8 +700,8 @@ export function ruleCardContract(sourceFile, relative, findings) {
632
700
  sourceFile,
633
701
  node: render.name,
634
702
  file: relative,
635
- message: `render declares ${list} but ${unused} - the SDK treats the card as a view card: the agent is told it was shown, with what execute returned.`,
636
- fix: `For a view card, remove ${list} from render. For an input card, call callback or cancel on every path, or readyState(value) once the card is drawn.`,
703
+ message: `${drawn} declares ${list} but ${unused} - the SDK treats the card as a view card: the agent is told it was shown, with what execute returned.`,
704
+ fix: `For a view card, remove ${list} from ${drawn}. For an input card, call callback or cancel on every path, or readyState(value) once the card is drawn.`,
637
705
  }),
638
706
  );
639
707
  }
@@ -857,6 +925,115 @@ export function bundleTurnsOnTestMode(js) {
857
925
  return /setTestMode/.test(js.replace(/\??\.setTestMode(\?\.)?\(\s*(!1|false)\s*\)/g, ""));
858
926
  }
859
927
 
928
+ /*
929
+ no-native-lookalike: an action that copies something every agent already
930
+ has (CLAUDE.md, "Built into every agent"). A warning, not an error: a name
931
+ is only a hint, and an action can have a reason the built-in doesn't cover.
932
+ Two signals: the action's key (agent/actions/<key>.ts or <key>/index.ts), and
933
+ code that reads the whole page or the microphone.
934
+ */
935
+ const NATIVE_LOOKALIKE_NAMES = [
936
+ {
937
+ native: "navigation",
938
+ pattern: /(^|_)(navigat[a-z]*|go_to|goto|open_(page|screen|view|url)|redirect[a-z]*)(_|$)|(^|_)urls?$|(^|_)build_[a-z_]*url/,
939
+ use: "a navigation route for each page (create_navigation_route); the agent opens it with the built-in navigate",
940
+ },
941
+ {
942
+ native: "share current tab / share screen",
943
+ pattern: /(^|_)(current|active|visible)_(page|screen|view|tab)(_|$)|(^|_)(page|screen|view|tab)_(content|context|html|text|snapshot|state)(_|$)|(^|_)(read_page|screenshot|capture_(page|screen))(_|$)/,
944
+ use: "Share current tab (agent settings → Customize → Data Access & Handoff), or shareState() for the app's own state",
945
+ },
946
+ {
947
+ native: "knowledge search",
948
+ pattern: /(^|_)search_(docs|documentation|knowledge|help|articles|faq)(_|$)/,
949
+ use: "a knowledge source or an internal article",
950
+ },
951
+ {
952
+ native: "voice",
953
+ pattern: /(^|_)(speech|voice|dictat[a-z]*|transcri[a-z]*|text_to_speech|tts|stt)(_|$)/,
954
+ use: "voice chat and dictation in agent settings",
955
+ },
956
+ {
957
+ native: "ask_user_question",
958
+ pattern: /^(ask_user(_input)?|ask_question|ask_the_user|clarify[a-z_]*|choose_option[s]?)$/,
959
+ use: "the built-in ask_user_question (to restyle or replace it, see CLAUDE.md)",
960
+ },
961
+ ];
962
+
963
+ const PAGE_TEXT_PROPS = new Set(["innerText", "innerHTML", "outerHTML", "textContent"]);
964
+ const VOICE_NAMES = new Set(["getUserMedia", "SpeechRecognition", "webkitSpeechRecognition", "speechSynthesis", "SpeechSynthesisUtterance"]);
965
+
966
+ export function actionKeyOf(filePath) {
967
+ const parts = filePath.split(/[\\/]/);
968
+ const i = parts.lastIndexOf("actions");
969
+ if (i < 0) return null;
970
+ const rest = parts.slice(i + 1);
971
+ let key = null;
972
+ if (rest.length === 1) key = rest[0].replace(/\.(ts|tsx|js|mjs)$/, "");
973
+ else if (rest.length === 2 && /^index\.(ts|tsx|js|mjs)$/.test(rest[1])) key = rest[0];
974
+ if (!key || key === "index") return null;
975
+ return key.replace(/-/g, "_").toLowerCase();
976
+ }
977
+
978
+ export function ruleNoNativeLookalike(sourceFile, relative, findings, filePath) {
979
+ const key = actionKeyOf(filePath || relative);
980
+ if (key && key !== "ask_user_question") {
981
+ for (const item of NATIVE_LOOKALIKE_NAMES) {
982
+ if (!item.pattern.test(key)) continue;
983
+ findings.push(
984
+ finding({
985
+ rule: "no-native-lookalike",
986
+ severity: "warning",
987
+ sourceFile,
988
+ node: sourceFile,
989
+ file: relative,
990
+ message: `"${key}" looks like the built-in ${item.native}, which every agent already has.`,
991
+ fix: `Use ${item.use}. If this action does something the built-in can't, keep it and tell the human why.`,
992
+ }),
993
+ );
994
+ break;
995
+ }
996
+ }
997
+
998
+ const reported = new Set();
999
+ const report = (node, native, use) => {
1000
+ if (reported.has(native)) return;
1001
+ reported.add(native);
1002
+ findings.push(
1003
+ finding({
1004
+ rule: "no-native-lookalike",
1005
+ severity: "warning",
1006
+ sourceFile,
1007
+ node,
1008
+ file: relative,
1009
+ message: native === "voice"
1010
+ ? "Microphone or speech code: voice chat and dictation are built into every agent."
1011
+ : "Reads the whole page's text or HTML: sharing the current tab is built into every agent.",
1012
+ fix: `Use ${use}.`,
1013
+ }),
1014
+ );
1015
+ };
1016
+
1017
+ function walk(node) {
1018
+ if (ts.isPropertyAccessExpression(node)) {
1019
+ const name = node.name.text;
1020
+ if (PAGE_TEXT_PROPS.has(name)) {
1021
+ const target = node.expression.getText(sourceFile).replace(/\s+/g, "");
1022
+ if (/^(window\.)?document\.(body|documentElement)$/.test(target)) {
1023
+ report(node, "page", NATIVE_LOOKALIKE_NAMES[1].use);
1024
+ }
1025
+ }
1026
+ if (VOICE_NAMES.has(name)) report(node, "voice", NATIVE_LOOKALIKE_NAMES[3].use);
1027
+ } else if (ts.isIdentifier(node) && VOICE_NAMES.has(node.text) && !ts.isPropertyAccessExpression(node.parent)) {
1028
+ report(node, "voice", NATIVE_LOOKALIKE_NAMES[3].use);
1029
+ } else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "html2canvas") {
1030
+ report(node, "page", NATIVE_LOOKALIKE_NAMES[1].use);
1031
+ }
1032
+ ts.forEachChild(node, walk);
1033
+ }
1034
+ walk(sourceFile);
1035
+ }
1036
+
860
1037
  export function lintActionFile(filePath, relative, findings) {
861
1038
  if (shouldSkipActionFile(filePath)) return;
862
1039
  const sourceFile = parseFile(filePath);
@@ -864,6 +1041,7 @@ export function lintActionFile(filePath, relative, findings) {
864
1041
  ruleNoRunTaskPrompt(sourceFile, relative, findings);
865
1042
  ruleNoEmoji(sourceFile, relative, findings);
866
1043
  ruleCardContract(sourceFile, relative, findings);
1044
+ ruleNoNativeLookalike(sourceFile, relative, findings, filePath);
867
1045
  }
868
1046
 
869
1047
  export function lintViewsFile(filePath, relative, findings) {
@@ -13,7 +13,7 @@
13
13
 
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
- import { finding, forEachNamedFunction, forEachReturn, isFunctionLike, parseFile, resolveModulePath, stringText } from "./ast.mjs";
16
+ import { finding, forEachComponentReport, forEachNamedFunction, forEachReturn, isFunctionLike, parseFile, resolveModulePath, stringText } from "./ast.mjs";
17
17
  import { ts } from "./ts.mjs";
18
18
  import { hostMatchesAny, siteOf } from "../observe-core.mjs";
19
19
  import { addressNamesLabel, customerLabel, suggestedApiBase } from "../customer-subdomain.mjs";
@@ -518,7 +518,7 @@ export function ruleTokenStaysHome(sourceFile, relative, findings, facts, scopes
518
518
  findings.push(finding({ rule: "token-stays-home", severity: "error", sourceFile, node, file: relative, message, fix }));
519
519
 
520
520
  // Returned to the agent: what execute and a card's callback return, and what
521
- // render hands to callback or readyState.
521
+ // render or renderComponent hands to callback or readyState.
522
522
  forEachNamedFunction(sourceFile, ["execute", "callback"], (fn, name) => {
523
523
  forEachReturn(fn, (expression) => {
524
524
  if (flow.carries(expression)) {
@@ -539,6 +539,14 @@ export function ruleTokenStaysHome(sourceFile, relative, findings, facts, scopes
539
539
  };
540
540
  if (fn.body) walk(fn.body);
541
541
  });
542
+ forEachNamedFunction(sourceFile, ["renderComponent"], (fn) => {
543
+ forEachComponentReport(fn, (call, key) => {
544
+ if (key === "cancel") return;
545
+ if (call.arguments.some((arg) => flow.carries(arg))) {
546
+ report(call, `The card hands the user's sign-in token to ${key}, which reaches the agent and the model.`, TOKEN_FIXES.agent);
547
+ }
548
+ });
549
+ });
542
550
 
543
551
  const walk = (node) => {
544
552
  if (ts.isCallExpression(node)) {