pmx-canvas 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/dist/types/server/ax-state-manager.d.ts +11 -0
- package/dist/types/server/ax-state.d.ts +2 -0
- package/dist/types/server/canvas-db.d.ts +13 -0
- package/dist/types/server/canvas-state.d.ts +5 -0
- package/docs/ax-host-adapter-contract.md +19 -1
- package/docs/http-api.md +4 -0
- package/docs/mcp.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +31 -9
- package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +23 -7
- package/src/server/ax-context.ts +8 -1
- package/src/server/ax-state-manager.ts +18 -0
- package/src/server/ax-state.ts +8 -0
- package/src/server/canvas-db.ts +35 -0
- package/src/server/canvas-state.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ All notable changes to `pmx-canvas` are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.2.1] - 2026-06-17
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Compact AX context surfaces the NEWEST steering first (report #57).** The
|
|
13
|
+
`delivery.pendingSteering` lead block on `GET /api/canvas/ax/context` /
|
|
14
|
+
`canvas://ax-context` returned the OLDEST 10 undelivered steers, so on a long-lived
|
|
15
|
+
board a fresh steer was buried behind old unacked ones and never appeared in the
|
|
16
|
+
compact block. It now returns the **newest** undelivered steering first (capped at
|
|
17
|
+
10), so a fresh steer is always visible. The FIFO claim/ack delivery queue
|
|
18
|
+
(`/api/canvas/ax/delivery/pending`, `canvas_ax_delivery { action: "claim" }`) is
|
|
19
|
+
unchanged — still oldest-first for ordered processing.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **AX context delivery backlog counts (report #57).** `delivery` now includes
|
|
24
|
+
`totalPending` (undelivered steering for the consumer, loop-safe) and `omittedPending`
|
|
25
|
+
(`= totalPending − pendingSteering.length`), so an agent can tell the compact block
|
|
26
|
+
omitted a backlog and drain the full FIFO queue when `omittedPending > 0`. Additive,
|
|
27
|
+
non-breaking.
|
|
28
|
+
- **Blessed AX HTML Control Surface recipe (report #60).** New
|
|
29
|
+
`skills/pmx-canvas/references/ax-html-control-surface.md` (linked from `SKILL.md`): a
|
|
30
|
+
copy-paste-safe template (awaited `emit` + live ack display + `pmx-ax-update`
|
|
31
|
+
reflection) plus the three footguns that make a hand-rolled AX node look inert — the
|
|
32
|
+
sandboxed opaque-origin iframe throws on `localStorage`/`sessionStorage`/cookies,
|
|
33
|
+
`emit` must be awaited, and `ax.steer` is recorded (queued), not delivered.
|
|
34
|
+
|
|
35
|
+
### Docs
|
|
36
|
+
|
|
37
|
+
- **Canvas-origin steering does not wake the active agent by itself (report #59).** The
|
|
38
|
+
host-adapter contract, `SKILL.md`, and the GitHub Copilot adapter reference now state
|
|
39
|
+
plainly that a browser-origin `ax.steer` (and its `ok:true` emit ack) is *queued*, not
|
|
40
|
+
pushed into the live session; the wake is host-adapter-owned (drain
|
|
41
|
+
`canvas_ax_delivery { action: "claim" }` → native send → `mark`), and a steering button
|
|
42
|
+
must be labeled "queued for the agent's next turn." Adapters should read steering from
|
|
43
|
+
the compact `delivery.pendingSteering` block, not `timeline.pendingSteering`.
|
|
44
|
+
|
|
8
45
|
## [0.2.0] - 2026-06-16
|
|
9
46
|
|
|
10
47
|
### Breaking
|
|
@@ -2234,6 +2271,7 @@ otherwise have to discover by trial and error.
|
|
|
2234
2271
|
- Regression coverage for snapshot flat-`id` aliases on both MCP and
|
|
2235
2272
|
HTTP surfaces, plus async / top-level-`await` WebView script bodies.
|
|
2236
2273
|
|
|
2274
|
+
[0.2.1]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.2.1
|
|
2237
2275
|
[0.2.0]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.2.0
|
|
2238
2276
|
[0.1.36]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.36
|
|
2239
2277
|
[0.1.35]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.35
|
|
@@ -245,6 +245,17 @@ export declare class AxStateManager {
|
|
|
245
245
|
consumer?: string;
|
|
246
246
|
limit?: number;
|
|
247
247
|
}): PmxAxSteeringMessage[];
|
|
248
|
+
/**
|
|
249
|
+
* NEWEST undelivered steering first, for the compact AX context lead block (report
|
|
250
|
+
* #57) — so a fresh steer is visible even behind a long backlog. Loop-safe like
|
|
251
|
+
* getPendingSteering, but ordered DESC instead of the FIFO ASC delivery queue.
|
|
252
|
+
*/
|
|
253
|
+
getPendingSteeringForContext(options?: {
|
|
254
|
+
consumer?: string;
|
|
255
|
+
limit?: number;
|
|
256
|
+
}): PmxAxSteeringMessage[];
|
|
257
|
+
/** Total undelivered steering for a consumer (loop-safe), for the context backlog counts. */
|
|
258
|
+
getPendingSteeringCount(consumer?: string): number;
|
|
248
259
|
getAxTimelineSummary(): PmxAxTimelineSummary;
|
|
249
260
|
getAxTimeline(q?: AxTimelineQuery): {
|
|
250
261
|
events: PmxAxEvent[];
|
|
@@ -145,6 +145,8 @@ export interface PendingAxActivityItem {
|
|
|
145
145
|
}
|
|
146
146
|
export interface PmxAxDeliveryContext {
|
|
147
147
|
pendingSteering: PmxAxSteeringMessage[];
|
|
148
|
+
totalPending: number;
|
|
149
|
+
omittedPending: number;
|
|
148
150
|
pendingActivity: PendingAxActivityItem[];
|
|
149
151
|
}
|
|
150
152
|
export interface PmxAxContext {
|
|
@@ -53,6 +53,19 @@ export declare function loadPendingAxSteeringFromDB(db: Database, options?: {
|
|
|
53
53
|
consumer?: string;
|
|
54
54
|
limit?: number;
|
|
55
55
|
}): PmxAxSteeringMessage[];
|
|
56
|
+
/**
|
|
57
|
+
* NEWEST undelivered steering first (report #57) for the compact AX context lead
|
|
58
|
+
* block — so a fresh steer is visible even behind a long backlog. Loop-safe: excludes
|
|
59
|
+
* the consumer's own steering in SQL so the LIMIT applies after loop-prevention.
|
|
60
|
+
* Distinct from loadPendingAxSteeringFromDB (FIFO oldest-first) which the claim/ack
|
|
61
|
+
* delivery queue uses for ordered processing.
|
|
62
|
+
*/
|
|
63
|
+
export declare function loadNewestPendingAxSteeringFromDB(db: Database, options?: {
|
|
64
|
+
consumer?: string;
|
|
65
|
+
limit?: number;
|
|
66
|
+
}): PmxAxSteeringMessage[];
|
|
67
|
+
/** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own). */
|
|
68
|
+
export declare function countPendingAxSteeringFromDB(db: Database, consumer?: string): number;
|
|
56
69
|
export declare function loadAxTimelineSummaryFromDB(db: Database): PmxAxTimelineSummary;
|
|
57
70
|
export declare function upsertAxHostCapabilityToDB(db: Database, cap: PmxAxHostCapability): void;
|
|
58
71
|
export declare function loadAxHostCapabilityFromDB(db: Database): PmxAxHostCapability | null;
|
|
@@ -431,6 +431,11 @@ declare class CanvasStateManager {
|
|
|
431
431
|
consumer?: string;
|
|
432
432
|
limit?: number;
|
|
433
433
|
}): PmxAxSteeringMessage[];
|
|
434
|
+
getPendingSteeringForContext(options?: {
|
|
435
|
+
consumer?: string;
|
|
436
|
+
limit?: number;
|
|
437
|
+
}): PmxAxSteeringMessage[];
|
|
438
|
+
getPendingSteeringCount(consumer?: string): number;
|
|
434
439
|
getAxTimelineSummary(): PmxAxTimelineSummary;
|
|
435
440
|
getAxTimeline(q?: AxTimelineQuery): {
|
|
436
441
|
events: PmxAxEvent[];
|
|
@@ -42,7 +42,25 @@ message; it does **not** wake the agent. It reaches the next turn only when:
|
|
|
42
42
|
|
|
43
43
|
The `delivery` lead block (`GET /api/canvas/ax/context?consumer=<id>`) is the
|
|
44
44
|
robustness hedge: it's compact and sits above the full dump, so an adapter can inject
|
|
45
|
-
it un-truncated even on a busy board where the full context is clipped.
|
|
45
|
+
it un-truncated even on a busy board where the full context is clipped. Its
|
|
46
|
+
`pendingSteering` is **newest-first** (most recent at index 0), capped at 10, so a
|
|
47
|
+
*fresh* steer is always visible even behind a long backlog of old unacked steers
|
|
48
|
+
(report #57); `delivery.totalPending` / `delivery.omittedPending` tell the agent how
|
|
49
|
+
many more are queued so it can drain the FIFO `…/delivery/pending` endpoint when the
|
|
50
|
+
count is non-zero. **Adapters should read `delivery.pendingSteering`** (this compact,
|
|
51
|
+
count-bearing block), not `timeline.pendingSteering`.
|
|
52
|
+
|
|
53
|
+
### Canvas-origin steering does not wake the agent by itself (#59)
|
|
54
|
+
|
|
55
|
+
Recording a browser-origin `ax.steer` (and the `ok:true` ack a surface button gets —
|
|
56
|
+
report #55) means the steer is **queued on the timeline**, not delivered into a live
|
|
57
|
+
agent turn. PMX deliberately does not import a host SDK, so the *wake* — turning a
|
|
58
|
+
queued steer into a visible turn — is **adapter-owned**: a cooperating host adapter
|
|
59
|
+
must drain `…/delivery/pending?consumer=<id>` and call its native send (e.g.
|
|
60
|
+
`copilotSession.send`), then `…/delivery/<id>/mark` it. Until an adapter wires that,
|
|
61
|
+
canvas-origin steering is delivered on the next human turn, not pushed. A steering
|
|
62
|
+
surface should therefore label its button honestly ("queued for the agent's next
|
|
63
|
+
turn"), never imply it interrupts the agent now.
|
|
46
64
|
|
|
47
65
|
## The two primitives that close the loop
|
|
48
66
|
|
package/docs/http-api.md
CHANGED
|
@@ -246,6 +246,10 @@ curl "http://localhost:4313/api/canvas/ax/mode/<id>?waitMs=30000"
|
|
|
246
246
|
|
|
247
247
|
# Context — optional ?consumer= filters the compact, loop-safe `delivery` lead block
|
|
248
248
|
# (undelivered steering + open work/approvals it can act on) for per-turn injection.
|
|
249
|
+
# `delivery.pendingSteering` is NEWEST-first (most recent first), capped at 10, so a
|
|
250
|
+
# fresh steer is visible even behind a backlog; `delivery.totalPending` /
|
|
251
|
+
# `delivery.omittedPending` report how many more are queued. Drain the full FIFO
|
|
252
|
+
# (oldest-first) backlog via /api/canvas/ax/delivery/pending when omittedPending > 0.
|
|
249
253
|
curl "http://localhost:4313/api/canvas/ax/context?consumer=copilot"
|
|
250
254
|
|
|
251
255
|
# Commands — list the registry, invoke a command (records a `command` agent-event)
|
package/docs/mcp.md
CHANGED
|
@@ -157,7 +157,7 @@ Individual bundled skills are also readable at `canvas://skills/<name>`.
|
|
|
157
157
|
|----------|-------------|
|
|
158
158
|
| `canvas://pinned-context` | Content of pinned nodes + nearby unpinned neighbors |
|
|
159
159
|
| `canvas://ax` | PMX AX state: focus, work items, approval gates, review annotations |
|
|
160
|
-
| `canvas://ax-context` | Agent-readable pinned and focused AX context, plus timeline summary and host capability |
|
|
160
|
+
| `canvas://ax-context` | Agent-readable pinned and focused AX context, plus a compact `delivery` lead block (`pendingSteering` newest-first + `totalPending`/`omittedPending` counts), timeline summary, and host capability |
|
|
161
161
|
| `canvas://ax-work` | Canvas-bound AX work: work items, approval gates, review annotations, elicitations, mode requests, and tool/prompt policy |
|
|
162
162
|
| `canvas://ax-timeline` | Bounded AX timeline: recent agent-events, evidence, and steering messages |
|
|
163
163
|
| `canvas://ax-pending-steering` | Undelivered steering an adapterless MCP client can claim, act on, and mark delivered |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
|
@@ -889,15 +889,23 @@ nodes.
|
|
|
889
889
|
live. Clients that **poll** instead should poll `canvas_ax_delivery { action: "claim" }` —
|
|
890
890
|
`pendingActivity` is how non-steering browser changes reach them. Only steering
|
|
891
891
|
flows through the claim/ack queue.
|
|
892
|
-
- **Steering is gated, not pushed.** A surface button
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
892
|
+
- **Steering is gated, not pushed (and does NOT wake the agent).** A surface button
|
|
893
|
+
that emits `ax.steer` *records/queues* a steer (the `ok:true` emit ack means
|
|
894
|
+
"recorded", not "the agent woke") — it does NOT interrupt or notify the active
|
|
895
|
+
session. With a prompt-injecting host adapter (e.g. Copilot), it reaches the next
|
|
896
|
+
turn only when (1) the **pin/focus gate is open** (something pinned or focused — so
|
|
897
|
+
keep a steering board pinned, or have its button also emit `ax.focus.set` on
|
|
898
|
+
itself), (2) a **human message** fires the turn, and (3) the agent **acts then acks**
|
|
899
|
+
(`canvas_ax_delivery { action: "mark" }`), or the steer re-injects every gated turn.
|
|
900
|
+
Immediate wake (turning a queued steer into a visible turn) is **host-adapter-owned**
|
|
901
|
+
— the adapter must drain `canvas_ax_delivery { action: "claim" }` and call its native
|
|
902
|
+
send. So a steering button should label itself honestly ("queued for the agent's next
|
|
903
|
+
turn"), never imply it steers the agent *now*. `GET /api/canvas/ax/context?consumer=<id>`
|
|
904
|
+
adds a compact, loop-safe `delivery: { pendingSteering, totalPending, omittedPending,
|
|
905
|
+
pendingActivity }` lead block an adapter injects un-truncated; `pendingSteering` is
|
|
906
|
+
**newest-first** (most recent first), capped at 10, so a fresh steer is visible even
|
|
907
|
+
behind a backlog, and the counts say how many more to drain from the FIFO
|
|
908
|
+
`canvas_ax_delivery { action: "claim" }` queue (which stays oldest-first).
|
|
901
909
|
- **Activity ingestion (bidirectional board):** a host adapter forwards the agent's
|
|
902
910
|
tool/session events with `canvas_ingest_activity` (standalone; HTTP `POST /api/canvas/ax/activity`)
|
|
903
911
|
and the board auto-reacts — `failure`/`error` (or `outcome:"failure"`) → a blocked
|
|
@@ -1028,6 +1036,20 @@ This is the right home for a deliberate, interactive AX experience — not the
|
|
|
1028
1036
|
native node buttons. Any agent (via MCP/SDK) can also create/update the same work
|
|
1029
1037
|
items, and the board reflects them live.
|
|
1030
1038
|
|
|
1039
|
+
> **Authoring an AX HTML node? Use the blessed, copy-paste-safe recipe in
|
|
1040
|
+
> [`references/ax-html-control-surface.md`](references/ax-html-control-surface.md)**
|
|
1041
|
+
> and avoid the common footguns:
|
|
1042
|
+
> - **The iframe is sandboxed opaque-origin** (no `allow-same-origin`): `localStorage`,
|
|
1043
|
+
> `sessionStorage`, and cookies **throw** and will halt your startup script, making the
|
|
1044
|
+
> node look inert. Keep state in plain JS variables / `window.PMX_AX.state`, or wrap any
|
|
1045
|
+
> storage access in `try/catch`.
|
|
1046
|
+
> - **`window.PMX_AX.emit` is async — `await` it** (or use `.then`/`window.PMX_AX.on('ack')`);
|
|
1047
|
+
> don't read a result synchronously. It's injected only when the node is opted in
|
|
1048
|
+
> (`axCapabilities.enabled = true`).
|
|
1049
|
+
> - **`ax.steer` is recorded, not delivered** — a successful emit means the steer is
|
|
1050
|
+
> *queued for the agent's next turn*, not that the agent woke (see "Steering is gated").
|
|
1051
|
+
> Label steering buttons accordingly.
|
|
1052
|
+
|
|
1031
1053
|
> Security note: an AX-enabled surface can READ the whole canvas AX board (all
|
|
1032
1054
|
> work items, focus, approval gates, etc. — human review comment text is redacted),
|
|
1033
1055
|
> while its EMITS are clamped to its own node. Under the single-workspace
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# AX HTML Control Surface — a blessed, copy-paste-safe recipe
|
|
2
|
+
|
|
3
|
+
A canvas `html` node can be a live **AX control surface**: it emits AX interactions
|
|
4
|
+
(create work, request approval, steer the agent, …) and reflects the current AX board
|
|
5
|
+
back, all from inside the sandboxed iframe. The bridge is already injected for you —
|
|
6
|
+
you do **not** need to read `axToken` from the URL or hand-post `pmx-canvas-ax`
|
|
7
|
+
messages. Just opt the node in and use `window.PMX_AX`.
|
|
8
|
+
|
|
9
|
+
## Opt in
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
// MCP / SDK
|
|
13
|
+
canvas_add_html_node({
|
|
14
|
+
title: "AX Control Room",
|
|
15
|
+
html: "<!-- see below -->",
|
|
16
|
+
axCapabilities: { enabled: true, allowed: ["ax.work.create", "ax.steer"] },
|
|
17
|
+
});
|
|
18
|
+
// HTTP: POST /api/canvas/node { type:"html", title, html, axCapabilities } (top-level html + axCapabilities both accepted)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Without `axCapabilities.enabled = true`, `window.PMX_AX` is **not** injected — the node
|
|
22
|
+
renders but can't emit. `allowed` narrows what it may emit (never escalates the type's
|
|
23
|
+
ceiling). Flip an existing node on with `canvas_node({ action: "update", id, axCapabilities: { enabled: true, allowed: [...] } })`.
|
|
24
|
+
|
|
25
|
+
## Three footguns (this is why a hand-rolled node looks "inert")
|
|
26
|
+
|
|
27
|
+
1. **The iframe is sandboxed opaque-origin** (no `allow-same-origin`). `localStorage`,
|
|
28
|
+
`sessionStorage`, and `document.cookie` **throw** — and an uncaught throw at script
|
|
29
|
+
start aborts the whole script, so the node renders blank/inert. Keep state in plain
|
|
30
|
+
JS variables (or `window.PMX_AX.state`); if you must touch storage, wrap it in
|
|
31
|
+
`try/catch`.
|
|
32
|
+
2. **`window.PMX_AX.emit(type, payload)` is async.** It returns a Promise that resolves
|
|
33
|
+
with the result once the canvas acks it. `await` it (or `.then` / `window.PMX_AX.on('ack', cb)`).
|
|
34
|
+
Reading a result synchronously gets you `undefined`.
|
|
35
|
+
3. **`ax.steer` is recorded, not delivered.** A successful emit (`{ ok: true }`) means
|
|
36
|
+
the steer was **queued** on the timeline — it does **not** wake or notify the active
|
|
37
|
+
agent. A cooperating host adapter must drain the delivery queue and call its native
|
|
38
|
+
send to create a visible turn (host-owned). Otherwise the steer is picked up on the
|
|
39
|
+
human's next turn. Label steering buttons honestly ("Queued for the agent's next turn").
|
|
40
|
+
|
|
41
|
+
## Drop-in template (work + steer, with ack + live reflection)
|
|
42
|
+
|
|
43
|
+
```html
|
|
44
|
+
<style>
|
|
45
|
+
body { font: 13px system-ui; margin: 0; padding: 12px; }
|
|
46
|
+
button { cursor: pointer; }
|
|
47
|
+
#s { margin-left: 8px; color: #6b7280; }
|
|
48
|
+
ul { padding-left: 18px; }
|
|
49
|
+
</style>
|
|
50
|
+
<button id="add">+ Work item</button>
|
|
51
|
+
<button id="steer">Steer agent</button>
|
|
52
|
+
<span id="s"></span>
|
|
53
|
+
<ul id="q"></ul>
|
|
54
|
+
<script>
|
|
55
|
+
// In-memory only — NO localStorage/sessionStorage/cookies (sandboxed: they throw).
|
|
56
|
+
const $ = (id) => document.getElementById(id);
|
|
57
|
+
const flash = (msg) => { $('s').textContent = msg; setTimeout(() => { $('s').textContent = ''; }, 1500); };
|
|
58
|
+
|
|
59
|
+
function render(state) {
|
|
60
|
+
const items = (state && state.workItems) || [];
|
|
61
|
+
$('q').innerHTML = items.map((w) => '<li>[' + w.status + '] ' + w.title + '</li>').join('');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
$('add').onclick = async () => {
|
|
65
|
+
const r = await window.PMX_AX.emit('ax.work.create', { title: 'New task' });
|
|
66
|
+
flash(r && r.ok ? 'queued ✓' : ('failed: ' + (r && (r.error || r.code))));
|
|
67
|
+
};
|
|
68
|
+
$('steer').onclick = async () => {
|
|
69
|
+
const r = await window.PMX_AX.emit('ax.steer', { message: 'Prioritize the auth refactor' });
|
|
70
|
+
// Honest: recorded/queued, NOT delivered to the live agent.
|
|
71
|
+
flash(r && r.ok ? 'queued for next turn ✓' : 'failed');
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Reflect: seeded once at load, then live via the pmx-ax-update event.
|
|
75
|
+
render(window.PMX_AX && window.PMX_AX.state);
|
|
76
|
+
window.addEventListener('pmx-ax-update', (e) => render(e.detail));
|
|
77
|
+
</script>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API surface (injected by PMX when opted in)
|
|
81
|
+
|
|
82
|
+
- `window.PMX_AX.emit(type, payload) → Promise<{ ok, primitive?, status?, code?, error? }>`
|
|
83
|
+
— `ok:true` on accept; `ok:false` with `code`/`error` on reject; falls back to an
|
|
84
|
+
`ax-ack-timeout` result after 10s so `await` never hangs.
|
|
85
|
+
- `window.PMX_AX.on('ack', (result, interaction) => …)` — also fires a `pmx-ax-ack`
|
|
86
|
+
CustomEvent; use instead of `await` if you prefer a listener.
|
|
87
|
+
- `window.PMX_AX.state` — compact board snapshot `{ focus, workItems, approvalGates,
|
|
88
|
+
reviewAnnotations, elicitations, modeRequests, policy }` (human review text redacted).
|
|
89
|
+
- `pmx-ax-update` window event — fires with the fresh snapshot on every AX change.
|
|
90
|
+
|
|
91
|
+
Allowed `type`s are gated per node capability (see the node-capability matrix in
|
|
92
|
+
`SKILL.md`). Emits are clamped to the surface's own node; the server re-validates every
|
|
93
|
+
interaction — the bridge is convenience, not a trust boundary.
|
|
@@ -52,20 +52,36 @@ panel.
|
|
|
52
52
|
### Agent behavior — steering is gated, not pushed
|
|
53
53
|
|
|
54
54
|
`onUserPromptSubmitted` injects the whole `/api/canvas/ax/context` (pins, focus, work
|
|
55
|
-
items, approval gates, and `
|
|
56
|
-
when the **pin/focus gate is open** (`pinned.count > 0 || focus.nodeIds.length > 0`),
|
|
57
|
-
and it is clipped to a char budget.
|
|
55
|
+
items, approval gates, and the compact `delivery` lead block) as hidden context — but
|
|
56
|
+
only when the **pin/focus gate is open** (`pinned.count > 0 || focus.nodeIds.length > 0`),
|
|
57
|
+
and it is clipped to a char budget. Read steering from **`delivery.pendingSteering`**
|
|
58
|
+
(the compact, count-bearing block — newest-first, capped at 10), not the full
|
|
59
|
+
`timeline.pendingSteering`. Three consequences the adapter/agent must honor:
|
|
58
60
|
|
|
59
61
|
1. A steering board must **stay pinned** (or its button must also emit `ax.focus.set`
|
|
60
62
|
on the board node) to hold the gate open.
|
|
61
63
|
2. A sandbox button click does **not** wake a turn — a human message does. The click
|
|
62
64
|
only enqueues the steer.
|
|
63
|
-
3. The agent must **act on injected `pendingSteering` / `pendingActivity` and
|
|
64
|
-
(`canvas_ax_delivery { action: "mark" }`), or it re-injects every gated turn.
|
|
65
|
+
3. The agent must **act on injected `delivery.pendingSteering` / `pendingActivity` and
|
|
66
|
+
then ack** (`canvas_ax_delivery { action: "mark" }`), or it re-injects every gated turn.
|
|
65
67
|
|
|
66
|
-
To be robust to the char clip, prefer injecting
|
|
68
|
+
To be robust to the char clip, prefer injecting that compact loop-safe lead block from
|
|
67
69
|
`GET /api/canvas/ax/context?consumer=copilot` (`delivery.pendingSteering` +
|
|
68
|
-
`delivery.
|
|
70
|
+
`delivery.totalPending` / `delivery.omittedPending` + `delivery.pendingActivity`)
|
|
71
|
+
**above** the full dump. When `omittedPending > 0`, drain the full FIFO backlog from
|
|
72
|
+
`canvas_ax_delivery { action: "claim", consumer: "copilot" }` (oldest-first).
|
|
73
|
+
|
|
74
|
+
#### Waking the agent from a canvas steer (#59) — adapter-owned
|
|
75
|
+
|
|
76
|
+
Recording a browser-origin `ax.steer` does **not** wake the active session by itself
|
|
77
|
+
(report #59); PMX only queues it (the `ok:true` emit ack = "recorded", not "delivered").
|
|
78
|
+
To make a canvas **Steer** button actually create a visible turn, the adapter must, on
|
|
79
|
+
its own cadence (e.g. an SSE subscription or poll), **drain**
|
|
80
|
+
`canvas_ax_delivery { action: "claim", consumer: "copilot" }`, call the host's native
|
|
81
|
+
send (`copilotSession.send` / the working `send_instruction` path) with each steer, then
|
|
82
|
+
`canvas_ax_delivery { action: "mark" }` it (loop-safe). This wake is intentionally
|
|
83
|
+
host-owned — PMX never imports the host SDK. Until the adapter wires it, a steering
|
|
84
|
+
button must be labeled "queued for the agent's next turn", not "steer now".
|
|
69
85
|
|
|
70
86
|
### Closing the loop (optional, recommended)
|
|
71
87
|
|
package/src/server/ax-context.ts
CHANGED
|
@@ -79,10 +79,17 @@ export function buildCanvasAxContext(consumer?: string): PmxAxContext {
|
|
|
79
79
|
const focusNodes = ax.focus.nodeIds
|
|
80
80
|
.map((id) => canvasState.getNode(id))
|
|
81
81
|
.filter((node): node is CanvasNodeState => node !== undefined);
|
|
82
|
+
// Report #57: surface the NEWEST undelivered steering (so a fresh steer is visible
|
|
83
|
+
// even behind a long backlog) + counts so the agent can detect an omitted backlog.
|
|
84
|
+
// The FIFO claim/ack queue (getPendingSteering) stays oldest-first for processing.
|
|
85
|
+
const pendingSteering = canvasState.getPendingSteeringForContext({ consumer, limit: AX_CONTEXT_STEERING_LIMIT });
|
|
86
|
+
const totalPending = canvasState.getPendingSteeringCount(consumer);
|
|
82
87
|
return buildAxContext({
|
|
83
88
|
layout,
|
|
84
89
|
delivery: {
|
|
85
|
-
pendingSteering
|
|
90
|
+
pendingSteering,
|
|
91
|
+
totalPending,
|
|
92
|
+
omittedPending: Math.max(0, totalPending - pendingSteering.length),
|
|
86
93
|
pendingActivity: buildPendingAxActivity(ax, consumer),
|
|
87
94
|
},
|
|
88
95
|
pinned: buildCanvasAxPinnedContext(),
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
loadAxEvidenceFromDB,
|
|
30
30
|
loadAxSteeringFromDB,
|
|
31
31
|
loadPendingAxSteeringFromDB,
|
|
32
|
+
loadNewestPendingAxSteeringFromDB,
|
|
33
|
+
countPendingAxSteeringFromDB,
|
|
32
34
|
loadAxTimelineSummaryFromDB,
|
|
33
35
|
upsertAxHostCapabilityToDB,
|
|
34
36
|
loadAxHostCapabilityFromDB,
|
|
@@ -790,6 +792,22 @@ export class AxStateManager {
|
|
|
790
792
|
return db ? loadPendingAxSteeringFromDB(db, options) : [];
|
|
791
793
|
}
|
|
792
794
|
|
|
795
|
+
/**
|
|
796
|
+
* NEWEST undelivered steering first, for the compact AX context lead block (report
|
|
797
|
+
* #57) — so a fresh steer is visible even behind a long backlog. Loop-safe like
|
|
798
|
+
* getPendingSteering, but ordered DESC instead of the FIFO ASC delivery queue.
|
|
799
|
+
*/
|
|
800
|
+
getPendingSteeringForContext(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
|
|
801
|
+
const db = this.deps.getDb();
|
|
802
|
+
return db ? loadNewestPendingAxSteeringFromDB(db, options) : [];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Total undelivered steering for a consumer (loop-safe), for the context backlog counts. */
|
|
806
|
+
getPendingSteeringCount(consumer?: string): number {
|
|
807
|
+
const db = this.deps.getDb();
|
|
808
|
+
return db ? countPendingAxSteeringFromDB(db, consumer) : 0;
|
|
809
|
+
}
|
|
810
|
+
|
|
793
811
|
getAxTimelineSummary(): PmxAxTimelineSummary {
|
|
794
812
|
const db = this.deps.getDb();
|
|
795
813
|
return db
|
package/src/server/ax-state.ts
CHANGED
|
@@ -168,8 +168,16 @@ export interface PendingAxActivityItem {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
// ── Delivery lead block (compact, un-truncated; for per-turn injection) ──
|
|
171
|
+
// `pendingSteering` here is NEWEST-first (most recent at index 0), capped at
|
|
172
|
+
// AX_CONTEXT_STEERING_LIMIT, so a fresh steer is always visible even behind a long
|
|
173
|
+
// backlog (report #57). This is "what's new?" awareness — distinct from the FIFO
|
|
174
|
+
// claim/ack delivery queue (`/api/canvas/ax/delivery/pending`, getPendingSteering),
|
|
175
|
+
// which stays OLDEST-first for ordered processing. The counts let an agent detect a
|
|
176
|
+
// backlog the compact block omits.
|
|
171
177
|
export interface PmxAxDeliveryContext {
|
|
172
178
|
pendingSteering: PmxAxSteeringMessage[];
|
|
179
|
+
totalPending: number;
|
|
180
|
+
omittedPending: number;
|
|
173
181
|
pendingActivity: PendingAxActivityItem[];
|
|
174
182
|
}
|
|
175
183
|
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -921,6 +921,41 @@ export function loadPendingAxSteeringFromDB(
|
|
|
921
921
|
.filter((s): s is PmxAxSteeringMessage => s !== null);
|
|
922
922
|
}
|
|
923
923
|
|
|
924
|
+
/**
|
|
925
|
+
* NEWEST undelivered steering first (report #57) for the compact AX context lead
|
|
926
|
+
* block — so a fresh steer is visible even behind a long backlog. Loop-safe: excludes
|
|
927
|
+
* the consumer's own steering in SQL so the LIMIT applies after loop-prevention.
|
|
928
|
+
* Distinct from loadPendingAxSteeringFromDB (FIFO oldest-first) which the claim/ack
|
|
929
|
+
* delivery queue uses for ordered processing.
|
|
930
|
+
*/
|
|
931
|
+
export function loadNewestPendingAxSteeringFromDB(
|
|
932
|
+
db: Database,
|
|
933
|
+
options: { consumer?: string; limit?: number } = {},
|
|
934
|
+
): PmxAxSteeringMessage[] {
|
|
935
|
+
interface Row { seq: number; id: string; message: string; delivered: number; created_at: string; source: string | null }
|
|
936
|
+
const limit = clampTimelineLimit(options.limit);
|
|
937
|
+
const rows = options.consumer
|
|
938
|
+
? db.query<Row, [string, number]>(
|
|
939
|
+
'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) ORDER BY seq DESC LIMIT ?',
|
|
940
|
+
).all(options.consumer, limit)
|
|
941
|
+
: db.query<Row, [number]>(
|
|
942
|
+
'SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?',
|
|
943
|
+
).all(limit);
|
|
944
|
+
return rows
|
|
945
|
+
.map((r) => normalizeAxSteeringMessage({ ...r, createdAt: r.created_at, delivered: r.delivered === 1 }))
|
|
946
|
+
.filter((s): s is PmxAxSteeringMessage => s !== null);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own). */
|
|
950
|
+
export function countPendingAxSteeringFromDB(db: Database, consumer?: string): number {
|
|
951
|
+
const n = consumer
|
|
952
|
+
? db.query<{ n: number }, [string]>(
|
|
953
|
+
'SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?)',
|
|
954
|
+
).get(consumer)?.n
|
|
955
|
+
: db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0').get()?.n;
|
|
956
|
+
return Number(n ?? 0);
|
|
957
|
+
}
|
|
958
|
+
|
|
924
959
|
function countRows(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_steering'): number {
|
|
925
960
|
return Number(db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n ?? 0);
|
|
926
961
|
}
|
|
@@ -1957,6 +1957,14 @@ class CanvasStateManager {
|
|
|
1957
1957
|
return this.ax.getPendingSteering(options);
|
|
1958
1958
|
}
|
|
1959
1959
|
|
|
1960
|
+
getPendingSteeringForContext(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
|
|
1961
|
+
return this.ax.getPendingSteeringForContext(options);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
getPendingSteeringCount(consumer?: string): number {
|
|
1965
|
+
return this.ax.getPendingSteeringCount(consumer);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1960
1968
|
getAxTimelineSummary(): PmxAxTimelineSummary {
|
|
1961
1969
|
return this.ax.getAxTimelineSummary();
|
|
1962
1970
|
}
|