@appsolutely-io/waypoint 0.0.0-stage → 0.1.1-next.0
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/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/block-reply-DN4RiLL0.d.cts +169 -0
- package/dist/block-reply-Dtof9ild.d.ts +169 -0
- package/dist/blocks-25YOJ5SS.cjs +1 -0
- package/dist/blocks-UJ7GUJHD.js +1 -0
- package/dist/chunk-3KV7TDDR.cjs +3 -0
- package/dist/chunk-4BWV3SEL.cjs +1 -0
- package/dist/chunk-4ZGW4HM4.cjs +1 -0
- package/dist/chunk-5TBO732O.js +1 -0
- package/dist/chunk-6OZSKCPT.cjs +1 -0
- package/dist/chunk-C3VS33WJ.js +1 -0
- package/dist/chunk-D2EGPBCX.cjs +1 -0
- package/dist/chunk-FBADWFRR.cjs +1 -0
- package/dist/chunk-FQSWQLRW.js +1 -0
- package/dist/chunk-GT6PI4CT.cjs +1 -0
- package/dist/chunk-II26UWZM.cjs +1 -0
- package/dist/chunk-JFMVVGGB.js +1 -0
- package/dist/chunk-K73VV7KA.js +1 -0
- package/dist/chunk-L4PFFRDE.js +3 -0
- package/dist/chunk-LLPGVQUT.cjs +1 -0
- package/dist/chunk-M3UENQ6L.cjs +1 -0
- package/dist/chunk-M63QOANE.js +1 -0
- package/dist/chunk-MEKDQDSV.js +1 -0
- package/dist/chunk-OEPKPDLD.js +1 -0
- package/dist/chunk-OWT55N6J.js +3 -0
- package/dist/chunk-PFR2J5BV.js +1 -0
- package/dist/chunk-Q6BD2YL3.cjs +1 -0
- package/dist/chunk-Q7BULGQG.cjs +1 -0
- package/dist/chunk-QS3MKZOY.cjs +3 -0
- package/dist/chunk-RHBDQGD4.js +1 -0
- package/dist/chunk-RZZDQWC2.js +1 -0
- package/dist/chunk-S73WZWDP.js +1 -0
- package/dist/chunk-SD2KAIVE.js +1 -0
- package/dist/chunk-T5XF2R74.js +1 -0
- package/dist/chunk-TCFCSGQ3.js +1 -0
- package/dist/chunk-TGL3DR2K.js +1 -0
- package/dist/chunk-TODYQ3SG.cjs +1 -0
- package/dist/chunk-TVSQHTK2.cjs +1 -0
- package/dist/chunk-UYR3UEIW.cjs +1 -0
- package/dist/chunk-VGGJGRNK.cjs +1 -0
- package/dist/chunk-W6Y7H56X.js +1 -0
- package/dist/chunk-XSHOLIC7.cjs +1 -0
- package/dist/chunk-XWDB2LS3.cjs +1 -0
- package/dist/cli.js +3 -0
- package/dist/confirmation-view-CICNJJIO.js +1 -0
- package/dist/confirmation-view-OMWHUYVC.cjs +1 -0
- package/dist/conformance.cjs +1 -0
- package/dist/conformance.d.cts +10 -0
- package/dist/conformance.d.ts +10 -0
- package/dist/conformance.js +1 -0
- package/dist/flow-parser.cjs +1 -0
- package/dist/flow-parser.d.cts +3 -0
- package/dist/flow-parser.d.ts +3 -0
- package/dist/flow-parser.js +1 -0
- package/dist/flows-panel-AIULKYOS.cjs +1 -0
- package/dist/flows-panel-YEJ36RTY.js +1 -0
- package/dist/form-extras-QYE32TUY.cjs +1 -0
- package/dist/form-extras-SBBOMJS2.js +1 -0
- package/dist/form-fill-C32AHxSr.d.cts +653 -0
- package/dist/form-fill-Dp3ut1_Y.d.ts +653 -0
- package/dist/form-session.cjs +1 -0
- package/dist/form-session.d.cts +7 -0
- package/dist/form-session.d.ts +7 -0
- package/dist/form-session.js +1 -0
- package/dist/frame-decoder.cjs +3 -0
- package/dist/frame-decoder.d.cts +5 -0
- package/dist/frame-decoder.d.ts +5 -0
- package/dist/frame-decoder.js +3 -0
- package/dist/generated-C9tbjwK0.d.cts +797 -0
- package/dist/generated-C9tbjwK0.d.ts +797 -0
- package/dist/hooks/index.cjs +1 -0
- package/dist/hooks/index.d.cts +48 -0
- package/dist/hooks/index.d.ts +48 -0
- package/dist/hooks/index.js +1 -0
- package/dist/i18n-DPNfYTMG.d.cts +148 -0
- package/dist/i18n-DPNfYTMG.d.ts +148 -0
- package/dist/index-B5idIFTx.d.cts +34 -0
- package/dist/index-DPzoKXCK.d.ts +34 -0
- package/dist/index.cjs +3 -0
- package/dist/index.d.cts +38 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +3 -0
- package/dist/intent-BQPPows1.d.cts +154 -0
- package/dist/intent-BuvqEU3Q.d.ts +154 -0
- package/dist/intent.cjs +1 -0
- package/dist/intent.d.cts +2 -0
- package/dist/intent.d.ts +2 -0
- package/dist/intent.js +1 -0
- package/dist/markdown-BSRKJFFE.cjs +17 -0
- package/dist/markdown-ETLB66BK.js +17 -0
- package/dist/message-actions-3HGE6VTS.js +1 -0
- package/dist/message-actions-F6SPLTT7.cjs +1 -0
- package/dist/messages/en/index.cjs +1 -0
- package/dist/messages/en/index.d.cts +3 -0
- package/dist/messages/en/index.d.ts +3 -0
- package/dist/messages/en/index.js +1 -0
- package/dist/messages/index.cjs +1 -0
- package/dist/messages/index.d.cts +14 -0
- package/dist/messages/index.d.ts +14 -0
- package/dist/messages/index.js +1 -0
- package/dist/messages/zh-Hans/index.cjs +1 -0
- package/dist/messages/zh-Hans/index.d.cts +3 -0
- package/dist/messages/zh-Hans/index.d.ts +3 -0
- package/dist/messages/zh-Hans/index.js +1 -0
- package/dist/on-demand.cjs +1 -0
- package/dist/on-demand.d.cts +56 -0
- package/dist/on-demand.d.ts +56 -0
- package/dist/on-demand.js +1 -0
- package/dist/problem-messages-THONMXFZ.cjs +1 -0
- package/dist/problem-messages-Z5QGO6RE.js +1 -0
- package/dist/react/index.cjs +1 -0
- package/dist/react/index.d.cts +419 -0
- package/dist/react/index.d.ts +419 -0
- package/dist/react/index.js +1 -0
- package/dist/reads-Bk84x_rt.d.ts +38 -0
- package/dist/reads-DlpCt8dE.d.cts +38 -0
- package/dist/recovery-panel-OWY2CWOH.cjs +3 -0
- package/dist/recovery-panel-VOKVDDZD.js +3 -0
- package/dist/reply-controls-5CWIAKIG.js +1 -0
- package/dist/reply-controls-CPWB4VBK.cjs +1 -0
- package/dist/run-XBABOEXK.js +1 -0
- package/dist/schema.json +2900 -0
- package/dist/skin-antd/index.cjs +1 -0
- package/dist/skin-antd/index.d.cts +30 -0
- package/dist/skin-antd/index.d.ts +30 -0
- package/dist/skin-antd/index.js +1 -0
- package/dist/skin-default/index.cjs +1 -0
- package/dist/skin-default/index.d.cts +42 -0
- package/dist/skin-default/index.d.ts +42 -0
- package/dist/skin-default/index.js +1 -0
- package/dist/step-name-3hQ6CCGr.d.cts +19 -0
- package/dist/step-name-BdkYnb5g.d.ts +19 -0
- package/dist/storage-commit.cjs +1 -0
- package/dist/storage-commit.d.cts +17 -0
- package/dist/storage-commit.d.ts +17 -0
- package/dist/storage-commit.js +1 -0
- package/dist/stream-reads.cjs +1 -0
- package/dist/stream-reads.d.cts +22 -0
- package/dist/stream-reads.d.ts +22 -0
- package/dist/stream-reads.js +1 -0
- package/dist/styles.css +796 -0
- package/dist/testing/index.cjs +1 -0
- package/dist/testing/index.d.cts +57 -0
- package/dist/testing/index.d.ts +57 -0
- package/dist/testing/index.js +1 -0
- package/docs/examples/message-versions.tsx +74 -0
- package/docs/form-fill-protocol.md +83 -0
- package/docs/message-versions.md +64 -0
- package/docs/plan-protocol.md +83 -0
- package/docs/reply-protocol.md +75 -0
- package/etc/hooks.api.md +2385 -0
- package/etc/index.api.md +2159 -0
- package/etc/messages-en.api.md +13 -0
- package/etc/messages-zh-Hans.api.md +13 -0
- package/etc/messages.api.md +183 -0
- package/etc/react.api.md +2968 -0
- package/etc/skin-antd.api.md +2989 -0
- package/etc/skin-default.api.md +61 -0
- package/etc/testing.api.md +3034 -0
- package/package.json +168 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Appsolutely
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,261 @@
|
|
|
1
|
-
#
|
|
1
|
+
# waypoint
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An MIT-licensed package for conversations that propose, confirm and run requests. The core is framework-free; React and antd are optional peers. No request runs merely because a flow or model proposed it.
|
|
4
|
+
|
|
5
|
+
## Using the package
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @appsolutely-io/waypoint@next
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Only pre-releases are published so far, under the `next` dist-tag. Pin the exact version: a pre-release may change shape before the next one.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
createWaypointClient,
|
|
16
|
+
createSseTransport,
|
|
17
|
+
} from "@appsolutely-io/waypoint";
|
|
18
|
+
|
|
19
|
+
const client = createWaypointClient({
|
|
20
|
+
accountId: "example-account",
|
|
21
|
+
conversationId: "example-conversation",
|
|
22
|
+
platform, // Inject clock, online, visibility and ids from your application.
|
|
23
|
+
transport: createSseTransport(request, stream),
|
|
24
|
+
storage, // Optional StoragePort; synchronous and asynchronous ports work.
|
|
25
|
+
});
|
|
26
|
+
await client.ready;
|
|
27
|
+
await client.setDraft("Rename the item");
|
|
28
|
+
await client.send();
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`request` and `stream` are application adapters. They retain the application's authentication, CSRF and API error handling. The core itself reads no browser global. Dispose each client when its owning screen ends. A storage key includes the account and conversation; pass a separate port when clients must also have separate persistence.
|
|
32
|
+
|
|
33
|
+
Startup negotiation and the first conversation read each have a 10-second budget, and reading the optional storage (at most 1 second) runs alongside negotiation, so `ready` settles within 20 seconds. A failed start reports `sign-in` for an authentication refusal (an HTTP 401), `update-required` only for an incompatible protocol, and `unavailable` for anything else. One retry owner per client then retries an `unavailable` start after 1, 2, 4, 8 and 16 seconds and every 30 seconds after that, skipping attempts while `online()` or `visibility()` is false. It also retries at once when the optional `subscribeOnline` or `subscribeVisibility` callback reports that the application is back online or visible, and when the application calls `client.refresh()`. A `sign-in` start waits for one of those signals; an `update-required` start retries only on `client.refresh()`. A transient first read is retried the same way; a 404 or 410 read is final. Retrying never resends a message or reply and never confirms or runs a plan. Storage that answers after its budget adds its saved rows without replacing a draft or pending row the user already has.
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { WaypointChat } from "@appsolutely-io/waypoint/react";
|
|
37
|
+
import { antdSkin } from "@appsolutely-io/waypoint/skin-antd";
|
|
38
|
+
import "@appsolutely-io/waypoint/styles.css";
|
|
39
|
+
|
|
40
|
+
<WaypointChat
|
|
41
|
+
client={client}
|
|
42
|
+
{...antdSkin}
|
|
43
|
+
layout="panel"
|
|
44
|
+
colorScheme="dark"
|
|
45
|
+
/>;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Omit the skin for neutral controls. `@appsolutely-io/waypoint/skin-default` exports the neutral token values. Importing core requires no UI peer. `/testing` additionally needs React, React DOM and Testing Library; install those only when using that helper.
|
|
49
|
+
|
|
50
|
+
## Preview locally
|
|
51
|
+
|
|
52
|
+
In a checkout of this repository:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npm ci
|
|
56
|
+
npm run playground
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Open http://127.0.0.1:43300/. The local server prints the URL and does not open a browser. The toolbar switches between neutral and Ant Design controls, light/dark/system colour schemes, layouts, direction, and the shipped languages. Choose a conversation, AI form fill, saved requests, or a long transcript with its message outline. The right-to-left option mirrors the layout using the selected language’s text. The mock server streams an answer to every message. The reply state choice (`?state=`) opens a fresh conversation that reaches one state on load: `streaming`, `done`, `stopped`, `versions`, `tools`, `sources`, `overdue`, `reply-failed`, `reply-unavailable`, a refused send for each problem code (`error-retryable`, `error-rate-limited` and so on), `plan` and `form`. Settings stay in the URL for sharing and reloading. All demo data uses the local mock server; the playground is excluded from the published package.
|
|
60
|
+
|
|
61
|
+
Set `PLAYGROUND_PORT` to an integer from 1024 to 65535 to use another local port (for example, `PLAYGROUND_PORT=43301 npm run playground`).
|
|
62
|
+
|
|
63
|
+
## Extending waypoint
|
|
64
|
+
|
|
65
|
+
Every client owns its state, listeners, streams and send failures. Providers change rendering without replacing that state. These extension points are public:
|
|
66
|
+
|
|
67
|
+
| Extension point | Example |
|
|
68
|
+
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
69
|
+
| `createWaypointClient` | `createWaypointClient({ accountId, conversationId, platform, transport })` |
|
|
70
|
+
| `PlatformPorts` | `platform: { clock, online: () => true, visibility: () => true, ids }`; optional `subscribeOnline` / `subscribeVisibility` |
|
|
71
|
+
| `MessageVersionPort` | `versions: { edit, regenerate, select }`; message actions refetch the authoritative sibling after the host operation; the host draws the controls ([docs/message-versions.md](docs/message-versions.md)) |
|
|
72
|
+
| `StoragePort` | `storage: { get, set, remove }`; each operation may return a promise |
|
|
73
|
+
| `transport` | `createSseTransport(request, stream)`, `createFetchStreamTransport(request, stream)` or `createSignalTransport(request, stream)` |
|
|
74
|
+
| State subscriptions | `const unsubscribe = client.subscribe(() => draw(client.getSnapshot()))` |
|
|
75
|
+
| Typed events | `client.on(event => recordId(event.type, event))`; events contain ids, never field values |
|
|
76
|
+
| `beforeSend` | `beforeSend: async reply => permitted ? { ...reply, content: rewritten } : false` |
|
|
77
|
+
| `ComposerContribution` | `contributions: [{ id: 'example', evaluate: () => ({ hold, hint, payload }) }]` |
|
|
78
|
+
| Plan execution | `plans: { read, request, heartbeat, intent?, stepUp? }`; `stepUp` returns one ephemeral code or cancellation |
|
|
79
|
+
| Plan recovery | `plans: { ...runnerPorts, intent }`; protected controls use stored intent results; optional legacy `resume` / `retryUndo` remain adapter-driven |
|
|
80
|
+
| `FlowSource` | `flowSource: { list, setFavourite, run }`, `tabs: ['presets', 'favourites']` |
|
|
81
|
+
| Parsed flow documents | Add `flowSource.load: id => fetchDocument(id)` and `flowOperations: ['items.update']`; `run(id, values, document)` receives validated JSON without loading YAML |
|
|
82
|
+
| Headless React | `useConversation`, `useComposer`, `usePlan`, `useFlows` inside `WaypointProvider`, or pass a client directly to each hook |
|
|
83
|
+
| Renderer-neutral hooks | `usePlanView(client)` and `useBlockReply(client)` from `/hooks` (also from `/react`): the plan's presses and the one-answer-per-card lock, as message keys, with React only and no DOM; see [A UI of your own](#a-ui-of-your-own) |
|
|
84
|
+
| Plan step names | `planLabels={{ resource: key => names[key], field: (resource, key) => fields[resource]?.[key] }}`; steps are numbered ("Task 1", "Task 2"), an unnamed step shows "Change 1" and an unnamed field "Value 1", never its internal key |
|
|
85
|
+
| Theme tokens | `theme: { accent: '#245ad1' }`; `themeProperties(theme)` derives CSS custom properties from the same typed source |
|
|
86
|
+
| Direction and scheme | `locale="zh-CN" dir="rtl" colorScheme="dark"`; `colorScheme` is host-driven and preserves state |
|
|
87
|
+
| Layout | `layout="panel"`, `"page"`, `"floating"` or `"sheet"`; container queries respond to the instance's available width |
|
|
88
|
+
| Slots | `slots={{ composer: props => <MyComposer {...props} /> }}`; `messageActions`, `extraPanels` and `stepUpPrompt` are also regions |
|
|
89
|
+
| Provider replacement | `parts={{ bubble: MyBubble }}` replaces that part everywhere |
|
|
90
|
+
| Control replacement | `primitives={{ button: MyButton, textarea: MyTextarea }}`; input, checkbox, datePicker and drawer are replaceable too |
|
|
91
|
+
| Widget registry | `widgets={[{ type: 'example/status', render: StatusWidget }]}`; standard entries use the explicit `standardWidgets` array |
|
|
92
|
+
| Markdown | `renderers={{ markdown: MyMarkdown }}` (`renderers.markdown`); the default renderer is lazy, disables raw HTML and allows only approved link schemes |
|
|
93
|
+
| Problem presentation | `problemText={problem => ({ text: explain(problem.code), action: recovery })}` over the closed problem union |
|
|
94
|
+
| Messages | `format={(key, values) => catalogue.format(key, values)}`; typed keys, ICU plurals, English and Simplified Chinese defaults |
|
|
95
|
+
| Feature selection | `features={['widgets', 'flows']}` intersects the negotiated capabilities; unknown capabilities are off |
|
|
96
|
+
| Shortcuts | `shortcuts={{ focusComposer: '/', send: false }}`; each shortcut can be disabled and focus stays within the chat |
|
|
97
|
+
| Skin wrapper | `wrapper={MyThemeProvider}` receives the scheme and resolved tokens; the antd wrapper maps dark mode to its dark algorithm |
|
|
98
|
+
| Skin testing | `renderWithWaypoint(element, { client, skin })`, `defineSkinContract`, `defineOverrideContract`, `defineAccessibilityContract` from `/testing` |
|
|
99
|
+
|
|
100
|
+
A slot or replacement receives presentation props and callbacks. The client remains the behavior owner. Answering a card answers the whole card and disables it in place while the answer is in flight. Whether a card is answered is the host's to say (see **Card answers** below): after every answer, accepted, refused or lost, the client reads the conversation and shows the card as the host reports it, answered or open, and focus goes back to the pressed answer when the card is open, or to the composer when it is answered. A card the host accepted an answer for stays answered even if that read fails; the failed read shows its Retry. An answered card says "Answered: Accept" when the host names an action of the card in its `answer`, and "Answered" otherwise. An answer refused because the card was already answered, in another tab or an earlier page, says "This question was already answered." instead of the generic refusal. Widget types unknown to the client show their fallback with update guidance and cannot submit a reply. Tokens are scoped to the instance root, never the document root.
|
|
101
|
+
|
|
102
|
+
### A UI of your own
|
|
103
|
+
|
|
104
|
+
A host that draws its own UI, React Native included, uses the hooks from `/hooks` and formats the keys they return with `/messages` and the language it needs; nothing else of the web chat is loaded:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import { usePlanView } from "@appsolutely-io/waypoint/hooks";
|
|
108
|
+
import { createFormatter } from "@appsolutely-io/waypoint/messages";
|
|
109
|
+
import en from "@appsolutely-io/waypoint/messages/en";
|
|
110
|
+
|
|
111
|
+
const t = createFormatter("en", en);
|
|
112
|
+
|
|
113
|
+
function PlanActions({ client }) {
|
|
114
|
+
const { view, press } = usePlanView(client);
|
|
115
|
+
return view?.actions.map((entry) => (
|
|
116
|
+
<Button
|
|
117
|
+
key={entry.id}
|
|
118
|
+
disabled={!entry.available}
|
|
119
|
+
onPress={() => press(entry.id)}
|
|
120
|
+
>
|
|
121
|
+
{entry.countedLabelKey && entry.labelValues
|
|
122
|
+
? t(entry.countedLabelKey, entry.labelValues)
|
|
123
|
+
: t(entry.labelKey)}
|
|
124
|
+
</Button>
|
|
125
|
+
));
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each language is its own entry (`/messages/en`, `/messages/zh-Hans`), so a host ships only the text it shows; a host with its own wording passes its own catalogue. A key the catalogue lacks throws in development and shows the key itself in production. `stepName(t, planLabels, step)` names a plan step the way the web chat does. `problemView(problem)` gives a problem's words (`key`), its one action (`retry` or `refresh`, worded `chat.retry` and `chat.problem.refresh`) and the `code` and `id` the chat shows behind "Details"; `replyRowView(snapshot.replyState, { canStop: client.canStopReply(), canRetry: client.canRetryReply() })` gives the reply row's words and its buttons (Stop, Keep waiting, Retry), each wired to the client method of the same name (`retry` is `retryReply()`).
|
|
130
|
+
|
|
131
|
+
### Part data
|
|
132
|
+
|
|
133
|
+
Each part receives a copy of its own data as `data`, typed by its name (`PartData`, `PartDataMap`; `PartProps<"stepRow">` for one part). Parts not listed receive none.
|
|
134
|
+
|
|
135
|
+
| Part | `data` |
|
|
136
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
137
|
+
| `bubble` | `{ message }` (`id`, `role`, `content`, `status`, `versionIndex`, `versionCount`, `blocks`) or `{ pending }` for a message being sent |
|
|
138
|
+
| `messageActions` | `{ message }` (`id`, `role`, `status`, `versionIndex`, `versionCount`) |
|
|
139
|
+
| `widgetFrame` | `{ block }` (`id`, `type`, `answered`) |
|
|
140
|
+
| `failedSendControls` | `{ pending }` (`id`, `content`, `failure`: `{ code }` or `null`) |
|
|
141
|
+
| `composer` | `{ sending }` |
|
|
142
|
+
| `confirmBar` | `{ view, actions }`: the plan view and the actions the bar shows |
|
|
143
|
+
| `confirmation` | `{ confirmation }`: the host's confirmation record |
|
|
144
|
+
| `planNode` | `{ view }` |
|
|
145
|
+
| `stepRow` | `{ step }` (`id`, `operation`, `resource`, `view`, `tier`, `undo`, `stepUp`) |
|
|
146
|
+
| `stepUpPrompt` | `{ stepUpCount }` |
|
|
147
|
+
| `recoveryPanel` | `{ view }` |
|
|
148
|
+
| `stepStatusRow` | `{ step }` (`stepId`, `index`, `resource`, `truth`, `textKey`): name it with `stepName(format, planLabels, step)` |
|
|
149
|
+
| `recoveryActions` | `{ actions }` |
|
|
150
|
+
|
|
151
|
+
`FlowSource` defaults to favourites, most-used, recent, presets, in that order, and initially chooses the first nonempty tab. An explicit selection survives subsequent reads. The source owns favourite status and usage; the client does not infer them. `load` optionally returns a parsed `Flow` matching `schema.json`. Both YAML and JSON paths use `validateFlowDocument`; `bindFlow` binds parameters, and `run` asks the host for a proposal. This path never bypasses confirmation or executes arbitrary flow requests directly.
|
|
152
|
+
|
|
153
|
+
The runner sends the confirmed canonical method, path and body unchanged, adds `Waypoint-Step`, and retains `If-Match`. A transient `stepUpCode` is supplied separately to the application's request adapter; add it only to the outgoing write's verification field, never to the stored canonical request or logs. Outcomes and undo requests come from the server. Stop inhibits local dispatch and reads the recorded outcomes; protected plans additionally submit a fenced terminal intent. Neither Stop nor interruption automatically requests Undo. A heartbeat or browser loss needs authoritative readback; forward writes are never automatically retried. Scheduled plans are handed to the application's scheduling adapter and never run browser writes.
|
|
154
|
+
|
|
155
|
+
Integrations not included in this release:
|
|
156
|
+
|
|
157
|
+
- Form-popup images use the injected `PrivateAttachmentPort` upload/remove adapter. General composer attachments remain an unimplemented seam through the protocol `AttachmentPort` and `ComposerContribution`.
|
|
158
|
+
- The command/mention registry: **seam exists, not implemented**. Contributions supply hints, hold decisions and payloads; no registry implementation ships.
|
|
159
|
+
- The `/element` custom-element wrapper: **seam exists, not implemented**. The framework-free `subscribe` / `getSnapshot` contract can drive a future wrapper; there is no `/element` export.
|
|
160
|
+
- A React Native UI: **seam exists, not implemented**. The core's injected platform/storage/transport ports require no DOM, and the `/hooks` entry gives a native UI the plan presses and the card answer lock; native rendering is outside this package.
|
|
161
|
+
- **Not implemented yet:** continuing a plan's remaining steps, selected undo and continuing a confirmed undo, decision time limits, per-account plan capacity and its account screen, account deletion handling, background recovery checks and operator diagnostics, scheduled-work recovery, execution identity across windows, custom step kinds and the conformance scenarios for these.
|
|
162
|
+
|
|
163
|
+
## Versions and distribution
|
|
164
|
+
|
|
165
|
+
The protocol version is independent of the package version. `SUPPORTED_PROTOCOLS` currently contains `1.1` and `1.0`; negotiation selects a supported version and capability list. Additive optional fields belong to protocol minor versions. An incompatible version returns the typed update problem. A package release can change UI or tooling without changing the protocol.
|
|
166
|
+
|
|
167
|
+
For the package, a **0.x minor = breaking** release; patch releases preserve its public API. Public changes carry a Changeset and an updated API Extractor report. Deprecated APIs retain `@deprecated` for at least one minor release and issue a development-mode warning before removal. `RunnerPorts.stop` is deprecated and no longer called by the runner; guard any direct call because the port is now optional.
|
|
168
|
+
|
|
169
|
+
The package is published to npm with prebuilt `dist`. There are no install/prepare lifecycle builds. `npm run release-build` generates and verifies the artifact, and `prepublishOnly` runs it before every publish; it does not commit, tag, push or publish by itself. Local consumer verification uses an equivalent packed prebuilt artifact, with both npm and pnpm, and verifies installed file hashes and all explicit exports.
|
|
170
|
+
|
|
171
|
+
The **initial core** has its own budget, set in `.size-limit.cjs` and checked by `npm run size`. Lazy recovery code has separate caps and an additional deduplicated aggregate regression guard allowing 3% above the measured size, rounded down. The **parser chunk** keeps its separate 15 kB cap; parsing a server-supplied JSON flow avoids YAML entirely. React, the renderer-neutral hooks, CSS, skins, testing and the schema retain their approved limits in `.size-limit.cjs`. Run `npm run size` for current measurements. See [the measurement method and package checks](docs/plan-protocol.md#packaging-acceptance).
|
|
172
|
+
|
|
173
|
+
### Upgrading
|
|
174
|
+
|
|
175
|
+
For the full list of breaking changes and host duties, see [CHANGELOG Upgrading](CHANGELOG.md#upgrading).
|
|
176
|
+
|
|
177
|
+
**Shortcut scope.** Remove `shortcuts.global`; it is deprecated and ignored. The composer focus shortcut acts only while focus is inside the chat and outside an editable field.
|
|
178
|
+
|
|
179
|
+
The [plan protocol guide](docs/plan-protocol.md) describes the implemented host contract, delivery uncertainty, packaging checks and quality backlog. The following adapter changes are additive unless identified as replacements:
|
|
180
|
+
|
|
181
|
+
| Before | Now | Consumer action |
|
|
182
|
+
| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
183
|
+
| `RunnerPorts.stop(id): Promise<PlanRecord>` | `stop?(id): Promise<PlanRecord>` (deprecated) | Guard direct calls with `ports.stop?.(id)`; the runner never calls it or automatically requests Undo. |
|
|
184
|
+
| `heartbeat(id)` | `heartbeat(id, acceptance?)` | Forward the optional protected execution identity to the host's renewal check. |
|
|
185
|
+
| `PlanRunner.run(id, confirmedDigest)` | `run(id, confirmedDigest, acceptance?)` | Protected runs require the first accepted confirmation's private `ExecutionAcceptance`; legacy calls remain valid. |
|
|
186
|
+
| `TransportPort.send(...): Promise<{ id: string }>` | `Promise<ReplyDelivery>` (`{ id, acceptance? }`) | Preserve private acceptance in the adapter response; `client.reply()` still returns `Promise<boolean>`. |
|
|
187
|
+
| `negotiate(supported)` | `negotiate(supported, offer?)` | Forward the offer; host helper `negotiate(supported, capabilities)` becomes `negotiate(supported, capabilities, offer)` for binding. |
|
|
188
|
+
| `read(conversationId)`, `send(reply, conversationId)`, `subscribe(conversationId, listener)` | Same calls with optional final `ReaderRequestContext` | Forward the per-client agreement without storing shared mutable context. |
|
|
189
|
+
| `RunnerPorts.read(id)` | `read(id, context?)` | Forward its optional reader agreement context too. |
|
|
190
|
+
| No protected terminal-intent adapter | `intent?(request: IntentRequest): Promise<PlanIntentReply>` | Add the authenticated safety-control route and durable host state described below; old adapters remain assignable and protected plans are read-only without it. |
|
|
191
|
+
| `runFlow(id, values): Promise<void>` | `Promise<string \| void>` | Use the loaded conversation id when returned; a refused or superseded navigation returns no id, and a rejected operation may throw. |
|
|
192
|
+
|
|
193
|
+
`stopPlan`, `resumePlan` and `retryUndo` retain their public names. Optional legacy `plans.resume(id)` and `plans.retryUndo(id)` signatures remain supported and adapter-driven; they are not protected continuation APIs. This release's delivery types are `ReplyDelivery` and `PlanIntentReply`; it does not export `ControlDeliveryReply`. `IntentOverlay` exposes pending delivery, not authority. Custom snapshots carry intent overlays/notices/alerts and `planReadOnly`; consumers that implement their own client follow the current API report rather than assuming an older snapshot shape.
|
|
194
|
+
|
|
195
|
+
**Host adoption.** Persist generations, reviews, execution/fence state, intent slots, rounds, evidence and final receipts atomically and durably. Validate ownership and scope from stored records, enforce streaming body limits, fence old admissions/commits, preserve exact confirmation replay, and refuse legacy stop for protected plans. Forward negotiation offers and contexts. Keep conformance controls isolated and audit production routing independently. Custom reads use trusted host manifests/templates and project only declared low-tier fields; a stream cannot select its own trusted template. Account quota, principal deletion, custom step registries, scheduled-work recovery and their lifecycle controls are not included in this release. The guide records the identity non-reuse duty and the released-before-deletion receipt limitation without claiming those missing controls ship.
|
|
196
|
+
|
|
197
|
+
**Guarded field parts.** A custom `fieldAdornment` part previously received `{ disabled, onPress }`; it now also receives `{ "aria-disabled", describedBy }` on `PartProps`. Forward those as `aria-disabled` and `aria-describedby` while reserving native `disabled` for the existing disabled state; a guarded action must remain focusable, and its `onPress` is absent while guarded.
|
|
198
|
+
|
|
199
|
+
**Form catalogues.** `MessageKey` now includes `keyof FormCatalogue` (also exported as `FormMessageKey`). Consumers with exhaustive `Record<MessageKey, string>` dictionaries or formatters add these form keys or a fallback; `FormCatalogue` contains the lazy form extras' messages.
|
|
200
|
+
|
|
201
|
+
**Form sessions load on first use.** `createFormSession(config)` is replaced by `await loadFormSession(config)`, which loads the session code as its own chunk. If the chunk cannot be loaded it rejects with the fixed problem `FORM_SESSION_UNAVAILABLE` (`retryable`, id `form-session-load`), keeps nothing, and a later call tries again.
|
|
202
|
+
|
|
203
|
+
**Queued messages are withdraw-only.** `client.edit(id)` and `useComposer().edit` are replaced by `client.withdraw(id)` and `useComposer().withdraw`, and the `chat.edit` message key by `chat.withdraw`, with `chat.restored`, `chat.restoredEdit` and `chat.restoredDiscard` for restored items. Withdrawing moves a queued message's content into its own restored item (`snapshot.restored`, each with its own id, kept in the same stored record) and removes the row in the same commit. Withdrawn content never enters the draft by itself: `client.editRestored(id)` appends it to the draft only when the user asks, as an ordinary draft change, and `client.discardRestored(id)` deletes the item, the only way an item is removed; draft changes never touch restored items. Nothing changes before the withdrawal's commit lands (the row id is listed in `snapshot.withdrawing` meanwhile, and the stock UI disables its control), and a withdrawal whose commit fails changes nothing. The stock UI lists restored items next to the composer with Edit and Discard. It is refused, reported as `refused` with id `sending`, while a send is in flight or its outcome is unknown, because that message may already be delivered. Outbox rows never change: each row is one logical message whose id is sent as `clientMessageId` and is the server's dedupe key, so a message with an unknown outcome is only ever resent with the same id and content. An attempt is settled only by an acknowledgement (a successful send, or a conversation read that holds the id) or by a definite failure of that same attempt; a refused retry, a reload, a new runtime or a read that does not hold the id settles nothing. A send dispatches only after its claim is committed to storage and sends the row as committed; if the claim cannot be committed nothing is sent and the row is marked `unavailable`.
|
|
204
|
+
|
|
205
|
+
**Storage writes.** A client saves operations, not snapshots: set the draft, add an outbox row, claim or finish a send attempt, withdraw a row, remove a row once it is acknowledged or dismissed. Each is applied to the stored record when the commit lands, through the optional atomic `StoragePort.compareAndSet(key, expected, value)` (implemented by `createMemoryStorage`), otherwise under a Web Lock on the key (`navigator.locks`, shared by tabs and workers of one origin) where the platform has it, otherwise by writing, reading back and retrying. An operation whose condition no longer holds is dropped and the client shows the stored record instead. The draft is ordered by commit through a per-key revision persisted in the record, never by a clock: the later commit wins. A disposed client's draft edit lands only if nobody changed the draft since the revision it last saw. Loading a record only replaces what the client shows and never writes. Stores shared by several runtimes without Web Locks should implement `compareAndSet`: the read-back fallback cannot stop a write one runtime started from landing late.
|
|
206
|
+
|
|
207
|
+
**Protected plans and terminal intents.** A plan record may carry `recovery` (profile `plan-recovery-v1`): its stored phase, generation, round, revision, conversation, current review and execution, intent slot versions, winner and per-step truth (`done`, `not-done`, `unknown`). Every such record passes one monotone gate, which keeps each protected plan's history for the client's lifetime: a lower revision is ignored, an equal revision must be the same record, and a record for another conversation or generation, one whose `conversationId`, `generation` or `revision` is missing or ill typed, or a protected plan arriving without its recovery view is reported as `invalid-plan` and the stored record kept. A plan under a profile or phase this client does not know, or without the negotiated `plan-recovery` capability, is adopted **read-only** under the same rules: it is shown with its recorded state and the `chat.recovery.updateToControl` explanation, every control refuses it locally and sends nothing, a run whose plan turns read-only gets no further record, and an acceptance that lands meanwhile starts nothing; controls return only from a fresh read at a known profile and phase. The runner consumes and the client publishes (`onRecord`) only records the gate adopted; a refused record ends a run as an interruption with cause `plan-changed`. A client offers the `plan-recovery` capability only when `plans.intent(request)` is supplied; it delivers terminal intents (`IntentRequest` → `PlanIntentReply`) through the host's `/plans/:id/safety-control` route, which needs authentication and CSRF but no agreement. `stopPlan()` on a protected plan inhibits the local run at once and submits a Stop for the exact execution, or the generation before any acceptance; `declinePlan()` submits an initial Cancel (never a normal reply); `leavePlan()` submits a Leave when the `plan-leave` capability is agreed (it holds only together with `plan-recovery`), acknowledging the adopted record's `decisionKey`, `versions`, `evidenceDigest` and `unknownCount` (against a running execution the host fences it and saves no decision, `leaveSafetyNeedsReview`; from readback an informed Leave opens the next decision round and closes the decision as `left-known` or `left-unconfirmed`, and a second window's identical Leave joins that round and gets the same stored result); `keepChangesReview()` / `canKeepChanges()` read synchronously, from the adopted record, what Keep would confirm (`forward-only` when planned work remains, `end-retention` otherwise) or why it is disabled (`capabilityMissing`, `keepNoDecision`, `sealed`, `keepBlockedActive`, `keepBlockedUnknown`), and `keepChanges(review)` sends exactly that Keep under the `plan-keep` capability (it holds only together with `plan-recovery`); a successful Keep closes the decision or ends own retention, records a permanent receipt and seals the plan, after which every later intent is answered from the final history (`finalHistory`) without changing it; while an intent is pending, `snapshot.intentOverlays[planId]` shows it (at most one per plan: its request, delivery `sending` / `uncertain` / `not-sent` and a problem key), `snapshot.intentNotice` the last resolved reply's facts, and `snapshot.intentAlert` the one action to focus after a new failure; `checkIntentStatus()` (also `checkSavedChoice()`, `checkKeepStatus()`, `retryIntent()`) redelivers the same request, a 401 is retried once after a successful authenticated call, 403 / 429 / offline wait for the user, and new confirmation is refused while pending; the intent port should reject with a numeric HTTP `status` (and `retryAfter` for 429) for host answers, any other rejection being an unknown outcome; the intent client loads when a protected plan is first adopted, so `keepChangesReview()` answers `{ disabled: "loading" }` until then and `{ disabled: "intentUnavailable" }` after a failed load (retried by a later review); an interrupted run (failed step or step-up, missing attribution, refused renewal, changed plan) submits an automatic Stop with that cause; each then reads the plan again. The intent client loads on first use; if it cannot load, the action reports `retryable`/`intent-load` and only the local Stop stays. A first accepting confirmation of a protected plan returns a private `acceptance` (`ReplyDelivery`), which only the runner receives; `reply()` stays boolean, a run starts only under it, and `runPlan()`/`resumePlan()` or the runner itself refuse a protected plan without it. A failed plan read during a run also submits the automatic Stop (cause `connection-lost`), for the interrupted plan whichever plan is displayed. A capability is enabled only when this client offered it, whatever the host echoes. **Behaviour change for every plan:** a stopped or failed run never asks the host to stop or undo anymore: it stops dispatching and reads its facts; `RunnerPorts.stop` is deprecated and never called, and `RunnerPorts.heartbeat(id, acceptance?)` receives the accepted execution. Hosts: the reference resolver (`src/host/plan-intents.ts`, not part of the client bundle) is a pure per-plan transition to run inside the host's own per-plan transaction, with `parseIntentRequest` to validate an intent body (exact fields, token and integer bounds, a 4096-byte NFC-normalized UTF-8 payload) and `parseConfirm` a confirmation, both before any comparison; the separate 8192-byte body limit `MAX_INTENT_BODY_BYTES` must be enforced while the body streams in (a declared or streamed length over it is refused at once, before parsing). A commit needs the `Admission` that only the admission of that exact operation returns. `parseIntentRequest` and `Admission` are repository reference code, not package exports. A host selects the plan and execution only from its own records (the route, and the execution it issued), requires the caller's scoped permission and the plan's own conversation, accepts an effect only as the exact accepted operation at its step, and answers an exact confirmation replay (every field equal) with the original acknowledgment and no new authority, refusing any changed or malformed one; the reference fixture keeps that safety state in memory, while a real host must keep it durable and atomic, and refuse the legacy `/stop` for protected plans.
|
|
208
|
+
|
|
209
|
+
**Stream recovery for bound subscriptions.** When a subscription opened under an agreement reports `reconnecting` or `unavailable` (including a frame the transport cannot parse), the client closes it at once, before the adapter's own reconnect can retry the old URL, and owns recovery: after 250, 500, 1000, 2000 and 4000 ms it probes an authenticated conversation read and, when that succeeds, opens a new subscription under the current agreement. A probe refused with `agreement-expired` hands over to the agreement renegotiation, which reconnects under the new agreement; a probe refused for sign-in or an incompatible version stops with that cause (problem id `stream`); a missing conversation stops as for any read. A probe that succeeds does not reset the count; only an open that stays healthy for 10 s does, or regaining connectivity. While offline an attempt waits for connectivity (the `subscribeOnline` notification, or a 4 s recheck of `online()`) instead of being spent. After five attempts the snapshot reports `streamUnavailable: true` and the problem `unavailable`/`stream`; `client.refresh()` or coming back online retries, and the next healthy open clears that problem. The count, the give-up state and the episode belong to the stream under its current agreement: renegotiation, Retry, switching conversation or disposal retires them, so `streamUnavailable` never carries over to a new agreement. Hints coalesce with any read already running for the conversation. Because the client owns this reconnect, an adapter's native resume (for example an EventSource `Last-Event-ID`) is not used: each recovery refetches the full conversation. Subscriptions opened without an agreement keep their adapter's reconnect as before.
|
|
210
|
+
|
|
211
|
+
**Signal transport hints.** `createSignalTransport` now turns each stream line into a `{ type: "invalidate" }` transport event instead of parsing it as a frame; a hint is never applied as content. The client coalesces hints per conversation with whatever conversation read is already running: at most one more read follows it. Code that switches over `TransportEvent` should handle the new member.
|
|
212
|
+
|
|
213
|
+
**Conformance runners load on first use.** `runConformance`, `runPlanConformance` and `runFormFillConformance` keep their signatures but load their code as a separate chunk; if it cannot be loaded they reject with `CONFORMANCE_UNAVAILABLE` (`retryable`, id `conformance-load`) and a later call loads again.
|
|
214
|
+
|
|
215
|
+
**Agreement binding.** `TransportPort.negotiate(supported, offer?)` now receives the client's `NegotiationOffer` (the capabilities it implements) and may return an `agreementId`. `read`, `send` and `subscribe` take an optional last `ReaderRequestContext` argument, `{ agreementId }`, and `RunnerPorts.read(id, context?)` an optional second one. Existing adapters with the old signatures stay assignable and keep working without a binding. A transport must store no binding itself: each client owns its answer, even when several clients share one transport. The built-in HTTP transport sends the offer in the `/negotiate` body and never an old agreement; once a client has an agreement, it sends it as a `Waypoint-Agreement` header and, for the stream, as a `?waypointAgreement=` query parameter, because an EventSource cannot set headers. The id is not a secret and grants nothing.
|
|
216
|
+
|
|
217
|
+
Hosts that pass the offer to `negotiate(supported, capabilities, offer)` return only capabilities both sides support and should issue an `agreementId`; a host that keeps the two-argument call stays a legacy host and issues none. On a reader or message route, after normal authentication, an agreement the host does not know (expired, revoked or reset) must answer 409 with `{ "code": "agreement-expired" }` before admitting anything, and a malformed id must answer 400. `waypoint-conformance` checks this when the host issues agreements.
|
|
218
|
+
|
|
219
|
+
On `agreement-expired` for its current agreement, the client closes the stream, renegotiates once and reads again; concurrent refusals share that renegotiation, and a refusal of an older agreement counts as one ordinary failure. If the replacement is refused before anything succeeds under it, the client stops with `unavailable` (id `agreement`) until `client.refresh()`. A renegotiation that changes the protocol reports `update-required`. A refused message stays in the outbox with the same id and is sent again only by an explicit retry; a refused reply or plan confirmation is never resent. A refusal of the current agreement also retires an active plan run on this client locally, with no remote stop or undo; plan reads take part in the same binding, and a refusal from a conversation the client has left is ignored.
|
|
220
|
+
|
|
221
|
+
**Card answers.** A host holds one answer per card, a block of a message with the blocks nested in it. The first answer it applies marks that block `answered: true` in every later read: the message's block (the card), not a block nested in it that the answer names. A replay of that request (the same `clientReplyId` and body) gets its first response; any other answer to the card, whatever its `clientReplyId`, is refused with 409 `{ "code": "refused" }` and changes nothing. The same `clientReplyId` with another body is refused too, with 409 `refused` or, as the [Idempotency-Key draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) answers a changed body, 422. The rule departs from that draft in one way: another answer under a new `clientReplyId` is refused because the card already holds one, which the draft does not cover. While the first request with a `clientReplyId` is still being processed, a host either waits for it and answers with its response or, as the draft does, answers 409; the client then reads and shows the card as the host holds it. A host may also name the answer it holds in the card's optional `answer`: the applied `ReplyAction` or `ReplyForm` without its `protocol` (`CardAnswer`), only with `answered: true`. The chat shows it as the chosen answer when it names an action of the card, and ignores it otherwise or without `answered: true`; a host that leaves it out stays valid. Every reader of the conversation sees `answer`, so a host leaves out a form's `values` when they are not for everyone to read. The client keeps no answer of its own: `useBlockReply` locks a card while its answer is in flight, and after the host accepted it, builds the `clientReplyId` from the conversation, message, card and answer (`cardReplyId`, the same whatever the order of the answer's keys), so the same answer after a reload or in another tab is the same request, and frees a refused or lost answer's card once it has read the conversation after the answer. `reply()` resolves with the `ReplyOutcome`. When an answer is refused and the read after it shows the card answered, another answer was held first (another tab, an earlier page): the chat then says `chat.problem.answered` instead of the generic refusal, and says nothing about the refusal until that read settles. A host's `problemText` gets the same fact as its second argument, `{ answeredElsewhere: true }`. A headless UI decides this from the outcome and the conversation the same way. That read follows every reply, accepted or not, and has the startup read's budget: one that never answers ends as the failed read, with its Retry. `createScriptedTransport` from `/testing` behaves this way and names the applied answer, and `waypoint-conformance` checks it (`one answer per card`, an answer to a block nested in a card; it accepts 422 for the changed body, and checks `answer` only when a host sends it, field by field, in any key order and with any other fields the schema allows).
|
|
222
|
+
|
|
223
|
+
**Plans from newer hosts.** `RecoveryView.profile` is now `'plan-recovery-v1' | (string & {})` (was `'plan-recovery-v1'`) and `RecoveryView.phase` is `RecoveryPhase | (string & {})` (was `RecoveryPhase`); the schema marks both with the `x-open-enum` extension and accepts other strings, while `RecoveryPhase` itself stays closed for intent replies. The extension value is either an array of known string literals (for example `["plan-recovery-v1"]`) or the name of the enum definition containing them (`"RecoveryPhase"`); the generator emits those known values unioned with `(string & {})`. A validator in strict mode refuses unknown keywords and formats: register `x-open-enum` as an annotation (Ajv: `ajv.addKeyword("x-open-enum")`) and the `date-time` format the schema uses (Ajv: `ajv-formats`), or validate in non-strict mode. Code that switches exhaustively on `recovery.profile` or `recovery.phase` must handle other values and render them read-only, and code that assigns `recovery.phase` to a `RecoveryPhase` must narrow it first. A client without `plans.intent`, or facing a profile or phase it does not know, now shows such a plan read-only instead of reporting `invalid-plan`; a record whose `conversationId`, `generation` or `revision` is missing or ill typed is refused for every profile.
|
|
224
|
+
|
|
225
|
+
**Modal drawer root.** A skin whose drawer renders a mask or focus sentinels beside its dialog element marks the drawer's own root with the attribute `data-wp-modal-root`: the chat then makes everything outside that root inert, so the mask still takes clicks. It makes its focus sentinels inert itself, as the antd skin does.
|
|
226
|
+
|
|
227
|
+
**Form fill.** The popup's Message and Reference links are drawn by the `textarea` primitive (paste is caught on a wrapper, so a custom one still gets images), and a boolean field by the new optional `checkbox` primitive (`CheckboxProps`: `checked`, `onChange(checked)`, `label`, `disabled`, `invalid`, `describedBy`; the native checkbox if absent). Generation, suggestions, fill reverts, the popup's status and attachments load as a lazy chunk with the first form: until it loads, or after it failed, Generate is unavailable with its reason and the fields keep working. A suggestion's buttons are "Replace" and "Keep mine" (the `/testing` form contract now presses "Replace"). Number fields edit as numbers; other non-text values are shown read-only and need `renderField` to be edited. `FormDraft` gains `localInvalid` and `localText`, `FormSnapshot` gains `fills`, and `FormSession` gains `markEdited`, `revert` and `failRequest`: a custom session implementation adds them. `FieldAdornmentProps.wrapper` reports the field's wrapper element.
|
|
228
|
+
|
|
229
|
+
**Flows.** A row press opens a parameter drawer when the source can `load` documents (`client.canPrepareFlow()`); `client.prepareFlow(id)` loads and validates the document and keeps it for the next `runFlow` of that flow, which no longer loads it again (`prepareFlow()` forgets it). A custom `WaypointClient` adds both methods. A custom `button` primitive forwards `aria-pressed` and `id` (new on `ButtonProps`): the favourite toggle and the tabs use them.
|
|
230
|
+
|
|
231
|
+
**Messages.** The `messageActions` part receives `data.message` (`id`, `role`, `status`, `versionIndex`, `versionCount`). Without a host part it shows the default actions, loaded with the first message: Copy on every message; with a `versions` port, Regenerate on a reply and Edit (an inline editor whose Send calls `client.editMessage`) on the user's own message; with a `feedback` port (`FeedbackPort`, called through `client.sendFeedback`), Good response and Bad response on a reply. A message with several versions shows "‹ 2/3 ›" in place (Previous version, Next version, through `client.selectVersion`); the walk's ends stay focusable and do nothing. A reply still being written shows none. A reply the host ended as failed or unavailable has nothing to copy or rate, and its Retry, in the reply row, asks for it again when there is a `versions` port, so it shows only the version walk, when it has versions. `client.canChangeVersions()` and `client.canSendFeedback()` say which ports are there. A host part replaces the defaults; see [docs/message-versions.md](docs/message-versions.md). In a narrow chat, the part of the reply that presents the plan (the last message, holding the plan's `plan` block) is rendered after the plan, outside that reply's article. A stream's `message.start` marks an assistant message `writing` and replaces any text shown under its id; `message.end` sets its status. A reply that streams in is announced once, when it ends.
|
|
232
|
+
|
|
233
|
+
**Lazy code.** `handleToolRead` and `handlePlanningRead` keep their signatures but load their code on first use and reject with `STREAM_READS_UNAVAILABLE` (`retryable`, id `stream-reads-load`) if it cannot be loaded; `loadReadHandlers()` loads it ahead. `decodeFrames` loads its decoder on first use. A failed load of the storage commit code is reported as the existing `storage-write` problem.
|
|
234
|
+
|
|
235
|
+
**Local problem identifiers.** Read-only `confirmPlan`, `runPlan`, `stopPlan`, `resumePlan` and `retryUndo` report `refused` with the target plan id. Read-only `declinePlan`, `leavePlan`, `keepChanges` and `checkIntentStatus` (and its aliases `checkSavedChoice`, `checkKeepStatus` and `retryIntent`) report `refused` / `intentPending`, as do a confirmation and a new terminal choice made while a terminal choice for the same plan is still pending. A missing read adapter reports `invalid-read` / `read-unavailable`, and other read handling failures use `invalid-read` / `read-failed`. Form mode refuses sensitive tool frames with `refused` / `form-tools` and composer sends without a session with `refused` / `form-session`. A client-actions chunk failure reports `retryable` / `on-demand-load`. These are local problem ids, distinct from host-issued identifiers.
|
|
236
|
+
|
|
237
|
+
## Verification
|
|
238
|
+
|
|
239
|
+
`npm run lint`, `npm run typecheck`, `npm test -- tests/protocol tests/conformance tests/core tests/react tests/skin-antd tests/package`, `npm run build`, `npm run api:check`, `npm run size` and `npm run test:consumer` are local/CI gates. `npm run test:native` checks the local build; CI's consumer check invokes `scripts/native-smoke.mjs` against the installed packed core for both npm and pnpm. Focused tests use at most two workers; `npm run test:browser` uses one. `npm run api:update` intentionally regenerates reports after a reviewed API change; check mode never blesses an uncommitted change. CI exercises lowest and latest optional peers.
|
|
240
|
+
|
|
241
|
+
`waypoint-conformance <test-base-url> [--app-host]` runs HTTP scenarios against an isolated scripted service. The fixture's session and script endpoints answer 404 outside explicit test mode. No production database or model credential is needed. Profile-specific service behavior remains the application's responsibility.
|
|
242
|
+
|
|
243
|
+
The protocol documents (this README and `docs/`) state requirements in plain English ("must", "never", "only"), binding as written. [The reply protocol](docs/reply-protocol.md), which covers stopping a reply and the other reply features a host can offer, uses the capitalised key words; the others do not use the capitalised key words of [BCP 14](https://www.rfc-editor.org/info/bcp14) ([RFC 2119](https://www.rfc-editor.org/rfc/rfc2119), [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)). The schema uses [JSON Schema 2020-12](https://json-schema.org/draft/2020-12). YAML parsing uses [js-yaml's JSON schema](https://github.com/nodeca/js-yaml), rejecting duplicate keys, aliases and executable tags. API snapshots use [API Extractor reports](https://api-extractor.com/pages/setup/configure_api_report/). The antd skin follows its [token and algorithm APIs](https://ant.design/docs/react/customize-theme/).
|
|
244
|
+
|
|
245
|
+
Markdown images are links by default and initiate no image download. Opt in explicitly with `markdownImages={{ origin: "https://example.test", allow: url => allowedImages.has(url) }}`: origin permits same-origin images and allow permits a host-defined image list.
|
|
246
|
+
|
|
247
|
+
All built-in Markdown, route and plan-view links share `isSafeUrl`. Add host-owned protocols with `extraUrlSchemes={["example"]}`; executable schemes and slash/backslash authority tricks remain refused.
|
|
248
|
+
|
|
249
|
+
Read frames are answered by the client when `reads` is configured. For example, `reads: { manifest, request: call => apiRead(call), planning: { template: (planId, stepId) => trustedTemplate(planId, stepId), request: template => planningRead(template) } }`. The template comes from the host's trusted plan source, independently of the stream. Manifest reads project declared low-tier fields into `tool-result`; exact-template planning reads send their projected values only as `plan.values`. Refused reads never dispatch a request.
|
|
250
|
+
|
|
251
|
+
`DEFAULT_FLOW_TABS` is the shared ordered built-in list. Add host tabs with `tabs: [{ id: "team", labelKey: "host.team" }]` and resolve their labels with `flowTabLabel={key => hostMessages(key)}`. Built-in tab names retain their typed catalogue keys.
|
|
252
|
+
|
|
253
|
+
Direct runner integrations call `runner.run(planId, confirmedDigest)` with the digest the user confirmed. `client.confirmPlan()` captures that digest and deduplicates simultaneous confirmations of the same digest. `client.stopPlan(planId)` records stop intent even before execution begins; a stopped identifier cannot restart forward writes.
|
|
254
|
+
|
|
255
|
+
Relative links (`docs/page`, `?q=1`) are refused. For application navigation, only absolute same-origin paths (beginning with `/`, excluding `//` and `/\`) are allowed; fragments and the explicitly allowed URL schemes remain supported.
|
|
256
|
+
|
|
257
|
+
Confirmation retry keys are scoped to each plan id and digest: retrying a refused confirmation reuses its key, while a revised digest gets a new key. Confirmed digests are held in memory, scoped to conversation and plan, and cleared when the plan ends or is stopped, on conversation switches, and on disposal. A resume after a page reload always asks for confirmation again; confirmations are never persisted. Resume runs only when the returned final plan still matches the confirmed id and digest; otherwise it reports `plan-changed` ("The plan changed; confirm again.") and offers fresh confirmation. Running a permanently stopped id reports `stopped`. Both use the normal typed problem channel. `client.reply` accepts a reply without `protocol` and sets the negotiated version after readiness.
|
|
258
|
+
|
|
259
|
+
For the legacy resume adapter, a resume that returns the same timed plan waiting to be scheduled restores that record without reporting a problem or running browser writes. An unfinished immediate proposal reports `invalid-plan` and retains its returned state. Returned plan identity is checked before either kind is handled.
|
|
260
|
+
|
|
261
|
+
Form-bound conversations negotiate `form-fill` on protocol 1.1. Use `await loadFormSession({ fields, ids })` (its code loads on first use, outside the core bundle), the optional client `formSession`, `FormFillPopup`, `FormFillFields` and the shared `FieldAdornment` slot. Draft reception never saves records. The rail accepts subject and parent metadata or a `ConversationSource`. See [the form protocol and integration guide](docs/form-fill-protocol.md) for wire examples, replacement rules and host responsibilities.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { W as WaypointClient, b as ClientSnapshot, x as KeepChangesReviewResult, L as ReplyOutcome } from './form-fill-C32AHxSr.cjs';
|
|
2
|
+
import { d as IntentOverlay, c as IntentNotice, I as IntentAlert } from './intent-BQPPows1.cjs';
|
|
3
|
+
import { ad as PlanState, Z as IntentKind, ac as PlanRecord, ag as Reply, B as Block } from './generated-C9tbjwK0.cjs';
|
|
4
|
+
import { M as MessageKey } from './i18n-DPNfYTMG.cjs';
|
|
5
|
+
|
|
6
|
+
declare function useWaypointSnapshot(client?: WaypointClient): ClientSnapshot;
|
|
7
|
+
|
|
8
|
+
interface ViewMessage {
|
|
9
|
+
key: MessageKey;
|
|
10
|
+
values?: Record<string, number>;
|
|
11
|
+
}
|
|
12
|
+
type Tone = "neutral" | "progress" | "warning" | "success";
|
|
13
|
+
interface StepStatus {
|
|
14
|
+
stepId: string;
|
|
15
|
+
/** Its place in the plan, from 1; with `resource`, what `stepName` names it by. */
|
|
16
|
+
index: number;
|
|
17
|
+
resource?: string;
|
|
18
|
+
truth: "done" | "not-done" | "unknown";
|
|
19
|
+
textKey: MessageKey;
|
|
20
|
+
lateReport?: "contradicted";
|
|
21
|
+
}
|
|
22
|
+
type ActionId = "confirm" | "stop" | "decline" | "leave" | "keep" | "status" | "undoSelected" | "continue";
|
|
23
|
+
interface ActionEntry {
|
|
24
|
+
id: ActionId;
|
|
25
|
+
/** The label; it takes no values. */
|
|
26
|
+
labelKey: MessageKey;
|
|
27
|
+
/**
|
|
28
|
+
* A label that also says how many changes there are ("Apply 2 changes"),
|
|
29
|
+
* formatted with `labelValues`; Confirm has one. Without it, or without
|
|
30
|
+
* the values, `labelKey` alone names the action ("Apply these changes").
|
|
31
|
+
*/
|
|
32
|
+
countedLabelKey?: MessageKey;
|
|
33
|
+
/** The values `countedLabelKey` takes: the number of changes. */
|
|
34
|
+
labelValues?: Record<string, number>;
|
|
35
|
+
available: boolean;
|
|
36
|
+
reasonKey?: MessageKey;
|
|
37
|
+
reasonValues?: Record<string, number>;
|
|
38
|
+
busy?: boolean;
|
|
39
|
+
schedule?: boolean;
|
|
40
|
+
}
|
|
41
|
+
interface PlanViewStatus {
|
|
42
|
+
dataStatus: string;
|
|
43
|
+
wireState: PlanState;
|
|
44
|
+
label: ViewMessage;
|
|
45
|
+
tone: Tone;
|
|
46
|
+
attention: boolean;
|
|
47
|
+
result?: "kept";
|
|
48
|
+
}
|
|
49
|
+
interface PlanViewAlert {
|
|
50
|
+
serial: number;
|
|
51
|
+
action?: "checkStatus" | "retry" | "signIn" | "hostGuidance" | "wait";
|
|
52
|
+
restoreFocus?: true;
|
|
53
|
+
message?: ViewMessage;
|
|
54
|
+
retryAvailable: boolean;
|
|
55
|
+
retryReasonKey?: MessageKey;
|
|
56
|
+
retryValues?: Record<string, number>;
|
|
57
|
+
}
|
|
58
|
+
interface PlanView {
|
|
59
|
+
planId: string;
|
|
60
|
+
/** Null for a plan without the recovery profile (legacy). */
|
|
61
|
+
status: PlanViewStatus | null;
|
|
62
|
+
intent: {
|
|
63
|
+
kind: IntentKind;
|
|
64
|
+
status: IntentOverlay["delivery"];
|
|
65
|
+
} | null;
|
|
66
|
+
steps: StepStatus[];
|
|
67
|
+
lines: ViewMessage[];
|
|
68
|
+
notice: ViewMessage[];
|
|
69
|
+
alert: PlanViewAlert | null;
|
|
70
|
+
actions: ActionEntry[];
|
|
71
|
+
pending: readonly IntentKind[];
|
|
72
|
+
conversationUnavailable: boolean;
|
|
73
|
+
/** A protected plan this client cannot control: shown, never acted on. */
|
|
74
|
+
readOnly?: true;
|
|
75
|
+
}
|
|
76
|
+
interface PlanViewInput {
|
|
77
|
+
plan: PlanRecord | null;
|
|
78
|
+
capabilities: readonly string[];
|
|
79
|
+
conversationUnavailable: boolean;
|
|
80
|
+
overlay: IntentOverlay | undefined;
|
|
81
|
+
notice: IntentNotice | null;
|
|
82
|
+
alert: IntentAlert | null;
|
|
83
|
+
keepReview: KeepChangesReviewResult;
|
|
84
|
+
pending: readonly IntentKind[];
|
|
85
|
+
now: number;
|
|
86
|
+
conversationId: string;
|
|
87
|
+
readOnly?: boolean;
|
|
88
|
+
}
|
|
89
|
+
/** The notice's messages, built only from what the host reported. */
|
|
90
|
+
declare function noticeMessages(notice: IntentNotice): ViewMessage[];
|
|
91
|
+
/** The view of the current plan, or null without one. */
|
|
92
|
+
declare function planView(input: PlanViewInput): PlanView | null;
|
|
93
|
+
interface KeepSheetView {
|
|
94
|
+
messages: ViewMessage[];
|
|
95
|
+
proceed: {
|
|
96
|
+
available: boolean;
|
|
97
|
+
reasonKey?: MessageKey;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** What the Keep confirmation states for the review captured at opening. */
|
|
101
|
+
declare function keepSheet(review: {
|
|
102
|
+
review: "forward-only" | "end-retention";
|
|
103
|
+
remaining: number;
|
|
104
|
+
}, view: PlanView): KeepSheetView;
|
|
105
|
+
/** A message block as part data: its identity only, never its body. */
|
|
106
|
+
interface BlockSummary {
|
|
107
|
+
id: string;
|
|
108
|
+
type: string;
|
|
109
|
+
answered: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface KeepSheet {
|
|
113
|
+
messages: ViewMessage[];
|
|
114
|
+
proceed: {
|
|
115
|
+
available: boolean;
|
|
116
|
+
reasonKey?: ViewMessage["key"];
|
|
117
|
+
};
|
|
118
|
+
onProceed(): void;
|
|
119
|
+
onCancel(): void;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The plan view of the current plan and its presses, for every renderer.
|
|
123
|
+
* Presses go through the client; this hook only tracks a press that has no
|
|
124
|
+
* overlay yet, so the view can show it as pending.
|
|
125
|
+
*/
|
|
126
|
+
declare function usePlanView(client?: WaypointClient): {
|
|
127
|
+
view: PlanView | null;
|
|
128
|
+
press: (id: ActionId | "retry") => void;
|
|
129
|
+
sheet: KeepSheet | null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** An answer to a card, without the ids the hook fills in. */
|
|
133
|
+
type BlockAnswer = Pick<Reply, "kind"> & Record<string, unknown>;
|
|
134
|
+
interface BlockReply {
|
|
135
|
+
/**
|
|
136
|
+
* Ids of this conversation's cards with an answer in flight or accepted
|
|
137
|
+
* here, by card id alone; `isAnswered` with a message id tells cards of
|
|
138
|
+
* different messages apart.
|
|
139
|
+
*/
|
|
140
|
+
answered: ReadonlySet<string>;
|
|
141
|
+
/**
|
|
142
|
+
* Whether a card takes no answer: the host marks it answered, or one is
|
|
143
|
+
* in flight or was accepted here. Pass the card's message id when card
|
|
144
|
+
* ids repeat.
|
|
145
|
+
*/
|
|
146
|
+
isAnswered(block: Pick<Block, "id" | "answered">, messageId?: string): boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Sends one answer to the card of `messageId` (when given), with a reply
|
|
149
|
+
* id built from the card and the answer (see `cardReplyId`). A press on a
|
|
150
|
+
* card that takes no answer here does nothing and resolves with nothing.
|
|
151
|
+
* Otherwise resolves with the outcome once the client has read the
|
|
152
|
+
* conversation after the answer; a card whose answer was refused or lost
|
|
153
|
+
* is then shown as the host reports it, and one whose answer was
|
|
154
|
+
* accepted stays answered.
|
|
155
|
+
*/
|
|
156
|
+
reply(block: Pick<Block, "id">, answer: BlockAnswer, messageId?: string): Promise<ReplyOutcome | void>;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* A card's reply id: the same for the same answer to the same card, in any
|
|
160
|
+
* page or tab, so a host deduplicates a resend, and another for another
|
|
161
|
+
* answer, which a host that already holds one refuses. It is opaque: its
|
|
162
|
+
* parts and a digest of the answer (whatever the order of its keys),
|
|
163
|
+
* escaped by code unit, and a digest of all of it past 128 characters.
|
|
164
|
+
*/
|
|
165
|
+
declare function cardReplyId(conversationId: string, messageId: string, blockId: string, answer: BlockAnswer): string;
|
|
166
|
+
/** The card answer lock every renderer uses. */
|
|
167
|
+
declare function useBlockReply(client?: WaypointClient): BlockReply;
|
|
168
|
+
|
|
169
|
+
export { type ActionEntry as A, type BlockAnswer as B, type KeepSheet as K, type PlanView as P, type StepStatus as S, type Tone as T, type ViewMessage as V, type ActionId as a, type BlockReply as b, type KeepSheetView as c, type PlanViewAlert as d, type PlanViewInput as e, type PlanViewStatus as f, cardReplyId as g, usePlanView as h, useWaypointSnapshot as i, type BlockSummary as j, keepSheet as k, noticeMessages as n, planView as p, useBlockReply as u };
|