pmx-canvas 0.9.0 → 0.9.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.
@@ -22,6 +22,7 @@ export declare function markYielded(nodeId: string, name: string): void;
22
22
  * node wears a "took over — agent yielded" pill for a moment.
23
23
  */
24
24
  export declare function takeOverNode(nodeId: string, title: string): void;
25
+ export declare function stopHumanPresence(): void;
25
26
  /** Announce this tab and keep it alive; renews a held grab. Call once per connection. */
26
- export declare function startHumanPresence(): () => void;
27
+ export declare function startHumanPresence(): void;
27
28
  export declare function resetHumanPresence(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.9.0",
3
+ "version": "0.9.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",
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.9.0"
2
+ "version": "0.9.1"
3
3
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "pmx-canvas",
4
4
  "description": "PMX Canvas spatial workbench and MCP tools, with a native canvas panel in the GitHub Copilot app.",
5
- "version": "0.9.0",
5
+ "version": "0.9.1",
6
6
  "author": {
7
7
  "name": "pskoett",
8
8
  "url": "https://github.com/pskoett"
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.9.0"
2
+ "version": "0.9.1"
3
3
  }
@@ -106,7 +106,7 @@ for the authoritative stop, viewport, persistence, reset, group-order, and captu
106
106
 
107
107
  ## Runtime prerequisites
108
108
 
109
- PMX Canvas 0.9.0 requires **Bun >=1.4.2**, including when installed through npm.
109
+ PMX Canvas 0.9.1 requires **Bun >=1.4.2**, including when installed through npm.
110
110
  Check `bun --version` and ensure Bun is on the MCP host's PATH, not just your shell's;
111
111
  use an absolute executable path if needed. Install/configure only when requested.
112
112
  For pinned installation, MCP configuration, managed services, and disposable verification,
@@ -834,6 +834,230 @@
834
834
  "type": "output_check"
835
835
  }
836
836
  ]
837
+ },
838
+ {
839
+ "id": 36,
840
+ "name": "folder-cli-without-removed-category",
841
+ "prompt": "In a verified disposable workspace, use the CLI to create Release notes under Engineering/Canvas, move it to Engineering/Release, then unfile it without opening it. An old script uses board create --category Engineering/Canvas. Check that obsolete command as a negative control; do not silently accept it or create a knowledge category instead. Leave the active board and its cards alone.",
842
+ "expected_output": "Executes board create \"Release notes\" --folder Engineering/Canvas, captures the returned board ID, uses board folder <id> Engineering/Release and board folder <id> --clear. Reads the library after each step to verify folder and stable identity/content. In the disposable target, --category exits nonzero with the removal diagnostic and creates no board. No board open or category-set substitute occurs; activeBoardId and the original board layout remain unchanged.",
843
+ "assertions": [
844
+ {
845
+ "name": "uses-current-folder-commands",
846
+ "description": "CLI trace uses --folder on create, the returned ID with positional board folder, and --clear to unfile; successful command results are required, not merely suggested commands",
847
+ "type": "output_check"
848
+ },
849
+ {
850
+ "name": "verifies-folder-and-identity",
851
+ "description": "Library reads show Engineering/Canvas, then Engineering/Release, then an empty folder on the same board ID without losing its content",
852
+ "type": "output_check"
853
+ },
854
+ {
855
+ "name": "rejects-obsolete-category-control",
856
+ "description": "The isolated --category control exits nonzero with 'The --category flag was removed.' and leaves the library count unchanged; no category-set is used to simulate filing",
857
+ "type": "output_check"
858
+ },
859
+ {
860
+ "name": "does-not-navigate-to-file",
861
+ "description": "Before/after reads show unchanged activeBoardId and active layout; the trace contains no board open to perform filing",
862
+ "type": "output_check"
863
+ }
864
+ ]
865
+ },
866
+ {
867
+ "id": 37,
868
+ "name": "whole-library-map-versus-full-content-delivery",
869
+ "prompt": "On a disposable library, board A is active and inactive board B has pinned Markdown cards Target and Unread control. Target's decisive sentence is beyond character 280. Show the whole-board connection map, then use the actual Target text to answer its question without opening B. Use one stable consumer. After a human edits Target, refresh just that card. Do map summaries prove you read both cards in full?",
870
+ "expected_output": "Reads a whole-library graph with projection boards and omitted focus for orientation, then resolves B/Target and explicitly gets its full content with canvas_node {action:get, id, board:B, full:true}. Uses the sentence beyond the summary in its answer. Captures read status before and after graph/full deliveries, checks Target's delivered content revision, then fully rereads Target after the edit. Does not mark Unread control fully read from a graph/compact summary or confuse complete link delivery with full-content delivery. Active A remains unchanged.",
871
+ "assertions": [
872
+ {
873
+ "name": "requests-board-map-not-content-dump",
874
+ "description": "Trace requests canvas_query graph with projection boards and no focus (or equivalent memory-graph API), and observes board-qualified summaryOnly results rather than using a full layout as the map",
875
+ "type": "output_check"
876
+ },
877
+ {
878
+ "name": "fetches-and-uses-full-qualified-target",
879
+ "description": "Trace gets the resolved B/Target with full:true (or equivalent tracked full-node API); the answer uses its seeded sentence beyond 280 characters and read-status evidence records that delivered revision",
880
+ "type": "output_check"
881
+ },
882
+ {
883
+ "name": "refreshes-only-observed-stale-content",
884
+ "description": "After the human edit, a new full Target delivery advances its recorded read revision; status deltas show the unread control received no full-content read from map/compact discovery alone",
885
+ "type": "output_check"
886
+ },
887
+ {
888
+ "name": "preserves-active-board-and-read-boundaries",
889
+ "description": "Before/after activeBoardId and A layout match, no open/write occurs, and full-read claims are backed by deliveries rather than summaryOnly or link-seen state",
890
+ "type": "output_check"
891
+ }
892
+ ]
893
+ },
894
+ {
895
+ "id": 38,
896
+ "name": "durable-background-create-replay",
897
+ "prompt": "In an isolated workspace, keep A active while populating inactive B with two static Markdown cards, a group containing them, and a references edge. Use durable request IDs per creation and batch named references. Simulate a lost response after a committed prefix, retry, and restart the disposable daemon before retrying again. With the harness, edit one created card and delete the other on B, return to A, then replay the original requests. Do not recreate deleted work or undo later edits.",
898
+ "expected_output": "Uses destinationBoardId:B and stable per-operation requestId inside each batch operation's args, resolving group/edge named references. Replays identical inputs after partial completion and restart and compares original receipts/IDs. Later edit/deletion controls are harness-owned, not unsupported background CRUD. Replay returns original receipts without duplicating, overwriting or resurrecting objects. Qualified reads show B's intended surviving state and unchanged A activeBoardId/layout/viewport throughout agent background writes.",
899
+ "assertions": [
900
+ {
901
+ "name": "targets-each-static-creation",
902
+ "description": "Batch trace puts destinationBoardId and distinct logical requestId in node/group/edge args, uses valid named references, and verifies group membership and qualified edge endpoints on B",
903
+ "type": "output_check"
904
+ },
905
+ {
906
+ "name": "replays-prefix-and-restart-durably",
907
+ "description": "Evidence includes a committed prefix with lost response, identical retry after it and after a real isolated daemon restart, stable returned IDs/receipts and no duplicate nodes/groups/edges",
908
+ "type": "output_check"
909
+ },
910
+ {
911
+ "name": "replay-is-not-reapplication",
912
+ "description": "After harness edit/deletion and original-input replay, qualified reads retain the later edit and deleted-card absence even though original creation receipts are returned",
913
+ "type": "output_check"
914
+ },
915
+ {
916
+ "name": "keeps-background-work-off-active-board",
917
+ "description": "Before/after reads around agent creation/replays show A stays active with identical layout/viewport; no agent board open, active update/remove, runtime creation or background intent is used",
918
+ "type": "output_check"
919
+ }
920
+ ]
921
+ },
922
+ {
923
+ "id": 39,
924
+ "name": "background-create-conflicts-and-stale-active-guard",
925
+ "prompt": "In a disposable workspace with A active and B inactive, create a static note on B with requestId logical-note and expectedBoardId A. Test reusing logical-note with changed content and with destination A. Then have the harness switch the active board to C and send a new creation for B using stale expectedBoardId A. Do not force it through. Finally create a genuinely separate identical note with a fresh request ID after checking C. Also test a background file-node creation as an unsupported control.",
926
+ "expected_output": "Observes conflicts on changed-input and changed-destination reuse without mutating either destination. Stale expectedBoardId guards the actual active board, not B: the new request is refused after the switch to C. Rereads active identity before using expectedBoardId C with a fresh logical ID for the intentional duplicate. Unsupported background file creation fails without side effects. No clearing guards, board switching or blind retries to defeat rejection; C stays active.",
927
+ "assertions": [
928
+ {
929
+ "name": "changed-request-conflicts-without-writes",
930
+ "description": "Actual changed-content and changed-destination requests using logical-note fail with conflicts, and destination reads prove original content/counts are unchanged",
931
+ "type": "output_check"
932
+ },
933
+ {
934
+ "name": "stale-guard-requires-reread",
935
+ "description": "After the harness switch, expectedBoardId A is refused with no creation on B; trace rereads C before any fresh write and does not remove the guard or substitute B as the active identity",
936
+ "type": "output_check"
937
+ },
938
+ {
939
+ "name": "new-operation-gets-new-identity",
940
+ "description": "The intentional identical creation uses a new requestId and expectedBoardId C, returns a distinct node ID on B and leaves C active with unchanged layout",
941
+ "type": "output_check"
942
+ },
943
+ {
944
+ "name": "unsupported-background-control-has-no-effect",
945
+ "description": "A file-node creation with destinationBoardId B fails as unsupported and qualified reads show no new file or active-board side effect; no active-board fallback is attempted",
946
+ "type": "output_check"
947
+ }
948
+ ]
949
+ },
950
+ {
951
+ "id": 40,
952
+ "name": "board-qualified-native-relations",
953
+ "prompt": "In a disposable library A is active, and A and B both contain a card with ID shared. The harness has given A/shared, A/Navigation and B/shared explicit durable lifecycle policies and validated their current content. From A/shared add a depends-on edge to B/shared with reason 'Capacity requires measured throughput'. Add an ordinary references edge from A/Navigation to the same remote source. Read the qualified graph, change B/shared with the harness, and check which dependents become review-due. Keep A active and do not replace the edge with a board portal.",
954
+ "expected_output": "Resolves the two board identities and uses canvas_edge add with from:shared, to:shared, toBoardId:B, type:depends-on for dependent to prerequisite, preserving reason. Uses a references edge for Navigation. Reads graph and lifecycle qualifications with explicit board identities: the dependency is remote, not a self-loop on A. With explicit durable lifecycle policies in the fixture, the source change makes the dependent due but not the ordinary-reference control. Reads do not open B or mutate its source.",
955
+ "assertions": [
956
+ {
957
+ "name": "writes-qualified-remote-edge",
958
+ "description": "Actual edge add uses A/shared as source and toBoardId B with target shared; graph endpoints are distinct qualified keys, not bare-ID self-loops or board portal nodes",
959
+ "type": "output_check"
960
+ },
961
+ {
962
+ "name": "preserves-direction-and-reason",
963
+ "description": "Readback shows depends-on from dependent A/shared to prerequisite B/shared with the exact supplied reason, independently of display style or label",
964
+ "type": "output_check"
965
+ },
966
+ {
967
+ "name": "distinguishes-navigation-from-validation-dependency",
968
+ "description": "Before/after lifecycle reads under explicit durable policies show B's semantic change makes A/shared due while the references-only Navigation control stays not due",
969
+ "type": "output_check"
970
+ },
971
+ {
972
+ "name": "reads-remote-source-without-navigation",
973
+ "description": "Tool trace and before/after board reads keep A active; agent does not open B, edit its source or create a portal as a substitute for the remote card relation",
974
+ "type": "output_check"
975
+ }
976
+ ]
977
+ },
978
+ {
979
+ "id": 41,
980
+ "name": "wiki-bindings-survive-renames-without-guessing",
981
+ "prompt": "In a disposable library, save a source Markdown card on A containing [[Research#Result|evidence]], plus code-span and fenced-code copies of that link. Research uniquely names B and Result uniquely names B's target card. Record the binding, then let the harness rename B and Result and create new objects using the old names. Separately save [[Ambiguous]] when two boards share that name, and [[Future]] before that board exists. After the harness creates Future, read the source without saving, then explicitly save it. Do not open other boards or hand-author wikiLinks metadata.",
982
+ "expected_output": "Saves through the native Markdown path and inspects server-produced qualified wiki bindings/backlinks. The bound aliased link continues to target B's original card after renames despite old-name replacements; code samples add no relations. Ambiguous links stay unresolved instead of choosing a board. The saved missing Future link remains unresolved on read after creation and resolves only on explicit source save. Does not fabricate binding IDs or switch active boards to resolve references.",
983
+ "assertions": [
984
+ {
985
+ "name": "uses-server-bindings-and-ignores-code",
986
+ "description": "Save/read trace shows one real qualified Research/Result wiki relation with alias evidence, server-owned data.wikiLinks, and no extra relations for code-span/fenced examples",
987
+ "type": "output_check"
988
+ },
989
+ {
990
+ "name": "identity-wins-over-renamed-labels",
991
+ "description": "After harness renames and old-name replacements, graph/backlink or binding readback still identifies the original B/card IDs rather than silently retargeting to replacement names",
992
+ "type": "output_check"
993
+ },
994
+ {
995
+ "name": "does-not-guess-ambiguous-board",
996
+ "description": "The saved Ambiguous reference remains explicitly unresolved with no target selected; trace does not rewrite the source to an arbitrary candidate",
997
+ "type": "output_check"
998
+ },
999
+ {
1000
+ "name": "resolution-requires-explicit-save",
1001
+ "description": "Future remains unresolved after a read with the board now present, then resolves on explicit source save; A stays active and no invented wikiLinks metadata is submitted",
1002
+ "type": "output_check"
1003
+ }
1004
+ ]
1005
+ },
1006
+ {
1007
+ "id": 42,
1008
+ "name": "review-evidence-bound-to-checked-content-and-sources",
1009
+ "prompt": "On disposable board A, review Conclusion against its explicit dependency on B/Source. Read the actual content and capture lifecycle observations. Before submission let the harness change Conclusion, then in a separate trial change Source. Submit the captured stale reviews as controls. Do not just copy fresh tokens onto the old note. Recheck the evidence for a successful retain review, and also test an unavailable source, applied:false and blank evidence. In a separate harness-seeded cached-provenance control, compare observedRevision and availability to the current live source rather than treating retrieval time as freshness. Does fetching or regenerating a card alone validate it?",
1010
+ "expected_output": "Reads full qualified content and lifecycle-get, captures the checked content revision and qualification.observations, then supplies them as expectedContentRevision/expectedSources on canvas_node review with outcome retain, applied true and substantive evidence. Both stale trials conflict and leave validation unchanged. A successful review follows real rereads/recomparison, not token replacement, and its stored record identifies checked content/source revisions. Unavailable sources refuse validation; unapplied or empty-evidence outcomes do not renew it. Retrieval, editing and recipe refresh alone never validate.",
1011
+ "assertions": [
1012
+ {
1013
+ "name": "checks-content-and-observed-sources-before-review",
1014
+ "description": "Trace delivers full Conclusion and qualified B/Source content and lifecycle observations before sending retain/applied:true with nonempty evidence, expectedContentRevision and expectedSources from the checked versions",
1015
+ "type": "output_check"
1016
+ },
1017
+ {
1018
+ "name": "stale-evidence-cannot-renew-validation",
1019
+ "description": "Separate harness content-change and source-change trials reject captured stale reviews with conflicts and preserve lastValidation; no unchecked new revision tokens are pasted onto the old evidence",
1020
+ "type": "output_check"
1021
+ },
1022
+ {
1023
+ "name": "successful-record-binds-rechecked-versions",
1024
+ "description": "After actual full rereads and evidence comparison, successful review readback records the checked contentRevision and sourceRevisions; fetching/editing/refreshing alone leaves lastValidation unchanged",
1025
+ "type": "output_check"
1026
+ },
1027
+ {
1028
+ "name": "nonvalidating-controls-do-not-renew",
1029
+ "description": "Unavailable-source review is refused, and applied:false and blank-evidence controls do not advance lastValidation; observed cached-source revision/availability is not misrepresented as a fresh live check",
1030
+ "type": "output_check"
1031
+ }
1032
+ ]
1033
+ },
1034
+ {
1035
+ "id": 43,
1036
+ "name": "retirement-excludes-context-until-audited-restore",
1037
+ "prompt": "On disposable A, pin Obsolete and Keep. Give Obsolete an explicit durable lifecycle policy and validate its current content with evidence, then retire it. Check the brief, pinned context, search and direct read. Read and edit Obsolete and apply a retain review: should any of these unretire it? Finally make an unvalidated content edit, explicitly restore it with reason 'Needed for follow-up', and check its eligibility and review status. B has the same card ID as Obsolete; do not change B.",
1038
+ "expected_output": "Uses canvas_node review outcome retire/applied true and expectedBoardId A. Retired Obsolete is absent from briefs and pinned-context delivery while search/direct reads retain it marked retired; Keep stays delivered. Read/edit/retain do not reverse retirement. Explicit lifecycle-restore with evidence reverses retirement, makes the still-pinned card eligible again, but leaves lastValidation bound to the older checked revision so the final edited content remains review-due. Board-qualified checks confirm B's same-ID card and lifecycle history are unchanged.",
1039
+ "assertions": [
1040
+ {
1041
+ "name": "retirement-excludes-delivery-not-storage",
1042
+ "description": "After retire/applied:true, brief and pinned-context results omit A/Obsolete while Keep remains; qualified direct read/search still find Obsolete marked retired rather than deleted",
1043
+ "type": "output_check"
1044
+ },
1045
+ {
1046
+ "name": "ordinary-actions-cannot-unretire",
1047
+ "description": "Actual read, content edit and applied retain review leave the retirement flag set and Obsolete excluded from context delivery",
1048
+ "type": "output_check"
1049
+ },
1050
+ {
1051
+ "name": "restore-is-audited-not-validation",
1052
+ "description": "After a final unvalidated semantic edit, lifecycle-restore records 'Needed for follow-up', clears retired and restores context eligibility without advancing lastValidation; the current revision remains due",
1053
+ "type": "output_check"
1054
+ },
1055
+ {
1056
+ "name": "lifecycle-identity-is-board-qualified",
1057
+ "description": "Writes use expectedBoardId A; before/after reads of B's same-ID card show unchanged content, retirement and review records, with A still active",
1058
+ "type": "output_check"
1059
+ }
1060
+ ]
837
1061
  }
838
1062
  ]
839
1063
  }
@@ -4,7 +4,7 @@ Use this reference when the `pmx-canvas` skill is installed but the `pmx-canvas`
4
4
 
5
5
  ## Prerequisites and version
6
6
 
7
- The examples pin **PMX Canvas 0.9.0**, which requires **Bun >=1.4.2** even when
7
+ The examples pin **PMX Canvas 0.9.1**, which requires **Bun >=1.4.2** even when
8
8
  installed through npm: the CLI runs through Bun, not Node. Check `bun --version`
9
9
  first. If missing or older, follow the [Bun installation instructions](https://bun.sh/docs/installation)
10
10
  with the user's authorization. Ensure Bun is on the MCP host's PATH as well as the
@@ -17,11 +17,11 @@ in `SKILL.md`. Do not pair newer checkout guidance with an older runtime unknowi
17
17
  ## Install from npm
18
18
 
19
19
  ```bash
20
- npm install -g pmx-canvas@0.9.0
20
+ npm install -g pmx-canvas@0.9.1
21
21
  pmx-canvas --version
22
22
  ```
23
23
 
24
- Without a global install, use `bunx pmx-canvas@0.9.0` in place of `pmx-canvas` in
24
+ Without a global install, use `bunx pmx-canvas@0.9.1` in place of `pmx-canvas` in
25
25
  the commands below. Do not use unpinned `bunx pmx-canvas` for a version-specific check.
26
26
 
27
27
  ## Choose the workspace and target
@@ -91,7 +91,7 @@ discards empty env values, remove `PMX_CANVAS_URL` from its launch environment i
91
91
  "mcpServers": {
92
92
  "canvas": {
93
93
  "command": "bunx",
94
- "args": ["pmx-canvas@0.9.0", "--mcp"],
94
+ "args": ["pmx-canvas@0.9.1", "--mcp"],
95
95
  "env": {
96
96
  "PMX_CANVAS_WORKSPACE_ROOT": "/absolute/path/to/project",
97
97
  "PMX_CANVAS_PORT": "14313",
@@ -135,7 +135,7 @@ Amp executes, not only on the user's laptop. Merge this into that workspace's
135
135
  "amp.mcpServers": {
136
136
  "canvas": {
137
137
  "command": "bunx",
138
- "args": ["pmx-canvas@0.9.0", "--mcp"],
138
+ "args": ["pmx-canvas@0.9.1", "--mcp"],
139
139
  "includeTools": ["canvas_*"],
140
140
  "env": {
141
141
  "PMX_CANVAS_WORKSPACE_ROOT": "/absolute/path/to/project",
@@ -169,7 +169,7 @@ The agent must not run this approval itself or move the command to global settin
169
169
  `--mcp-config` to bypass workspace trust. Approval authorizes execution of this MCP
170
170
  command; it is not a new Canvas import consent or permission to mutate arbitrary boards.
171
171
  After approval, reload MCP connections (`reload_mcp` in Amp) and require `connected`.
172
- Discover `canvas` tools with `tool_search`; for the full unfiltered 0.9.0 server there
172
+ Discover `canvas` tools with `tool_search`; for the full unfiltered 0.9.1 server there
173
173
  are 24 tools. If still absent, inspect the server status, command/PATH and target instead
174
174
  of reinstalling blindly. See [Amp MCP documentation](https://ampcode.com/docs/customize/mcp).
175
175
 
@@ -102,6 +102,7 @@ cmd(
102
102
  async (args) => {
103
103
  const { positional, flags } = parseFlags(args);
104
104
  if (flags.help || flags.h) return showCommandHelp('board create');
105
+ if ('category' in flags) die('The --category flag was removed.', 'Use --folder <folder> instead.');
105
106
  const name = positional.join(' ').trim();
106
107
  if (!name) die('Usage: pmx-canvas board create <name> [--folder <folder>]');
107
108
  const folder = getStringFlag(flags, 'folder');
@@ -105,7 +105,7 @@ function LifecycleDialog({ target }: { target: NonNullable<typeof lifecycleTarge
105
105
  void refreshKnowledge();
106
106
  if (!detail || needsReload) void load();
107
107
  }, [enabled]);
108
- const mutate = async (suffix: string, body: Record<string, unknown>) => {
108
+ const mutate = async (suffix: string, body: Record<string, unknown>, acknowledge?: () => void) => {
109
109
  if (!ownsDialog() || busy || needsReload) return false;
110
110
  setBusy(true);
111
111
  setError('');
@@ -114,7 +114,7 @@ function LifecycleDialog({ target }: { target: NonNullable<typeof lifecycleTarge
114
114
  headers: { 'Content-Type': 'application/json' },
115
115
  body: JSON.stringify({ ...body, expectedBoardId: target.boardId }),
116
116
  });
117
- if (!sameBoard() || lifecycleTarget.value !== target) {
117
+ if (!sameBoard()) {
118
118
  setBusy(false);
119
119
  return false;
120
120
  }
@@ -125,6 +125,14 @@ function LifecycleDialog({ target }: { target: NonNullable<typeof lifecycleTarge
125
125
  );
126
126
  return false;
127
127
  }
128
+ // Acknowledgement belongs to the accepted write, not the later reads or
129
+ // the dock's lifetime. Closing/suspending the dock must not lose Undo.
130
+ acknowledge?.();
131
+ if (lifecycleTarget.value !== target) {
132
+ void refreshKnowledge();
133
+ setBusy(false);
134
+ return false;
135
+ }
128
136
  // Invalidate acknowledged preconditions without unmounting focused controls.
129
137
  // If suspended, resume reloads before another mutation is allowed.
130
138
  setNeedsReload(true);
@@ -144,20 +152,22 @@ function LifecycleDialog({ target }: { target: NonNullable<typeof lifecycleTarge
144
152
  error)
145
153
  )
146
154
  return false;
147
- const ok = await mutate('/review', {
148
- outcome: reviewOutcome,
149
- evidence,
150
- applied: wasApplied,
151
- expectedContentRevision: detail.contentRevision,
152
- expectedSources: detail.qualification.observations,
153
- });
154
- if (ok) {
155
- setRetiredHere(wasApplied && reviewOutcome === 'retire');
156
- if (wasApplied && reviewOutcome === 'retire') retirementUndo.value = target;
157
- setEvidence('');
158
- setApplied(false);
159
- }
160
- return ok;
155
+ return mutate(
156
+ '/review',
157
+ {
158
+ outcome: reviewOutcome,
159
+ evidence,
160
+ applied: wasApplied,
161
+ expectedContentRevision: detail.contentRevision,
162
+ expectedSources: detail.qualification.observations,
163
+ },
164
+ () => {
165
+ setRetiredHere(wasApplied && reviewOutcome === 'retire');
166
+ if (wasApplied && reviewOutcome === 'retire') retirementUndo.value = target;
167
+ setEvidence('');
168
+ setApplied(false);
169
+ },
170
+ );
161
171
  };
162
172
  const recipe = node?.data.provenance as { recipe?: { tool: string; input?: unknown; prompt: string } } | undefined;
163
173
  const date = (value: string | null | undefined) => (value ? new Date(value).toLocaleString() : 'Unknown');
@@ -1016,6 +1016,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1016
1016
 
1017
1017
  // Register handlers BEFORE connect
1018
1018
  bridge.onsizechange = async ({ height }) => {
1019
+ const currentNode = nodes.value.get(nodeId);
1020
+ // Late app measurements must not undo an explicit manual card size.
1021
+ if (!currentNode || currentNode.data.userResized === true) return {};
1019
1022
  if (shouldApplyExtAppSizeChange(height, expandedNodeId.value === nodeId)) {
1020
1023
  const hostDimensions = resolveExtAppContainerDimensions(iframe.parentElement ?? iframe, {
1021
1024
  width: node.size.width,
@@ -1023,7 +1026,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1023
1026
  });
1024
1027
  const inlineFrameHeight = resolveExtAppInlineFrameHeight(height, hostDimensions.height);
1025
1028
  iframe.style.height = `${inlineFrameHeight}px`;
1026
- const currentSize = nodes.value.get(nodeId)?.size ?? node.size;
1029
+ const currentSize = currentNode.size;
1027
1030
  const nodeHeight = Math.max(currentSize.height, inlineFrameHeight + AUTO_FIT_TITLEBAR_HEIGHT);
1028
1031
  if (Math.abs(nodeHeight - currentSize.height) > 8) {
1029
1032
  resizeNode(nodeId, { width: currentSize.width, height: nodeHeight });
@@ -1031,8 +1034,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1031
1034
  window.clearTimeout(sizePersistTimerRef.current);
1032
1035
  }
1033
1036
  sizePersistTimerRef.current = window.setTimeout(() => {
1034
- persistLayout({ recordHistory: false });
1035
1037
  sizePersistTimerRef.current = null;
1038
+ const latest = nodes.value.get(nodeId);
1039
+ if (latest && latest.data.userResized !== true) persistLayout({ recordHistory: false });
1036
1040
  }, 0);
1037
1041
  }
1038
1042
  }
@@ -146,12 +146,23 @@ export function takeOverNode(nodeId: string, title: string): void {
146
146
  }
147
147
 
148
148
  let heartbeat: ReturnType<typeof setInterval> | null = null;
149
+ let leave: (() => void) | null = null;
150
+
151
+ export function stopHumanPresence(): void {
152
+ if (heartbeat) clearInterval(heartbeat);
153
+ heartbeat = null;
154
+ if (pending) clearTimeout(pending);
155
+ pending = null;
156
+ if (leave) window.removeEventListener('pagehide', leave);
157
+ leave = null;
158
+ }
149
159
 
150
160
  /** Announce this tab and keep it alive; renews a held grab. Call once per connection. */
151
- export function startHumanPresence(): () => void {
161
+ export function startHumanPresence(): void {
162
+ stopHumanPresence();
152
163
  post({ cursor: lastCursor, grabbingNodeId: grabbingNodeId.value });
153
164
  heartbeat = setInterval(() => post({ grabbingNodeId: grabbingNodeId.value }), 3000);
154
- const leave = () => {
165
+ leave = () => {
155
166
  try {
156
167
  navigator.sendBeacon?.(
157
168
  '/api/canvas/human-presence',
@@ -162,11 +173,6 @@ export function startHumanPresence(): () => void {
162
173
  }
163
174
  };
164
175
  window.addEventListener('pagehide', leave);
165
- return () => {
166
- if (heartbeat) clearInterval(heartbeat);
167
- heartbeat = null;
168
- window.removeEventListener('pagehide', leave);
169
- };
170
176
  }
171
177
 
172
178
  export function resetHumanPresence(): void {
@@ -58,7 +58,7 @@ import {
58
58
  tabAgentKey,
59
59
  } from './intent-bridge';
60
60
  import { agentActivity, applyPresenceSnapshot, sessionActive } from './presence-store';
61
- import { applyHumanSnapshot, humanClientId, startHumanPresence } from './human-store';
61
+ import { applyHumanSnapshot, humanClientId, startHumanPresence, stopHumanPresence } from './human-store';
62
62
  import type { HumanPresenceSnapshot } from '../../shared/human-presence.js';
63
63
  import { activityLens, applySessionReceipt, dismissSessionReceipt, refreshTimeline } from './session-store';
64
64
  import { initSessionThemeOverride, themeOverrideActive } from './theme-override';
@@ -502,7 +502,6 @@ function handleAgentSessionEnded(data: Record<string, unknown>): void {
502
502
  applySessionReceipt(data);
503
503
  }
504
504
 
505
- let stopHumanPresence: (() => void) | null = null;
506
505
  let boardResyncGeneration = 0;
507
506
  let layoutRevision = 0;
508
507
  let pendingBoardLayout: Record<string, unknown> | null = null;
@@ -586,8 +585,8 @@ function handleConnected(data: Record<string, unknown>): void {
586
585
  // Human collaborators (phase 8): announce this tab and read who else is here.
587
586
  // An `?agent=<key>` tab is an AGENT's view — it must not heartbeat a human
588
587
  // guest cursor (its writes already book as the agent).
589
- stopHumanPresence?.();
590
- if (!tabAgentKey && !isHostedWorkbench()) stopHumanPresence = startHumanPresence();
588
+ stopHumanPresence();
589
+ if (!tabAgentKey && !isHostedWorkbench()) startHumanPresence();
591
590
  void requestJson<Partial<HumanPresenceSnapshot> | null>(
592
591
  'fetchHumanPresence',
593
592
  '/api/canvas/human-presence',
@@ -1378,7 +1377,32 @@ function startPollingTransport(): () => void {
1378
1377
  // normalization still arrive even though the event cursor advances past the old batch.
1379
1378
  const acceptPins = pinsAtRequest === contextPinsRevision && contextPinsPendingWrites === 0;
1380
1379
  let skippedPins = false;
1380
+ // Full layouts in a buffered poll can predate an HTTP refresh. Replaying
1381
+ // an obsolete empty layout would detach a live editor before its card
1382
+ // reappears. Keep the last snapshot across bookkeeping frames only:
1383
+ // commands (focus, tours, deltas, etc.) still need their preceding layout.
1384
+ const supersededLayouts = new Set<(typeof body.events)[number]>();
1385
+ if (!body.snapshot && hasInitialServerLayout.value) {
1386
+ let previousLayout: (typeof body.events)[number] | undefined;
1387
+ for (const entry of body.events) {
1388
+ const data = entry.payload as Record<string, unknown>;
1389
+ const sameActivation =
1390
+ data.catalogEpoch === catalogEpoch &&
1391
+ data.boardGeneration === boardGeneration &&
1392
+ (entry.event === 'boards-changed' ? data.activeBoardId : data.boardId) === activeBoardId.value;
1393
+ if (entry.event === 'canvas-layout-update' && sameActivation) {
1394
+ if (previousLayout) supersededLayouts.add(previousLayout);
1395
+ previousLayout = entry;
1396
+ } else if (
1397
+ !(entry.event === 'boards-changed' && sameActivation) &&
1398
+ !['agent-presence', 'human-presence', 'ax-intent', 'ax-intent-clear'].includes(entry.event)
1399
+ ) {
1400
+ previousLayout = undefined;
1401
+ }
1402
+ }
1403
+ }
1381
1404
  for (const entry of body.events) {
1405
+ if (supersededLayouts.has(entry)) continue;
1382
1406
  if (entry.event === 'context-pins-changed' && !acceptPins) {
1383
1407
  skippedPins = true;
1384
1408
  continue;
@@ -1411,6 +1435,7 @@ function startPollingTransport(): () => void {
1411
1435
  void pollOnce();
1412
1436
 
1413
1437
  return () => {
1438
+ stopHumanPresence();
1414
1439
  pollGeneration += 1;
1415
1440
  pollingMode = false;
1416
1441
  pollSeq = null;
@@ -1511,6 +1536,7 @@ export function connectSSE(): () => void {
1511
1536
  };
1512
1537
 
1513
1538
  return () => {
1539
+ stopHumanPresence();
1514
1540
  if (reconnectTimer) {
1515
1541
  clearTimeout(reconnectTimer);
1516
1542
  reconnectTimer = null;