@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.
- package/dist/index.js +53 -0
- package/package.json +2 -2
- package/payload/platform/config/brand.json +12 -0
- package/payload/platform/docs/superpowers/plans/2026-07-26-task-1926-portal-push-concurrency-guard.md +482 -0
- package/payload/platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md +200 -0
- package/payload/platform/plugins/admin/PLUGIN.md +10 -5
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +96 -13
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +13 -0
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +68 -28
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +271 -29
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +5 -4
- package/payload/platform/plugins/docs/references/admin-ui.md +95 -12
- package/payload/platform/plugins/memory/PLUGIN.md +6 -3
- package/payload/platform/scripts/__tests__/check-brand-cache.test.sh +231 -0
- package/payload/platform/scripts/__tests__/provision-honours-disabled-agents.test.sh +84 -0
- package/payload/platform/scripts/check-brand-cache.sh +138 -0
- package/payload/platform/scripts/check-risk-class.mjs +7 -2
- package/payload/platform/scripts/check-specialist-tool-surface.mjs +5 -2
- package/payload/platform/scripts/lib/canonical-tool-names.mjs +7 -4
- package/payload/platform/scripts/lib/provision-account-dir.sh +43 -0
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +56 -3
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts +19 -5
- package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/tool-surface.js +50 -11
- package/payload/platform/services/claude-session-manager/dist/tool-surface.js.map +1 -1
- package/payload/server/maxy-edge.js +15 -10
- package/payload/server/public/activity.html +5 -5
- package/payload/server/public/agents.html +4 -4
- package/payload/server/public/assets/{AdminLoginScreens-YT7jsh_H.js → AdminLoginScreens-8Jk7hnWH.js} +1 -1
- package/payload/server/public/assets/{AdminShell-Ckht9hmU.js → AdminShell-BMPEWNZk.js} +1 -1
- package/payload/server/public/assets/{activity-DH3Wgc3O.js → activity-ByWYF5dC.js} +1 -1
- package/payload/server/public/assets/admin-CnUWbKcx.js +1 -0
- package/payload/server/public/assets/agents-C0rTfrYd.js +1 -0
- package/payload/server/public/assets/{browser-Bym8QhfA.js → browser-4tTaem8N.js} +1 -1
- package/payload/server/public/assets/{calendar-CFnufJOy.js → calendar-BEjnkKG1.js} +1 -1
- package/payload/server/public/assets/chat-DBgzjDIE.js +1 -0
- package/payload/server/public/assets/chevron-left-DqYv3oFh.js +1 -0
- package/payload/server/public/assets/chevron-right-CQfPGsFb.js +1 -0
- package/payload/server/public/assets/clock-Dn6FHB51.js +1 -0
- package/payload/server/public/assets/data-D_e6Vdfd.js +1 -0
- package/payload/server/public/assets/{file-text-zch0E1GJ.js → file-text-DMKw3nIk.js} +1 -1
- package/payload/server/public/assets/{graph-BavHLzpV.js → graph-C3vW8lvw.js} +1 -1
- package/payload/server/public/assets/{graph-labels-CBwh4g-6.js → graph-labels-BZgd0L6n.js} +1 -1
- package/payload/server/public/assets/{maximize-2-bTn_Ozpi.js → maximize-2-la3IBA3M.js} +1 -1
- package/payload/server/public/assets/{operator-a4IOwf62.js → operator-RRRdqoRQ.js} +1 -1
- package/payload/server/public/assets/{page-3T2mE63b.js → page-BWFHRIAH.js} +1 -1
- package/payload/server/public/assets/{page-D6M1CmPe.js → page-DC11gesX.js} +1 -1
- package/payload/server/public/assets/{public-B3y_YU1G.js → public-DQbbLHQN.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-BGvzNB1b.js → rotate-ccw-OQD5si8N.js} +1 -1
- package/payload/server/public/assets/{routines-C7Dr16am.js → routines-asrBGZUR.js} +1 -1
- package/payload/server/public/assets/{skills-DFeb3XO3.js → skills-D4ECwoxa.js} +1 -1
- package/payload/server/public/assets/{tasks-CSfnCKJe.js → tasks-U3QdbUsO.js} +1 -1
- package/payload/server/public/assets/{time-entry-format-BgZqOOqG.js → time-entry-format-BgdTKgYr.js} +1 -1
- package/payload/server/public/assets/{triangle-alert-DBJ7xz_t.js → triangle-alert-Ck_3VhT9.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-BFOujMl-.js → useCopyFeedback-BdUwyNpa.js} +1 -1
- package/payload/server/public/assets/useSubAccountSwitcher-CJKjEPNN.js +9 -0
- package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css +1 -0
- package/payload/server/public/assets/{useVoiceRecorder-BP6CpYQD.js → useVoiceRecorder-ChnBrzbZ.js} +1 -1
- package/payload/server/public/assets/{wrench-DdaRhfzS.js → wrench-Cpee3C3J.js} +1 -1
- package/payload/server/public/brand/fonts/inter-600.woff2 +0 -0
- package/payload/server/public/brand/fonts/inter-700.woff2 +0 -0
- package/payload/server/public/brand/fonts/inter-800.woff2 +0 -0
- package/payload/server/public/brand-defaults.css +22 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +7 -7
- package/payload/server/public/chat.html +13 -13
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +9 -9
- package/payload/server/public/index.html +14 -14
- package/payload/server/public/operator.html +14 -14
- package/payload/server/public/public.html +13 -13
- package/payload/server/public/routines.html +6 -6
- package/payload/server/public/skills.html +5 -5
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +1139 -427
- package/payload/server/public/assets/admin-DetvulAu.js +0 -1
- package/payload/server/public/assets/agents-X_IvDHLZ.js +0 -1
- package/payload/server/public/assets/chat-wrckyIpw.js +0 -1
- package/payload/server/public/assets/chevron-left-Dtk3jEK5.js +0 -1
- package/payload/server/public/assets/chevron-right-Dih6IZ8B.js +0 -1
- package/payload/server/public/assets/clock-iJCrrmWM.js +0 -1
- package/payload/server/public/assets/data-D76--TYs.js +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-0PfRrN6i.js +0 -9
- 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
|
-
|
|
153
|
-
- remote-auth-
|
|
154
|
-
|
|
155
|
-
-
|
|
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:
|
|
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
|
|
3108
|
-
|
|
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`).
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
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)
|
|
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=`,
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
295
|
-
* succeeding rather than merely stopped logging.
|
|
296
|
-
*
|
|
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
|
*/
|