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 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.0",
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 that emits `ax.steer`
893
- enqueues a steer it does NOT wake the agent. With a prompt-injecting host
894
- adapter (e.g. Copilot), it reaches the next turn only when (1) the **pin/focus
895
- gate is open** (something pinned or focused so keep a steering board pinned, or
896
- have its button also emit `ax.focus.set` on itself), (2) a **human message** fires
897
- the turn, and (3) the agent **acts then acks** (`canvas_ax_delivery { action: "mark" }`),
898
- or the steer re-injects every gated turn. `GET /api/canvas/ax/context?consumer=<id>` adds
899
- a compact, loop-safe `delivery: { pendingSteering, pendingActivity }` lead block an
900
- adapter can inject un-truncated, so steering survives the full-context char clip.
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 `timeline.pendingSteering`) as hidden context — but only
56
- when the **pin/focus gate is open** (`pinned.count > 0 || focus.nodeIds.length > 0`),
57
- and it is clipped to a char budget. Three consequences the adapter/agent must honor:
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 then ack**
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 the compact loop-safe lead block from
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.pendingActivity`) **above** the full dump.
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
 
@@ -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: canvasState.getPendingSteering({ consumer, limit: AX_CONTEXT_STEERING_LIMIT }),
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
@@ -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
 
@@ -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
  }