@rubytech/create-sitedesk-code 0.1.507 → 0.1.509

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.
Files changed (87) hide show
  1. package/dist/index.js +53 -0
  2. package/package.json +2 -2
  3. package/payload/platform/config/brand.json +12 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1926-portal-push-concurrency-guard.md +482 -0
  5. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md +200 -0
  6. package/payload/platform/plugins/admin/PLUGIN.md +10 -5
  7. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +96 -13
  8. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +13 -0
  9. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +68 -28
  10. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +271 -29
  11. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +5 -4
  12. package/payload/platform/plugins/docs/references/admin-ui.md +95 -12
  13. package/payload/platform/plugins/memory/PLUGIN.md +6 -3
  14. package/payload/platform/scripts/__tests__/check-brand-cache.test.sh +231 -0
  15. package/payload/platform/scripts/__tests__/provision-honours-disabled-agents.test.sh +84 -0
  16. package/payload/platform/scripts/check-brand-cache.sh +138 -0
  17. package/payload/platform/scripts/check-risk-class.mjs +7 -2
  18. package/payload/platform/scripts/check-specialist-tool-surface.mjs +5 -2
  19. package/payload/platform/scripts/lib/canonical-tool-names.mjs +7 -4
  20. package/payload/platform/scripts/lib/provision-account-dir.sh +43 -0
  21. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +56 -3
  24. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts +19 -5
  26. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/tool-surface.js +50 -11
  28. package/payload/platform/services/claude-session-manager/dist/tool-surface.js.map +1 -1
  29. package/payload/server/maxy-edge.js +15 -10
  30. package/payload/server/public/activity.html +5 -5
  31. package/payload/server/public/agents.html +4 -4
  32. package/payload/server/public/assets/{AdminLoginScreens-YT7jsh_H.js → AdminLoginScreens-8Jk7hnWH.js} +1 -1
  33. package/payload/server/public/assets/{AdminShell-Ckht9hmU.js → AdminShell-BMPEWNZk.js} +1 -1
  34. package/payload/server/public/assets/{activity-DH3Wgc3O.js → activity-ByWYF5dC.js} +1 -1
  35. package/payload/server/public/assets/admin-CnUWbKcx.js +1 -0
  36. package/payload/server/public/assets/agents-C0rTfrYd.js +1 -0
  37. package/payload/server/public/assets/{browser-Bym8QhfA.js → browser-4tTaem8N.js} +1 -1
  38. package/payload/server/public/assets/{calendar-CFnufJOy.js → calendar-BEjnkKG1.js} +1 -1
  39. package/payload/server/public/assets/chat-DBgzjDIE.js +1 -0
  40. package/payload/server/public/assets/chevron-left-DqYv3oFh.js +1 -0
  41. package/payload/server/public/assets/chevron-right-CQfPGsFb.js +1 -0
  42. package/payload/server/public/assets/clock-Dn6FHB51.js +1 -0
  43. package/payload/server/public/assets/data-D_e6Vdfd.js +1 -0
  44. package/payload/server/public/assets/{file-text-zch0E1GJ.js → file-text-DMKw3nIk.js} +1 -1
  45. package/payload/server/public/assets/{graph-BavHLzpV.js → graph-C3vW8lvw.js} +1 -1
  46. package/payload/server/public/assets/{graph-labels-CBwh4g-6.js → graph-labels-BZgd0L6n.js} +1 -1
  47. package/payload/server/public/assets/{maximize-2-bTn_Ozpi.js → maximize-2-la3IBA3M.js} +1 -1
  48. package/payload/server/public/assets/{operator-a4IOwf62.js → operator-RRRdqoRQ.js} +1 -1
  49. package/payload/server/public/assets/{page-3T2mE63b.js → page-BWFHRIAH.js} +1 -1
  50. package/payload/server/public/assets/{page-D6M1CmPe.js → page-DC11gesX.js} +1 -1
  51. package/payload/server/public/assets/{public-B3y_YU1G.js → public-DQbbLHQN.js} +1 -1
  52. package/payload/server/public/assets/{rotate-ccw-BGvzNB1b.js → rotate-ccw-OQD5si8N.js} +1 -1
  53. package/payload/server/public/assets/{routines-C7Dr16am.js → routines-asrBGZUR.js} +1 -1
  54. package/payload/server/public/assets/{skills-DFeb3XO3.js → skills-D4ECwoxa.js} +1 -1
  55. package/payload/server/public/assets/{tasks-CSfnCKJe.js → tasks-U3QdbUsO.js} +1 -1
  56. package/payload/server/public/assets/{time-entry-format-BgZqOOqG.js → time-entry-format-BgdTKgYr.js} +1 -1
  57. package/payload/server/public/assets/{triangle-alert-DBJ7xz_t.js → triangle-alert-Ck_3VhT9.js} +1 -1
  58. package/payload/server/public/assets/{useCopyFeedback-BFOujMl-.js → useCopyFeedback-BdUwyNpa.js} +1 -1
  59. package/payload/server/public/assets/useSubAccountSwitcher-CJKjEPNN.js +9 -0
  60. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css +1 -0
  61. package/payload/server/public/assets/{useVoiceRecorder-BP6CpYQD.js → useVoiceRecorder-ChnBrzbZ.js} +1 -1
  62. package/payload/server/public/assets/{wrench-DdaRhfzS.js → wrench-Cpee3C3J.js} +1 -1
  63. package/payload/server/public/brand/fonts/inter-600.woff2 +0 -0
  64. package/payload/server/public/brand/fonts/inter-700.woff2 +0 -0
  65. package/payload/server/public/brand/fonts/inter-800.woff2 +0 -0
  66. package/payload/server/public/brand-defaults.css +22 -1
  67. package/payload/server/public/browser.html +4 -4
  68. package/payload/server/public/calendar.html +7 -7
  69. package/payload/server/public/chat.html +13 -13
  70. package/payload/server/public/data.html +11 -11
  71. package/payload/server/public/graph.html +9 -9
  72. package/payload/server/public/index.html +14 -14
  73. package/payload/server/public/operator.html +14 -14
  74. package/payload/server/public/public.html +13 -13
  75. package/payload/server/public/routines.html +6 -6
  76. package/payload/server/public/skills.html +5 -5
  77. package/payload/server/public/tasks.html +6 -6
  78. package/payload/server/server.js +1139 -427
  79. package/payload/server/public/assets/admin-DetvulAu.js +0 -1
  80. package/payload/server/public/assets/agents-X_IvDHLZ.js +0 -1
  81. package/payload/server/public/assets/chat-wrckyIpw.js +0 -1
  82. package/payload/server/public/assets/chevron-left-Dtk3jEK5.js +0 -1
  83. package/payload/server/public/assets/chevron-right-Dih6IZ8B.js +0 -1
  84. package/payload/server/public/assets/clock-iJCrrmWM.js +0 -1
  85. package/payload/server/public/assets/data-D76--TYs.js +0 -1
  86. package/payload/server/public/assets/useSubAccountSwitcher-0PfRrN6i.js +0 -9
  87. package/payload/server/public/assets/useSubAccountSwitcher-BkQ5tA8A.css +0 -1
@@ -0,0 +1,200 @@
1
+ # Task 1926 — same-account portal push concurrency guard (design)
2
+
3
+ **Date:** 2026-07-26
4
+ **Task:** [`.tasks/1926-concurrent-same-account-portal-push-lets-the-pre-stage-clear-delete-the-live-tree.md`](../../../../.tasks/1926-concurrent-same-account-portal-push-lets-the-pre-stage-clear-delete-the-live-tree.md)
5
+ **Touches:** `platform/plugins/cloudflare/bin/portal-index-push.mjs`, `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts`
6
+
7
+ ## Problem
8
+
9
+ `pushAccount` stages an account's file index at `next = currentGeneration + 1`, flips
10
+ the `directory_state` pointer to `next`, then sweeps every other generation. Task 1923
11
+ added a pre-stage `DELETE FROM directory WHERE accountId = ? AND generation = ?` bound
12
+ to `next`, so a stage that died mid-batch could self-heal instead of colliding forever
13
+ on `UNIQUE (accountId, relPath, generation)`.
14
+
15
+ Two pushes of one account break that. The second writer reads `current = 5` before the
16
+ first writer's flip, computes `next = 6`, and by the time its pre-stage DELETE runs the
17
+ pointer already names generation 6. The DELETE removes the live tree. A client listing
18
+ in the gap between that DELETE and the re-insert sees an empty portal.
19
+
20
+ The overlap is reachable: the admin server's 60s loop calls `pushAllAccounts`, and a
21
+ hand-run `node portal-index-push.mjs --account <id>` calls the same `pushAccount` from a
22
+ separate OS process.
23
+
24
+ ## What success looks like
25
+
26
+ Two concurrent pushes of one account never leave a reader seeing an empty or partial
27
+ tree, and never delete rows at the pointer's generation.
28
+
29
+ ## Why the three candidate approaches in the task file were rejected
30
+
31
+ **A per-account advisory lock.** The two racers are separate OS processes. A JavaScript
32
+ mutex inside the admin server does not exist in the CLI process, so it guards nothing.
33
+ A lock that does work across processes has to live in D1, which means new columns, a
34
+ lease, and a lease expiry. A lease expiry reintroduces the wedge class Task 1923 just
35
+ removed: a holder that dies wedges the account until the lease runs out, and a push
36
+ slower than its lease keeps writing after another writer has taken over.
37
+
38
+ **`next` from `MAX(generation)` alone.** This stops two writers sharing a staging
39
+ generation, but the slower writer still flips the pointer down onto its own generation
40
+ after the faster writer has already swept it. The reader still sees an empty tree, so
41
+ the success criterion is not met.
42
+
43
+ **Scoping the clear to `generation = next AND generation != current`.** `current` is a
44
+ value read at the top of the push. By the time the DELETE runs, the pointer has moved.
45
+ Re-reading the pointer immediately before the DELETE narrows the window without closing
46
+ it, because the peer can flip between the re-read and the DELETE.
47
+
48
+ ## Design
49
+
50
+ Three changes to `pushAccount`. No lock, no lease, no schema change, no new table.
51
+
52
+ ### 1. Claim a generation above everything that exists
53
+
54
+ Keep the existing pointer read, and add one statement beside it:
55
+
56
+ ```sql
57
+ SELECT MAX(generation) AS g FROM directory WHERE accountId = ?
58
+ ```
59
+
60
+ `next = Math.max(current, maxStagedGeneration) + 1`.
61
+
62
+ A peer that has already inserted at least one row is visible in that MAX, so the two
63
+ writers claim different generations and neither stages into the other's rows.
64
+
65
+ Two statements rather than one nested `UNION ALL`: each is trivially readable, each is
66
+ trivially fakeable in a test, and the pointer read stays the push's first statement,
67
+ which an existing test asserts.
68
+
69
+ ### 2. The flip only ever moves the pointer forward
70
+
71
+ ```sql
72
+ INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?)
73
+ ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration
74
+ WHERE excluded.currentGeneration > directory_state.currentGeneration
75
+ ```
76
+
77
+ SQLite has supported a `WHERE` clause on upsert's `DO UPDATE` since 3.24, and D1 is
78
+ SQLite. This is what makes a losing writer harmless: it cannot drag readers back onto a
79
+ generation the winner has already swept.
80
+
81
+ The D1 client returns rows, not affected-row meta, so the flip is followed by a pointer
82
+ readback. That readback is the branch point:
83
+
84
+ - **Pointer equals `next`.** We won. Log `op=flip`, sweep, verify, return. Unchanged
85
+ behaviour.
86
+ - **Pointer is above `next`.** A peer won. Log
87
+ `op=stage-contended account=<id> generation=<next> pointer=<actual> action=superseded`
88
+ and return without sweeping and without verifying. Both of those would measure the
89
+ peer's tree against our expected row count, and `op=verify` would read as a failure
90
+ when nothing failed.
91
+
92
+ Our staged rows at the losing generation are left in place. They are invisible (no
93
+ reader sees a generation the pointer does not name) and the winner's next sweep collects
94
+ them.
95
+
96
+ ### 3. The sweep only ever removes older generations
97
+
98
+ ```sql
99
+ DELETE FROM directory WHERE accountId = ? AND generation < ?
100
+ ```
101
+
102
+ `< next` rather than `!= next`, so a writer never deletes a peer's higher in-flight
103
+ stage. Rows above `next` from an abandoned stage still converge: the following cycle
104
+ claims a generation above them and sweeps them once it flips.
105
+
106
+ ### 4. The Task 1923 pre-stage clear is removed
107
+
108
+ Because `next` is now always above every existing generation for the account, the orphan
109
+ `SELECT COUNT(*) ... generation = next` can only ever return 0 and the pre-stage DELETE
110
+ can never match a row. Leaving unreachable code whose comment claims it is the wedge
111
+ heal would be false narration, so the orphan count, the DELETE, the `op=stage-clear` log
112
+ line and its two tests go.
113
+
114
+ Task 1923's outcome is preserved by construction and is stronger than before. A stage
115
+ that died mid-batch leaves rows at generation 6; the next run claims 7, so there is no
116
+ UNIQUE collision to freeze on, and the gen-6 orphans are swept after the flip. The heal
117
+ now happens with no DELETE before the flip at all, which is exactly what removes this
118
+ task's hazard rather than narrowing it.
119
+
120
+ This was confirmed with the operator before the spec was written, because Task 1926
121
+ lists Task 1923's heal as out of scope.
122
+
123
+ ## Cases covered, and the one left fail-closed
124
+
125
+ **Peer reads the generation after we have staged a row.** It claims a higher generation.
126
+ Both pushes complete. The forward-only flip picks the winner. The reader sees the old
127
+ complete tree, then one complete new tree. Neither push fails.
128
+
129
+ **Both read the generation before either inserts.** Both claim the same generation. The
130
+ second writer's first INSERT hits `UNIQUE (accountId, relPath, generation)` and that push
131
+ throws. Both walks resolve the same exposed set and `walkExposed` emits the exposed dirs
132
+ in the same order, so the second writer collides on its first row and contributes
133
+ nothing. The reader is safe: the winner's tree is complete and the pointer never moved
134
+ backward. The loser logs `op=failed` and the next 60s cycle succeeds.
135
+
136
+ This second case is left fail-closed rather than retried. The task's success criterion is
137
+ about what a reader can see, and a loud failure that self-corrects one cycle later meets
138
+ it. A retry-on-collision path would be an invented requirement.
139
+
140
+ **The one residual, stated rather than fixed.** The claim above relies on both walks
141
+ starting with the same row. If the account's `SCHEMA.md` or `data-portal.json` changes in
142
+ the window between the two generation reads, the two walks can begin with different rows,
143
+ so the second writer contributes some non-overlapping rows at the shared generation before
144
+ colliding on a shared one. The winner then flips onto its own complete tree plus those few
145
+ extra rows. That is not an empty tree and not a partial one, so it does not breach the
146
+ success criterion, and the next cycle's sweep removes the whole generation. No task file is
147
+ filed for it because nothing is deferred: the case is inside what this design covers, and
148
+ its outcome is a stale row for one cycle rather than a reader-visible break.
149
+
150
+ ## Observability
151
+
152
+ `op=stage-contended account=<id> generation=<next> pointer=<actual> action=superseded`
153
+ is emitted when a push's flip was superseded by a peer. This deviates from the task
154
+ file's `action=<waited|skipped>` wording: the design never waits and never skips work, so
155
+ neither value describes what happened. `superseded` names the actual outcome, and
156
+ `pointer=` names the generation that won, which is what an operator needs to correlate
157
+ the two runs.
158
+
159
+ `op=stage-clear` is retired with the code that emitted it. The task file's line that a
160
+ `stage-clear` with `cleared=` equal to the live tree size must never appear is satisfied
161
+ absolutely: no `stage-clear` line can appear at all.
162
+
163
+ ## Testing
164
+
165
+ Both new tests drive two interleaved `pushAccount` calls against one shared fake store
166
+ and snapshot the reader's view after every statement. The assertion is the success
167
+ criterion stated directly: the visible tree is never empty and never a strict subset of
168
+ a complete tree.
169
+
170
+ 1. **The task file's interleave.** Writer B reads the generation, then pauses. Writer A
171
+ runs to completion. B resumes. Red on current code, because B's pre-stage DELETE binds
172
+ the generation the pointer now names and the snapshot goes empty. Green after, because
173
+ B collides on UNIQUE and A's tree is untouched. This test also asserts that no DELETE
174
+ is ever issued naming the pointer's current generation, which is the other half of the
175
+ success criterion.
176
+
177
+ 2. **The higher-claim interleave.** Writer A stages fully but pauses before its flip.
178
+ Writer B reads the generation, sees A's staged rows in the MAX, claims the next one up,
179
+ and completes. A resumes and its flip is a no-op. Asserts the pointer never moves
180
+ backward, that A emits `op=stage-contended` with `action=superseded`, and that A
181
+ issues no sweep.
182
+
183
+ The existing 27 tests stay green apart from the two Task 1923 stage-clear tests, which
184
+ are removed with the code they cover.
185
+
186
+ ## Scope boundaries
187
+
188
+ **In scope:** the generation claim, the forward-only flip, the backward-only sweep, the
189
+ removal of the pre-stage clear, and the two-writer regression tests.
190
+
191
+ **Out of scope:** INSERT batching (Task 1842). Cross-account contention (separate
192
+ generation counters, no contention). Any change to `runTargets`, `recordSuccess`, or the
193
+ portal reader in `files.ts`.
194
+
195
+ ## Deployment
196
+
197
+ `portal-index-push.mjs` ships inside the installer payload and is inlined into the admin
198
+ server bundle. No D1 migration is required: every statement runs against the existing
199
+ `directory` and `directory_state` tables as defined in
200
+ `platform/plugins/cloudflare/skills/data-portal/schema.sql`.
@@ -148,11 +148,16 @@ tools:
148
148
  adminAllowlist: true
149
149
  riskClass: write_local
150
150
  hidden:
151
- - remote-auth-status
152
- - remote-auth-set-password
153
- - remote-auth-revoke-password
154
- - qr-generate
155
- - wifi
151
+ - name: remote-auth-status
152
+ riskClass: read
153
+ - name: remote-auth-set-password
154
+ riskClass: write_local
155
+ - name: remote-auth-revoke-password
156
+ riskClass: write_local
157
+ - name: qr-generate
158
+ riskClass: write_local
159
+ - name: wifi
160
+ riskClass: exec
156
161
  always: true
157
162
  embed: false
158
163
  mcp:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:314794449900df77759bdbbc4bbeadb2afb0e8d6479371d935e7030a18d47485
4
+ content-hash: sha256:aabbb6dbe5d9f4fe8ff6f7b3e2f06a46bd1c5281ea46b820829ab2b3bc93a0c0
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -3104,8 +3104,11 @@ not-found|rm-error>`.
3104
3104
 
3105
3105
  ### Agents
3106
3106
 
3107
- The Agents surface (`/agents`) is the admin view of the account's agents, of two
3108
- kinds, each card tagged `Public` or `Specialist`:
3107
+ The Agents surface (`/agents`) is the admin view of the account's agents. Each
3108
+ card is tagged by **origin**, which says who put the agent there and therefore
3109
+ what the operator may do with it. Origin is a different question to `kind`, which
3110
+ says what shape the agent is: a shipped specialist and a user-created one are
3111
+ both `kind: 'specialist'` and differ only by origin.
3109
3112
 
3110
3113
  - **Public agents** — the per-agent directories under `<accountDir>/agents/<slug>/`,
3111
3114
  each carrying a `config.json` (`displayName`, `status`, `model`,
@@ -3116,19 +3119,63 @@ kinds, each card tagged `Public` or `Specialist`:
3116
3119
  to the public URL (computed client-side: an `admin.` host maps to the `public.`
3117
3120
  host), plus a **delete** behind a confirm that runs the loud-fail `DELETE`
3118
3121
  (graph projection cleanup precedes file removal).
3119
- - **User-created specialists** — the agent files under
3122
+ - **User-created specialists** (`origin: 'specialist'`) — the agent files under
3120
3123
  `<accountDir>/plugins/<plugin>/agents/<plugin>--<name>.md` (written by
3121
- `agent-builder`). `<accountDir>/plugins` is the user-created tree, so only
3122
- user-created specialists list here; shipped premium and core specialists (whose
3123
- plugins live under `PLATFORM_ROOT`) do not. The specialist modal shows the
3124
- model, tools, the owning plugin's skills, and the system-prompt body. Specialists
3125
- are authored, edited, and deleted through chat (`agent-builder` /
3126
- `specialist-management`), so the specialist modal offers no delete or open-agent
3127
- link.
3124
+ `agent-builder`). The specialist modal shows the model, tools, the owning
3125
+ plugin's skills, and the system-prompt body. Specialists are authored, edited,
3126
+ and deleted through chat (`agent-builder` / `specialist-management`), so the
3127
+ specialist modal offers no delete or open-agent link.
3128
+ - **Shipped specialists** (`origin: 'shipped'`) the union of
3129
+ `<accountDir>/specialists/agents/`, `<accountDir>/specialists/agents-disabled/`
3130
+ and `PLATFORM_ROOT/templates/specialists/agents/`, in that precedence order.
3131
+ `sidebar-artefacts.ts` reads the same union minus the quarantine directory, so
3132
+ a disabled agent still appears there through its bundled-template row. The
3133
+ route once walked only the account plugins tree, so the twelve
3134
+ highest-capability agents on the box were the ones the operator could not see.
3135
+ These are the only agents that can be disabled, and the route enforces that
3136
+ rather than relying on the UI to withhold the control.
3128
3137
 
3129
3138
  The former "Public" flyout toggle in the account menu was removed; this page is
3130
3139
  the sole agent surface.
3131
3140
 
3141
+ **Risk class.** Every classified card carries a green/amber/red badge derived
3142
+ from the agent's declared `tools:` line: green when every tool is `read`, amber
3143
+ when the worst is `write_local`, red on any `exec` or `external`, and red when
3144
+ any tool resolves to no class at all. Resolution has two sources, because
3145
+ frontmatter names tools in Claude Code's namespace: a plugin tool is a direct
3146
+ lookup in `ToolSurface.riskByTool`, which is keyed by the same
3147
+ canonical `mcp__plugin_<p>_<p>__<tool>` string frontmatter writes, and the
3148
+ eleven built-ins carry their own table in `server/lib/agent-risk.ts`. An empty
3149
+ tool list is red: no `tools:` line means the full surface, not none of it.
3150
+
3151
+ A public agent carries `risk: null` and shows no badge. Its tool surface
3152
+ resolves through `ADMIN_CORE_TOOLS` plus the plugin allowlists rather than a
3153
+ `tools:` line, and classifying its empty list would paint every public agent red
3154
+ on a premise nothing established.
3155
+
3156
+ The detail modal renders one chip per declared tool carrying **that tool's own**
3157
+ class, so a `read` tool inside a red agent still reads as harmless; a tool with
3158
+ no class is dashed and uncoloured rather than painted as if it were understood.
3159
+ Badge colours come from the shared semantic status set: `-solid` with white text
3160
+ for the badge, `-tint` with `-textOnTint` for a chip, never white.
3161
+
3162
+ **Disable.** `<accountDir>/specialists/agents/` is the live dispatchable set —
3163
+ `spawn-context.ts` reads it to build the spawn manifest — so disabling **moves**
3164
+ the agent file to `<accountDir>/specialists/agents-disabled/` and records the
3165
+ basename in a 0600 `<accountDir>/agents-disabled.json`. A flag alone would leave
3166
+ the agent running while the card claimed otherwise.
3167
+
3168
+ The file is moved, never deleted: a premium `--` agent and an operator-edited
3169
+ override each exist only in the account dir, so deleting one and later restoring
3170
+ from the bundled template would hand back a different agent than the operator
3171
+ switched off. A disabled agent stays listed, marked `Disabled`, because an agent
3172
+ that vanished from the surface that disabled it could never be switched back on.
3173
+
3174
+ `provision-account-dir.sh` re-reads the store after its core-specialist recopy
3175
+ and removes anything named in it, so an upgrade does not silently re-deliver a
3176
+ disabled agent. An unreadable store withholds nothing and says so: a corrupt
3177
+ file is not evidence that an agent was disabled.
3178
+
3132
3179
  **Account scope.** The route (`server/routes/admin/agents.ts`) never infers an
3133
3180
  account from device state. The two reads resolve from the caller's admin session
3134
3181
  (`requireAdminSession` + the shared `accountDirForSession` in
@@ -3142,8 +3189,10 @@ explicit `?plugin=` parameter, not inferred from `--` in the slug.
3142
3189
 
3143
3190
  | Route | Behaviour |
3144
3191
  |-------|-----------|
3145
- | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent) plus its user-created specialists from `plugins/*/agents/*.md`, each row tagged `kind`. Returns `{agents, accountId, skipped, specialistsSkipped}``accountId` names the account resolved (the client sends it back on a delete); `skipped` counts public dirs whose `config.json` failed to parse; `specialistsSkipped` counts specialist `.md` files with no parseable `name`. 401 when the session maps to no account. |
3146
- | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, returns that plugin's specialist `{kind, model, tools, systemPrompt, skills}`. Unknown 404s, 401 as above. |
3192
+ | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
3193
+ | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
3194
+ | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Same `accountId` contract as delete. |
3195
+ | `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. |
3147
3196
  | `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
3148
3197
  | `POST /api/admin/agents/:slug/project?accountId=` | Re-projects the named account's on-disk agent into the graph. Same `accountId` contract as delete. |
3149
3198
 
@@ -3170,6 +3219,40 @@ Agents page survived undiagnosed on a multi-account install.
3170
3219
  - `op=delete accountId=<id8> slug=<…> outcome=<ok|failed>` with
3171
3220
  `reason=<graph-cleanup-failed|rm-error>` on failure (files preserved on a
3172
3221
  graph-cleanup throw).
3222
+ - `op=classify account=<id8> agent=<name> origin=<shipped|public|specialist>
3223
+ risk=<green|amber|red> riskiestTool=<tool> tools=<n> unresolved=<comma-list|none>
3224
+ disabled=<bool>` — one line per classified agent per listing, so it fires on
3225
+ every page load. `riskiestTool` makes a wrong class diagnosable without
3226
+ re-deriving it. Its value is the FULL canonical tool name, while the card
3227
+ renders only the segment after the plugin prefix. One load
3228
+ showing `riskiest tool: memory-update` on the card and
3229
+ `riskiestTool=mcp__plugin_memory_memory__memory-update` on this line is the
3230
+ confirmation that both derive from one value.
3231
+ **A non-empty `unresolved=` is the one to act on**: the agent
3232
+ is forced red, and the cause is that the built-in table or the registry
3233
+ mapping has drifted, not that the agent changed.
3234
+ - `op=risk-surface status=load-failed reason=<msg>` — the plugin registry did
3235
+ not parse, so every row is forced red and the listing returns
3236
+ `riskSurfaceFailed: true`. Without the forced red, every plugin tool would
3237
+ land in `unresolved` and a registry outage would read as agent drift across
3238
+ every card at once; without the response field, a red row would be
3239
+ indistinguishable from a genuinely dangerous agent, because nothing resolved
3240
+ so `unresolved` is empty and the per-tool explanation cannot fire.
3241
+ - `op=disabled-store status=parse-failed reason=<msg>` — the listing then treats
3242
+ every agent as enabled, which is what they actually are. A read surface shows
3243
+ the true dispatchable set rather than a comforting one.
3244
+ - `op=<disable|enable> accountId=<id8> slug=<…> outcome=<ok|failed>`, with
3245
+ `moved=<bool>` on disable and `reason=nothing-quarantined` on a failed enable.
3246
+
3247
+ **Disable is a no-event failure, so it needs a standing check.** If the store
3248
+ names an agent whose file is still in `specialists/agents/`, nothing throws, the
3249
+ card reads disabled, and the agent keeps being dispatched. The five-minute
3250
+ `account-dir-schema-reconcile` pass reports it as
3251
+ `[fs-reconcile] op=agent-parity account=<id8> disabled=<n> stillOnDisk=<list>`,
3252
+ fired only on disagreement. The per-cycle heartbeat is `agent-drift=0` on that
3253
+ pass's summary line, which is what makes the absence of a per-account line
3254
+ readable as "clean" rather than "never audited" — grep the summary line, not the
3255
+ per-account one, to confirm the check is running.
3173
3256
 
3174
3257
  ### Graph
3175
3258
 
@@ -9,6 +9,19 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-07-26 (0.1.509)
13
+
14
+ - The Agents page now names the tool that set an agent's risk level, and shows it by its short name instead of the full internal path.
15
+ - Publishing a site twice at the same time can no longer delete the live copy; the later publish now stands down instead of clearing the tree out from under the first.
16
+ - A slow dashboard now records where the time actually went, so a sluggish load can be traced rather than guessed at.
17
+
18
+ ## 2026-07-25 (0.1.508)
19
+
20
+ - SiteDesk's screens now use a single, cleaner typeface throughout, and the old olive-green colour is gone.
21
+ - You can now disable an agent from the Agents page, and each one shows its risk level.
22
+ - Dashboard sections appear as soon as each one loads, instead of the whole page waiting on the slowest one.
23
+ - Real Agent's icons and imagery were redrawn to match its colour palette.
24
+
12
25
  ## 2026-07-25 (0.1.507)
13
26
 
14
27
  - Chat now updates the moment a message or permission prompt arrives, instead of checking every couple of seconds.
@@ -122,7 +122,8 @@ export async function walkExposed(accountDir, exposed) {
122
122
  * client: { query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> },
123
123
  * log: (line: string) => void, nowIso: string,
124
124
  * exposeFolders?: string[], previousExposed?: string[] }} opts
125
- * @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean }>}
125
+ * @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean,
126
+ * generation?: number, superseded?: boolean }>}
126
127
  */
127
128
  export async function pushAccount(opts) {
128
129
  const { accountDir, accountId, client, log, nowIso } = opts
@@ -196,29 +197,26 @@ export async function pushAccount(opts) {
196
197
  [accountId],
197
198
  )
198
199
  const current = Number(state[0]?.currentGeneration ?? 0)
199
- const next = current + 1
200
-
201
- // Crash safety (Task 1923). A stage that died mid-batch — a D1 500 on one
202
- // INSERT leaves rows at `next` while the pointer stays at `current`. Every
203
- // later run recomputes the same `next`, and its first colliding INSERT then
204
- // hits UNIQUE(accountId, relPath, generation), so the index freezes at the
205
- // stale generation until an operator hand-clears the orphans. Clearing `next`
206
- // before staging lets a partial stage self-heal on the next cycle. Guarded on
207
- // a non-zero count so the DELETE never precedes the flip on a clean cycle and
208
- // the log line always means a recovered wedge. The count comes from a SELECT
209
- // because the D1 client returns rows, not the affected-row meta.
210
- const orphan = await client.query(
211
- 'SELECT COUNT(*) AS n FROM directory WHERE accountId = ? AND generation = ?',
212
- [accountId, next],
200
+
201
+ // Claim a generation above EVERY generation this account already holds, not
202
+ // merely above the pointer (Task 1926). Two pushes of one account are
203
+ // reachable: the admin server's 60s loop and a hand-run `--account` are
204
+ // separate processes, so no in-process lock can see both. `current + 1` let
205
+ // the second writer claim the generation the first had just flipped onto and
206
+ // then delete it. A peer that has inserted even one row is visible in this
207
+ // MAX, so the two runs claim different generations.
208
+ //
209
+ // This also retires the Task 1923 pre-stage clear. That clear existed because
210
+ // a later run recomputed the SAME generation as a dead partial stage and
211
+ // collided on UNIQUE(accountId, relPath, generation); claiming above the
212
+ // orphans leaves nothing to collide with, and the post-flip sweep collects
213
+ // them. The clear was itself the DELETE a concurrent peer could aim at the
214
+ // live tree, so removing it removes the hazard rather than narrowing it.
215
+ const staged = await client.query(
216
+ 'SELECT MAX(generation) AS g FROM directory WHERE accountId = ?',
217
+ [accountId],
213
218
  )
214
- const orphanCount = Number(orphan[0]?.n ?? 0)
215
- if (orphanCount > 0) {
216
- await client.query('DELETE FROM directory WHERE accountId = ? AND generation = ?', [
217
- accountId,
218
- next,
219
- ])
220
- log(`${TAG} op=stage-clear account=${accountId} generation=${next} cleared=${orphanCount}`)
221
- }
219
+ const next = Math.max(current, Number(staged[0]?.g ?? 0)) + 1
222
220
 
223
221
  for (const row of rows) {
224
222
  await client.query(
@@ -231,18 +229,56 @@ export async function pushAccount(opts) {
231
229
 
232
230
  // THE atomic point. Upsert rather than insert-or-update, so an account
233
231
  // publishing for the first time and one republishing take the same path.
232
+ //
233
+ // The WHERE moves the pointer FORWARD ONLY (Task 1926). Without it a writer
234
+ // that was overtaken while staging drags every reader back onto a generation
235
+ // the winner has already swept, which is an empty tree. SQLite has carried a
236
+ // WHERE on upsert's DO UPDATE since 3.24, and D1 is SQLite.
234
237
  await client.query(
235
238
  'INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?) ' +
236
- 'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration',
239
+ 'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration ' +
240
+ 'WHERE excluded.currentGeneration > directory_state.currentGeneration',
237
241
  [accountId, next],
238
242
  )
243
+
244
+ // Read back rather than assume: the D1 client returns rows, not affected-row
245
+ // meta, so a suppressed flip is otherwise indistinguishable from an applied
246
+ // one.
247
+ const flipped = await client.query(
248
+ 'SELECT currentGeneration FROM directory_state WHERE accountId = ?',
249
+ [accountId],
250
+ )
251
+ const pointer = Number(flipped[0]?.currentGeneration ?? 0)
252
+ if (pointer !== next) {
253
+ // A peer published while this run was staging. Its tree is live and
254
+ // complete, ours is invisible, and its rows are collected by the peer's
255
+ // next sweep. Sweeping or verifying from here would act on, and measure,
256
+ // the PEER's tree — reporting a failure where nothing failed. Logged rather
257
+ // than silent: a suppressed run is otherwise indistinguishable from one
258
+ // that never started.
259
+ log(
260
+ `${TAG} op=stage-contended account=${accountId} generation=${next} ` +
261
+ `pointer=${pointer} action=superseded`,
262
+ )
263
+ return {
264
+ exposed: r.exposed,
265
+ rows: rows.length,
266
+ schemaPresent: true,
267
+ generation: next,
268
+ superseded: true,
269
+ }
270
+ }
239
271
  log(`${TAG} op=flip account=${accountId} generation=${next}`)
240
272
 
241
273
  // Housekeeping only. A failure here leaves stale rows nothing reads, which is
242
274
  // waste rather than incorrectness — so it must not fail the publish that has
243
275
  // already succeeded.
244
276
  try {
245
- await client.query('DELETE FROM directory WHERE accountId = ? AND generation != ?', [
277
+ // Strictly older only (Task 1926): `!= next` would delete a peer's HIGHER
278
+ // in-flight stage. Rows above `next` left by an abandoned stage still
279
+ // converge, because the next cycle claims a generation above them and
280
+ // sweeps them once it flips.
281
+ await client.query('DELETE FROM directory WHERE accountId = ? AND generation < ?', [
246
282
  accountId,
247
283
  next,
248
284
  ])
@@ -291,9 +327,13 @@ export function readState(platformRoot) {
291
327
  }
292
328
 
293
329
  /**
294
- * Written ONLY after op=verify, so a stale entry means the push stopped
295
- * succeeding rather than merely stopped logging. Read-modify-write per account
296
- * so one account's failure does not erase another's record.
330
+ * Written after a publish that reached the flip, so a stale entry means the
331
+ * push stopped succeeding rather than merely stopped logging. That includes a
332
+ * run superseded by a concurrent peer (Task 1926), which emits
333
+ * `op=stage-contended` instead of `op=verify`: the index WAS refreshed inside
334
+ * this cycle, by the peer, and recording otherwise would report a healthy
335
+ * account as never-ran. Read-modify-write per account so one account's failure
336
+ * does not erase another's record.
297
337
  * @param {string} platformRoot @param {string} accountId
298
338
  * @param {{lastPushMs: number, rows: number, exposed: string[]}} entry
299
339
  */