trekoon 0.4.2 → 0.4.3

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.
@@ -1,129 +1,82 @@
1
1
  # Sync Reference
2
2
 
3
- ## Sync reminders
3
+ Trekoon uses one live SQLite database per repository at
4
+ `<sharedStorageRoot>/.trekoon/trekoon.db`. Linked worktrees share it. `git
5
+ checkout` and `git switch` do not roll back tracker state. Sync imports tracker
6
+ events between branches; never copy or commit the `.db` file.
4
7
 
5
- Same-branch sync is a no-op: `sync pull --from main` while on `main` produces
6
- zero conflicts and simply advances the cursor. `sync status` returns `behind=0`
7
- on the source branch. No action is needed.
8
+ Same-branch sync is a no-op. Cross-branch sync matters before merging a feature
9
+ branch back.
8
10
 
9
- Cross-branch sync matters before merging a feature branch back:
11
+ ## Before Merge
10
12
 
11
- - Before merge, pull tracker events from the base branch:
13
+ Pull tracker events from the base branch:
12
14
 
13
- ```bash
14
- trekoon --toon sync pull --from main
15
- ```
16
-
17
- - If conflicts exist, inspect and resolve them explicitly:
15
+ ```bash
16
+ trekoon --toon sync pull --from main
17
+ ```
18
18
 
19
- ```bash
20
- trekoon --toon sync conflicts list
21
- trekoon --toon sync conflicts show <conflict-id>
22
- trekoon --toon sync resolve <conflict-id> --use theirs --dry-run
23
- trekoon --toon sync resolve <conflict-id> --use ours
24
- ```
19
+ If conflicts exist:
25
20
 
26
- ### Conflict resolution: ours vs theirs
21
+ ```bash
22
+ trekoon --toon sync conflicts list
23
+ trekoon --toon sync conflicts show <conflict-id>
24
+ trekoon --toon sync resolve <conflict-id> --use theirs --dry-run
25
+ trekoon --toon sync resolve <conflict-id> --use ours
26
+ ```
27
27
 
28
- Conflicts are **field-level**, not whole-record. Each conflict targets one field
29
- (e.g., `status`, `title`, `description`) on one entity.
28
+ ## Conflict Rules
30
29
 
31
- - `--use ours` keep the current entity field value in the shared DB. The
32
- entity is not written, but the conflict record is marked resolved and a
33
- resolution event is appended.
34
- - `--use theirs` — overwrite the shared DB entity field with the source-branch
35
- value. The conflict record is marked resolved and a resolution event is
36
- appended.
37
- - `--dry-run` — preview the resolution without mutating the database. Returns
38
- `oursValue`, `theirsValue`, `wouldWrite`, and `dryRun: true`. Use this before
39
- committing to a resolution.
30
+ Conflicts are field-level, not whole-record. Each conflict targets one field on
31
+ one entity.
40
32
 
41
- **Example:** after `sync pull --from main`, a conflict appears on epic `abc123`,
42
- field `status`:
43
- - ours (current DB): `in_progress`
44
- - theirs (source branch): `done`
45
- - `--use ours` keeps status as `in_progress`
46
- - `--use theirs` changes status to `done` in the live shared DB
33
+ - `--use ours`: keep the current shared DB field value; mark conflict resolved.
34
+ - `--use theirs`: write the source-branch field value into the shared DB; mark
35
+ conflict resolved.
36
+ - `--dry-run`: preview without mutation. Returns `oursValue`, `theirsValue`,
37
+ `wouldWrite`, and `dryRun: true`.
47
38
 
48
- Always inspect conflicts with `sync conflicts show` before resolving. Choosing
49
- `theirs` without inspection can overwrite in-progress work in the shared DB.
39
+ Always inspect with `sync conflicts show` before resolving. Choosing `theirs`
40
+ without inspection can overwrite in-progress shared DB work.
50
41
 
51
- ### Understanding why conflicts happen
42
+ Typical choices:
52
43
 
53
- | Scenario | Typical resolution | Why |
44
+ | Scenario | Usually use | Why |
54
45
  |---|---|---|
55
- | Completed work vs stale main state | ours | Your branch has the latest progress |
46
+ | Completed work vs stale main | ours | Your branch has latest progress |
56
47
  | Enriched descriptions vs original | ours | Your descriptions are more detailed |
57
- | Upstream updates from another agent | theirs | Accept the newer upstream state |
58
- | User-intentional reset | theirs | Respect the user's explicit action |
48
+ | Upstream updates from another agent | theirs | Accept newer upstream state |
49
+ | User-intentional reset | theirs | Respect explicit user action |
59
50
 
60
- ### Agent decision framework
51
+ When unsure, ask the user.
61
52
 
62
- 1. List conflicts: `trekoon --toon sync conflicts list`
63
- 2. Group by pattern — are conflicts on the same field or direction?
64
- 3. If uniform pattern, batch resolve: `trekoon --toon sync resolve --all --use ours`
65
- 4. If mixed, narrow by entity or field, or inspect individually
66
- 5. When unsure, ask the user
67
-
68
- ### Batch resolve patterns
69
-
70
- Common scenarios:
53
+ ## Batch Resolve
71
54
 
72
55
  ```bash
73
- # Resolve all conflicts at once (most common after completing work)
74
- trekoon --toon sync resolve --all --use ours
75
-
76
- # Preview before resolving
77
56
  trekoon --toon sync resolve --all --use ours --dry-run
78
-
79
- # Narrow to status field conflicts only
57
+ trekoon --toon sync resolve --all --use ours
80
58
  trekoon --toon sync resolve --all --use ours --field status
81
-
82
- # Narrow to a specific entity
83
59
  trekoon --toon sync resolve --all --use theirs --entity <id>
84
-
85
- # Combine filters
86
60
  trekoon --toon sync resolve --all --use ours --entity <id> --field description
87
61
  ```
88
62
 
89
- ## Shared-database model
90
-
91
- Trekoon uses **one live SQLite database per repository**. The file lives at
92
- `<sharedStorageRoot>/.trekoon/trekoon.db`, where `sharedStorageRoot` is the
93
- parent of `git rev-parse --git-common-dir` (i.e., the main worktree root).
94
-
95
- Key consequences:
96
-
97
- - **All linked worktrees share the same database.** A status change in one
98
- worktree is immediately visible in every other worktree.
99
- - **`git checkout` / `git switch` does not change tracker state.** The database
100
- is outside the git object store, so switching branches does not roll back or
101
- swap task data.
102
- - **Sync operates on tracker events, not on the database file itself.** Use
103
- `sync pull` to import events between branches — never copy or commit the
104
- `.db` file.
105
-
106
- Treat every write as a mutation of shared repo-wide state, not branch-scoped
107
- state.
108
-
109
- **Conflicts are scoped per worktree + branch.** `sync_conflicts` rows are
110
- recorded with the originating worktree path and current branch, so resolving a
111
- conflict in one worktree never erases a peer worktree's conflict on the same
112
- entity. `sync conflicts list` and `sync resolve` from a given worktree only
113
- see and act on rows scoped to that worktree's branch.
114
-
115
- ## Worktree diagnostics and destructive scope
116
-
117
- - Inspect machine-readable storage fields when debugging worktrees:
118
- `storageMode`, `repoCommonDir`, `worktreeRoot`, `sharedStorageRoot`, and
119
- `databaseFile`.
120
- - `sharedStorageRoot` is the repo-scoped source of truth for `.trekoon` in git
121
- worktrees.
122
- - If `trekoon wipe --yes --toon` is explicitly requested, warn that it deletes
123
- shared storage for the entire repository and every linked worktree.
124
- - Wipe is destructive recovery only; it is never the right fix for a tracked DB
125
- or gitignore mistake.
126
-
127
- Trekoon stores local state in `.trekoon/trekoon.db`. In git repos and
128
- worktrees, storage resolves from the shared repository root rather than each
129
- worktree independently.
63
+ Use batch resolve only when the conflict pattern is uniform. Otherwise inspect
64
+ and resolve individually or narrow by `--entity` / `--field`.
65
+
66
+ ## Worktree Scope
67
+
68
+ Inspect machine-readable storage fields when debugging worktrees:
69
+ `storageMode`, `repoCommonDir`, `worktreeRoot`, `sharedStorageRoot`, and
70
+ `databaseFile`.
71
+
72
+ Conflicts are scoped per worktree and branch. `sync conflicts list` and
73
+ `sync resolve` only act on rows for the current worktree/branch, so resolving a
74
+ conflict does not erase a peer worktree's conflict on the same entity.
75
+
76
+ ## Destructive Recovery
77
+
78
+ `sharedStorageRoot` is the repo-scoped source of truth for `.trekoon` in git
79
+ worktrees. If the user explicitly requests `trekoon wipe --yes --toon`, warn
80
+ that it deletes shared storage for the whole repository and every linked
81
+ worktree. Wipe is destructive recovery only; it is never the fix for a tracked
82
+ DB or gitignore mistake.
package/README.md CHANGED
@@ -90,8 +90,8 @@ What the agent does during execution:
90
90
  calls `task done`
91
91
  6. `task done` returns which downstream tasks just became unblocked, so the
92
92
  orchestrator knows what to dispatch next
93
- 7. After all tasks complete: code review, tests, manual verification, then marks
94
- the epic `done`
93
+ 7. After all tasks complete: review agent or review skill for non-trivial
94
+ changes, tests, manual verification, then marks the epic `done`
95
95
 
96
96
  The orchestrator uses `task done` responses to drive the whole loop. No polling,
97
97
  no guessing. When a task finishes, Trekoon tells you exactly what's ready next.
@@ -110,13 +110,15 @@ agents don't know how to use Trekoon properly.
110
110
  ```bash
111
111
  trekoon skills install # repo-local (.agents/skills/trekoon/)
112
112
  trekoon skills install -g # global (~/.agents/skills/trekoon)
113
+ trekoon skills install --link --editor codex # editor link (.codex/skills/trekoon)
113
114
  trekoon update # refresh all installed skills
114
115
  ```
115
116
 
116
- The skill bundles three reference documents that agents load on demand:
117
+ The skill bundles reference documents that agents load on demand:
117
118
 
118
119
  | Agent needs to... | Skill reads | What it covers |
119
120
  | --- | --- | --- |
121
+ | Coordinate across harnesses | `reference/harness-primitives.md` | Universal subagent, question, local task display, review, and evidence-recording guidance |
120
122
  | Plan a feature | `reference/planning.md` | Decomposition, writing standard, dependency modeling, validation |
121
123
  | Execute an epic | `reference/execution.md` | Graph building, lane grouping, sub-agent dispatch, verification (universal) |
122
124
  | Execute with Agent Teams | `reference/execution-with-team.md` | TeamCreate/SendMessage, parallel Claude Code instances in tmux |
@@ -145,6 +147,16 @@ Execute only:
145
147
  /trekoon <epic-id> execute
146
148
  ```
147
149
 
150
+ When you execute an epic, use subagents by default for any meaningful work that
151
+ can run independently. Keep small or tightly coupled tasks in the parent agent.
152
+ Use the main context for orchestration, dependency decisions, user
153
+ communication, and final synthesis while Trekoon remains the durable source of
154
+ truth.
155
+
156
+ If a harness has a higher-priority rule requiring explicit permission before
157
+ subagents, surface that immediately instead of silently doing broad work
158
+ yourself.
159
+
148
160
  Plan and execute end to end:
149
161
 
150
162
  ```
@@ -154,9 +166,11 @@ dependency order until the epic is complete
154
166
 
155
167
  ## Agent Teams
156
168
 
157
- For larger epics, Trekoon supports Claude Code Agent Teams. Instead of
158
- sequential sub-agents, you get real parallel Claude Code instances coordinated
159
- through `TeamCreate` and `SendMessage`, each running in its own tmux pane.
169
+ For larger epics, use the current harness's native subagent mechanism for
170
+ meaningful work that can run independently. Claude Code Agent Teams are one
171
+ runtime-specific option: instead of sequential sub-agents, you get real parallel
172
+ Claude Code instances coordinated through `TeamCreate` and `SendMessage`, each
173
+ running in its own tmux pane.
160
174
 
161
175
  Requires Claude Code env variable `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=true`.
162
176
 
@@ -201,7 +215,7 @@ shell, worktree, or browser tab show up live without a manual refresh.
201
215
  | --- | --- |
202
216
  | Set up a repo | `trekoon init` |
203
217
  | Open the local board | `trekoon board open` |
204
- | Plan work | `trekoon epic create ...`, `trekoon epic expand ...` |
218
+ | Plan work | `trekoon --toon epic create ...`, `trekoon --toon epic expand ...` |
205
219
  | Create tasks in bulk | `trekoon task create-many ...` |
206
220
  | Add dependencies | `trekoon dep add-many ...` |
207
221
  | Start an agent session | `trekoon session --epic <id>` |
package/docs/ai-agents.md CHANGED
@@ -14,22 +14,39 @@ agent to:
14
14
  - append progress and blocker notes instead of rewriting descriptions
15
15
  - preview scoped replace before `--apply`
16
16
  - treat `.trekoon` as shared repo-scoped state
17
+ - use harness-local todo/task tools as a live progress display while keeping
18
+ Trekoon as the durable source of truth
19
+ - use subagents by default for meaningful work that can run independently when
20
+ the harness exposes them
21
+ - keep small or tightly coupled tasks in the parent agent so the main context
22
+ stays available for orchestration and decisions
17
23
 
18
24
  The skill ships with reference guides so the agent can handle the full
19
25
  plan-to-completion workflow from one install:
20
26
 
21
27
  ```
22
28
  .agents/skills/trekoon/
23
- SKILL.md <- command reference, status machine, agent loop
29
+ SKILL.md <- command router and operating contract
24
30
  reference/
31
+ harness-primitives.md <- universal agent/task/question/review primitives
25
32
  planning.md <- decomposition, writing standard, validation
26
33
  execution.md <- graph building, lane dispatch, verification
27
34
  execution-with-team.md <- Agent Teams pattern (Claude Code only)
28
35
  ```
29
36
 
30
- The agent loads the relevant reference on demand: `planning.md` when asked to
31
- plan, `execution.md` when asked to execute, `execution-with-team.md` when Agent
32
- Teams are available.
37
+ The command shape determines what the agent loads:
38
+
39
+ | User command | Required references |
40
+ | --- | --- |
41
+ | `/trekoon plan <goal>` | `harness-primitives.md`, then `planning.md` |
42
+ | `/trekoon <id>` | `SKILL.md` only, unless deeper analysis is needed |
43
+ | `/trekoon <id> analyze` | `SKILL.md` plus targeted Trekoon reads |
44
+ | `/trekoon <id> execute` | `harness-primitives.md`, then `execution.md` |
45
+ | `/trekoon <id> team execute` | `harness-primitives.md`, then `execution-with-team.md` |
46
+
47
+ `harness-primitives.md` is loaded before planning or execution because those
48
+ modes may need structured questions, local progress displays, subagent
49
+ delegation, review agents, and runtime-specific orchestration.
33
50
 
34
51
  ## Install the skill
35
52
 
@@ -42,6 +59,7 @@ Create a project-local editor link when your agent environment supports it:
42
59
  ```bash
43
60
  trekoon skills install --link --editor opencode
44
61
  trekoon skills install --link --editor claude
62
+ trekoon skills install --link --editor codex
45
63
  trekoon skills install --link --editor pi
46
64
  trekoon skills install --link --editor opencode --to ./.custom-editor/skills
47
65
  trekoon skills update
@@ -52,6 +70,7 @@ Path behavior:
52
70
  - Canonical install: `.agents/skills/trekoon/SKILL.md`
53
71
  - OpenCode link: `.opencode/skills/trekoon`
54
72
  - Claude link: `.claude/skills/trekoon`
73
+ - Codex link: `.codex/skills/trekoon`
55
74
  - Pi link: `.pi/skills/trekoon`
56
75
  - `--to <path>` changes only the editor link root
57
76
  - `--allow-outside-repo` is for intentional external links
@@ -97,6 +116,17 @@ Typical flow:
97
116
 
98
117
  The core loop: **session, work, task done, repeat**.
99
118
 
119
+ When you execute an epic, use subagents by default for any meaningful work that
120
+ can run independently. Keep small or tightly coupled tasks in the parent agent.
121
+ Build the Trekoon execution graph, group ready tasks into lanes, keep a local
122
+ todo/task display for the user, and synthesize the results. Use the parent
123
+ context for dependency decisions and finishing the epic.
124
+
125
+ Some harnesses have higher-priority rules that require explicit user permission
126
+ before spawning subagents. When that happens, say so immediately after
127
+ discovering independent lanes and ask for permission before broad execution. Do
128
+ not silently default to single-agent execution.
129
+
100
130
  Start with a single orientation call, optionally scoped to an epic:
101
131
 
102
132
  ```bash
@@ -193,6 +223,20 @@ notes, mark it done, and repeat until there are no ready tasks or you hit a
193
223
  blocker.
194
224
  ```
195
225
 
226
+ ### Execute with subagents where useful
227
+
228
+ ```text
229
+ /trekoon <epic-id> execute -- use subagents by default for meaningful work that
230
+ can run independently, keep Trekoon as the source of truth, and use local task
231
+ tools to show live progress.
232
+ ```
233
+
234
+ Short form for harnesses that require explicit delegation wording:
235
+
236
+ ```text
237
+ /trekoon <epic-id> execute with subagents
238
+ ```
239
+
196
240
  ### Plan and execute end to end
197
241
 
198
242
  ```text
package/docs/commands.md CHANGED
@@ -22,8 +22,8 @@ the quickest way to get started, read [Quickstart](quickstart.md) first.
22
22
  - `trekoon sync resolve <conflict-id> --use ours|theirs [--dry-run]`
23
23
  - `trekoon sync resolve --all --use ours|theirs [--entity <id>] [--field <name>] [--dry-run]`
24
24
  - `trekoon sync conflicts <list|show> [--mode pending|all]`
25
- - `trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]`
26
- - `trekoon skills install -g|--global [--editor opencode|claude|pi]`
25
+ - `trekoon skills install [--link --editor opencode|claude|codex|pi] [--to <path>] [--allow-outside-repo]`
26
+ - `trekoon skills install -g|--global [--editor opencode|claude|codex|pi]`
27
27
  - `trekoon skills update`
28
28
  - `trekoon wipe --yes`
29
29
  - `trekoon serve` (experimental)
@@ -69,7 +69,7 @@ if you just need to refresh the runtime assets without opening the browser.
69
69
  ## Create work
70
70
 
71
71
  ```bash
72
- trekoon epic create --title "Agent backlog stabilization" --description "Track stabilization work" --status todo
72
+ trekoon --toon epic create --title "Agent backlog stabilization" --description "Track stabilization work" --status todo
73
73
  trekoon task create --title "Implement sync status" --description "Add status reporting" --epic <epic-id> --status todo
74
74
  trekoon subtask create --task <task-id> --title "Add cursor model" --status todo
75
75
  ```
@@ -88,7 +88,7 @@ trekoon task list --all --view compact
88
88
  If you already know the full epic tree, create everything in one call:
89
89
 
90
90
  ```bash
91
- trekoon epic create \
91
+ trekoon --toon epic create \
92
92
  --title "Batch command rollout" \
93
93
  --description "Ship one-shot planning workflows" \
94
94
  --task "task-a|First task|First description|todo" \
@@ -118,7 +118,7 @@ For larger updates, use batch commands instead of looping:
118
118
  | Multiple tasks under one epic | `trekoon task create-many --epic <epic-id> --task ...` |
119
119
  | Multiple subtasks under one task | `trekoon subtask create-many <task-id> --subtask ...` |
120
120
  | Multiple dependency edges | `trekoon dep add-many --dep ...` |
121
- | Expand an existing epic | `trekoon epic expand <epic-id> ...` |
121
+ | Expand an existing epic | `trekoon --toon epic expand <epic-id> ...` |
122
122
 
123
123
  These validate the whole batch before applying, so a bad input fails the entire
124
124
  operation instead of leaving partial state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "AI-first task tracking that lives in your repo. You describe what to build, your agent plans it as a dependency graph, then executes it task by task",
5
5
  "keywords": [
6
6
  "ai",
@@ -479,9 +479,11 @@ export async function bootLegacyBoard(options = {}) {
479
479
  rerender,
480
480
  });
481
481
  if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
482
- window.addEventListener("beforeunload", () => {
482
+ const disposeSnapshotSubscription = () => {
483
483
  snapshotSubscription.dispose();
484
- }, { once: true });
484
+ };
485
+ window.addEventListener("beforeunload", disposeSnapshotSubscription, { once: true });
486
+ window.addEventListener("pagehide", disposeSnapshotSubscription, { once: true });
485
487
  }
486
488
 
487
489
  // Actions for delegation
@@ -153,8 +153,8 @@ export function preserveFormState(container, writeFn, options = {}) {
153
153
 
154
154
  // Per-form cache for getManagedControls: avoids the O(n^2) re-query that
155
155
  // occurs when many controls share the same form root.
156
- const captureCache = new Map();
157
- const inputs = getManagedControls(container, captureCache);
156
+ const controlCache = new Map();
157
+ const inputs = getManagedControls(container, controlCache);
158
158
 
159
159
  const activeElement = document.activeElement;
160
160
  let focusedIdentity = null;
@@ -162,7 +162,7 @@ export function preserveFormState(container, writeFn, options = {}) {
162
162
  const savedStates = inputs.map((el) => {
163
163
  const form = getFormRoot(el);
164
164
  const formId = getNamespacedFormIdentity(form);
165
- const controlId = form ? getControlIdentity(el, form, captureCache) : null;
165
+ const controlId = form ? getControlIdentity(el, form, controlCache) : null;
166
166
  const identity = controlId ? { formId, controlId } : null;
167
167
 
168
168
  if (activeElement === el) {
@@ -180,6 +180,7 @@ export function preserveFormState(container, writeFn, options = {}) {
180
180
  }).filter(s => s.identity);
181
181
 
182
182
  writeFn();
183
+ controlCache.clear();
183
184
 
184
185
  const formsByIdentity = new Map(
185
186
  Array.from(container.querySelectorAll(FORM_ROOT_SELECTOR)).map((form) => [
@@ -188,16 +189,13 @@ export function preserveFormState(container, writeFn, options = {}) {
188
189
  ]),
189
190
  );
190
191
 
191
- // Fresh cache for the restore pass (DOM was replaced by writeFn).
192
- const restoreCache = new Map();
193
-
194
192
  for (const state of savedStates) {
195
193
  const { formId, controlId } = state.identity;
196
194
  if (resetFormIds.has(formId)) {
197
195
  continue;
198
196
  }
199
197
  const form = formsByIdentity.get(formId) ?? container;
200
- const restored = getManagedControls(form, restoreCache).find((control) => getControlIdentity(control, form, restoreCache) === controlId);
198
+ const restored = getManagedControls(form, controlCache).find((control) => getControlIdentity(control, form, controlCache) === controlId);
201
199
  if (restored && restored.value !== state.value) {
202
200
  restored.value = state.value;
203
201
  }
@@ -213,7 +211,7 @@ export function preserveFormState(container, writeFn, options = {}) {
213
211
  return;
214
212
  }
215
213
  const form = formsByIdentity.get(formId) ?? container;
216
- const restored = getManagedControls(form, restoreCache).find((control) => getControlIdentity(control, form, restoreCache) === controlId);
214
+ const restored = getManagedControls(form, controlCache).find((control) => getControlIdentity(control, form, controlCache) === controlId);
217
215
  if (restored) {
218
216
  restored.focus({ preventScroll: true });
219
217
  const focusedState = savedStates.find((state) => state.identity?.formId === formId && state.identity?.controlId === controlId);
@@ -444,6 +444,9 @@ export function createBoardActions(options) {
444
444
  const nextKey = feedback ? `${feedback.targetStatus}|${feedback.kind}` : null;
445
445
  if (prevKey === nextKey) return;
446
446
  store.dragFeedback = feedback;
447
+ if (typeof model.invalidateBoardStateMemo === "function") {
448
+ model.invalidateBoardStateMemo();
449
+ }
447
450
  rerender();
448
451
  },
449
452
  dropTaskStatus(taskId, nextStatus) {
@@ -222,10 +222,9 @@ function createTimeoutError(method, path, timeoutMs) {
222
222
  * }}
223
223
  */
224
224
  export function createMutationQueue(model, rerender) {
225
- /** @type {Array<{ optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
225
+ /** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
226
226
  const queue = [];
227
227
  let processing = false;
228
- let nextMutationId = 1;
229
228
  /** @type {Array<() => void>} */
230
229
  let flushResolvers = [];
231
230
 
@@ -246,7 +245,7 @@ export function createMutationQueue(model, rerender) {
246
245
 
247
246
  while (queue.length > 0) {
248
247
  const mutation = queue.shift();
249
- if (model.store.notice?.retryMutationId !== mutation.id) {
248
+ if (model.store.notice?.retryMutationId !== mutation.mutationId) {
250
249
  model.store.notice = null;
251
250
  }
252
251
 
@@ -258,8 +257,8 @@ export function createMutationQueue(model, rerender) {
258
257
 
259
258
  try {
260
259
  if (typeof mutation.optimistic === "function") {
261
- const previousSnapshot = cloneSnapshot(model.store.snapshot);
262
- const optimisticSnapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
260
+ const previousSnapshot = model.store.snapshot;
261
+ const optimisticSnapshot = mutation.optimistic(cloneSnapshot(previousSnapshot));
263
262
  inverseDelta = computeInverseDelta(previousSnapshot, optimisticSnapshot);
264
263
  model.store.snapshot = optimisticSnapshot;
265
264
  // Direct snapshot mutation bypasses setState/syncState; invalidate
@@ -298,7 +297,7 @@ export function createMutationQueue(model, rerender) {
298
297
  title: "Action failed",
299
298
  message,
300
299
  retryLabel: "Retry",
301
- retryMutationId: mutation.id,
300
+ retryMutationId: mutation.mutationId,
302
301
  };
303
302
 
304
303
  if (typeof mutation.onError === "function") {
@@ -318,8 +317,10 @@ export function createMutationQueue(model, rerender) {
318
317
 
319
318
  return {
320
319
  enqueue(mutation) {
321
- queue.push({ ...mutation, id: nextMutationId });
322
- nextMutationId += 1;
320
+ queue.push({
321
+ ...mutation,
322
+ mutationId: mutation.mutationId ?? crypto.randomUUID(),
323
+ });
323
324
  processNext();
324
325
  },
325
326
 
@@ -569,7 +570,7 @@ export function createApi(model, options) {
569
570
  *
570
571
  * @param {object} model - Store with `applySnapshotDelta` method
571
572
  * @param {object} options
572
- * @param {string} options.sessionToken - Auth token (forwarded as ?token=)
573
+ * @param {string} options.sessionToken - Auth token for API parity; EventSource uses the same-origin HttpOnly cookie.
573
574
  * @param {function} options.rerender - Trigger UI rerender after applying deltas
574
575
  * @param {typeof EventSource} [options.EventSourceCtor] - Constructor override for tests
575
576
  * @param {string} [options.path] - Override stream path; default /api/snapshot/stream
@@ -577,7 +578,6 @@ export function createApi(model, options) {
577
578
  */
578
579
  export function subscribeSnapshotStream(model, options) {
579
580
  const {
580
- sessionToken,
581
581
  rerender,
582
582
  EventSourceCtor = typeof EventSource !== "undefined" ? EventSource : null,
583
583
  path = "/api/snapshot/stream",
@@ -587,14 +587,20 @@ export function subscribeSnapshotStream(model, options) {
587
587
  return { dispose: () => {}, eventSource: null };
588
588
  }
589
589
 
590
- // EventSource cannot set custom headers, so the auth token rides as a query
591
- // parameter. Server `extractToken` already accepts ?token=.
592
- const url = sessionToken && sessionToken.length > 0
593
- ? `${path}?token=${encodeURIComponent(sessionToken)}`
594
- : path;
595
-
596
590
  let disposed = false;
597
- const eventSource = new EventSourceCtor(url);
591
+ let consecutiveErrors = 0;
592
+ const eventSource = new EventSourceCtor(path);
593
+
594
+ const clearLiveUpdateNotice = () => {
595
+ if (model.store?.notice?.code === "live_updates_disconnected") {
596
+ model.store.notice = null;
597
+ }
598
+ };
599
+
600
+ const markLiveUpdateSuccess = () => {
601
+ consecutiveErrors = 0;
602
+ clearLiveUpdateNotice();
603
+ };
598
604
 
599
605
  const handleSnapshotDelta = (event) => {
600
606
  if (disposed) return;
@@ -613,6 +619,7 @@ export function subscribeSnapshotStream(model, options) {
613
619
  const delta = payload?.snapshotDelta;
614
620
  if (!delta || typeof delta !== "object") return;
615
621
  model.applySnapshotDelta(delta);
622
+ markLiveUpdateSuccess();
616
623
  if (typeof rerender === "function") rerender();
617
624
  };
618
625
 
@@ -632,27 +639,42 @@ export function subscribeSnapshotStream(model, options) {
632
639
  if (!snapshot || typeof snapshot !== "object") return;
633
640
  if (typeof model.replaceSnapshot === "function") {
634
641
  model.replaceSnapshot(snapshot);
642
+ markLiveUpdateSuccess();
635
643
  if (typeof rerender === "function") rerender();
636
644
  }
637
645
  };
638
646
 
647
+ function dispose() {
648
+ if (disposed) return;
649
+ disposed = true;
650
+ try {
651
+ eventSource.close();
652
+ } catch {
653
+ // best-effort
654
+ }
655
+ }
656
+
639
657
  const handleError = () => {
640
658
  if (disposed) return;
641
- // Surface the disconnect to the user so they don't silently miss live
642
- // updates. EventSource will keep auto-reconnecting; once a snapshot/
643
- // snapshotDelta event lands again, the regular notice clearing flow
644
- // (e.g. on the next mutation) replaces this notice.
659
+ consecutiveErrors += 1;
645
660
  if (model.store && typeof model.store === "object") {
646
661
  const existing = model.store.notice;
647
- if (!existing || existing.code !== "live_updates_disconnected") {
662
+ const disabled = consecutiveErrors >= 5;
663
+ const nextCode = disabled ? "live_updates_disabled" : "live_updates_disconnected";
664
+ if (!existing || existing.code !== nextCode) {
648
665
  model.store.notice = {
649
666
  type: "warning",
650
- code: "live_updates_disconnected",
651
- title: "Live updates disconnected",
652
- message: "Reconnecting to the server. Changes from other sessions may be delayed.",
667
+ code: nextCode,
668
+ title: disabled ? "Live updates disabled" : "Live updates disconnected",
669
+ message: disabled
670
+ ? "Refresh the board to resume live updates from other sessions."
671
+ : "Reconnecting to the server. Changes from other sessions may be delayed.",
653
672
  };
654
673
  if (typeof rerender === "function") rerender();
655
674
  }
675
+ if (disabled) {
676
+ dispose();
677
+ }
656
678
  }
657
679
  };
658
680
 
@@ -665,14 +687,6 @@ export function subscribeSnapshotStream(model, options) {
665
687
 
666
688
  return {
667
689
  eventSource,
668
- dispose() {
669
- if (disposed) return;
670
- disposed = true;
671
- try {
672
- eventSource.close();
673
- } catch {
674
- // best-effort
675
- }
676
- },
690
+ dispose,
677
691
  };
678
692
  }