Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb875ca79 | ||
|
|
c5b95becd2 | ||
|
|
f4b9596a88 | ||
|
|
09518e1e1f | ||
|
|
500bb925eb | ||
|
|
a970cb7c5f | ||
|
|
fb1cf0f6f1 | ||
|
|
a947df8d9d | ||
|
|
78bf63c209 | ||
|
|
347acf0171 | ||
|
|
766b18b7f9 | ||
|
|
e4f5e03105 | ||
|
|
3b0083aec2 |
@@ -243,7 +243,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
|||||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||||
never grantable at any level). **21 entries as of 2026-08-27** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
never grantable at any level). **20 entries as of 2026-08-29** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 5 admin. It was 21 until the todo board became the `kanban` plugin and took `todos` with it — the same shape as caldav below, and for the same reason: a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||||
and `confined` stripped for an account with no `osUser`.
|
and `confined` stripped for an account with no `osUser`.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 2026.08.29.01 — deploy report
|
||||||
|
|
||||||
|
**7 of 7 target hosts succeeded. 1 skipped (`officer-dev`, the host this rollout ran on).**
|
||||||
|
|
||||||
|
Every target ended at `58f50a52`, the tag commit. Tag and `package.json` agreed before anything was
|
||||||
|
touched. No host failed, none was left half-applied, and nothing was reset or discarded.
|
||||||
|
|
||||||
|
## The two facts this release is about
|
||||||
|
|
||||||
|
**Every host prints a libvips version.** `sharp@0.35.4` installed and loaded on all seven, `libvips
|
||||||
|
8.18.6` everywhere — x86_64 and arm64, Node v22 and v24. The plan flagged `edge-pertento-bridge` (Node
|
||||||
|
v24) as the most likely failure; it was not one.
|
||||||
|
|
||||||
|
**Every host now has ffmpeg.** Six already had it. `macbruno` did not, and the plan's per-host `brew
|
||||||
|
install ffmpeg` ran there before the restart and succeeded — so this release ships with no ffmpeg caveat
|
||||||
|
outstanding on any host.
|
||||||
|
|
||||||
|
| Host | HEAD before → after | install | libvips | ffmpeg | officer |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `officer-dev` | — | — | — | — | **SKIPPED — rollout host** |
|
||||||
|
| `alpha` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 2→3, 401 |
|
||||||
|
| `fofos` | `de425d9d` → `58f50a52` | no-op | 8.18.6 | 6.1.1 (had) | online, rt 25→26, 401 |
|
||||||
|
| `macbookpro` | `16fa8692` → `58f50a52` | 10 pkgs | 8.18.6 | 8.1.2 (had) | online, rt 2→3, 401 :9010 |
|
||||||
|
| `macbruno` | `9c8af9d4` → `58f50a52` | 10 pkgs | 8.18.6 | **9.0.1 (installed)** | online, rt 5→6, 401 |
|
||||||
|
| `production-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 3→4, 401 :9010 |
|
||||||
|
| `edge-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 4→5, 401 |
|
||||||
|
| `deadpool-rezio-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 21→22, 401 |
|
||||||
|
|
||||||
|
Health is `/api/health` on each host's own port, read from its `.env` rather than assumed. `401` is
|
||||||
|
healthy per the plan. `production-pertento-bridge` was checked on 9010, not 9000.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
**Unchanged, and no `bun db:push` was run anywhere.** The plan says so and I verified it rather than
|
||||||
|
taking its word:
|
||||||
|
|
||||||
|
```
|
||||||
|
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
|
||||||
|
```
|
||||||
|
|
||||||
|
returns nothing. No table, column or constraint in this release, so the "check the database by query"
|
||||||
|
rule had nothing to check and no SQL was improvised. Plugin schema lines in `plugins:update` output
|
||||||
|
(`schema: applied in …ms, N table(s) verified`) are that script's own per-plugin verification, not this
|
||||||
|
release.
|
||||||
|
|
||||||
|
## The one deviation, and why
|
||||||
|
|
||||||
|
**On `macbookpro` the standard step order failed, and I ran the same steps in the other order.**
|
||||||
|
|
||||||
|
`bun run plugins:update` exited 1 with:
|
||||||
|
|
||||||
|
```
|
||||||
|
error: Cannot find package 'sharp' from '…/src/servers/api/file-browser/thumbnails.ts'
|
||||||
|
```
|
||||||
|
|
||||||
|
The updater imports the server graph, which now reaches the new `thumbnails.ts`, which imports `sharp`.
|
||||||
|
The standard steps in `prompts/deploy-commander.md` put `plugins:update` **before** the plan's `bun
|
||||||
|
install` — so on a host where sharp is genuinely new, the step cannot resolve a dependency the same
|
||||||
|
release is about to add.
|
||||||
|
|
||||||
|
I ran `bun install` first, then `plugins:update`, which then exited 0. Both steps ran, none was skipped
|
||||||
|
and nothing was composed — only the order changed. I did the same on the four hosts after it. `alpha`
|
||||||
|
and `fofos` had already passed in the documented order because sharp was on disk for reasons of their
|
||||||
|
own: `alpha` via a plugin's declared npm dependencies, `fofos` as the pre-tag test host.
|
||||||
|
|
||||||
|
**This is a finding for whoever writes the next plan, not a fix I made.** Any future release that adds a
|
||||||
|
runtime dependency imported by the server graph hits the same wall in the same place. The ordering lives
|
||||||
|
in the commander prompt's standard steps, and correcting it is a documentation change, which a rollout
|
||||||
|
does not make.
|
||||||
|
|
||||||
|
## What else moved
|
||||||
|
|
||||||
|
`bun run plugins:update` restarts sidecars itself. It reported `sidecar: started` on `alpha` (15/15
|
||||||
|
plugins, `officer-music` re-added), `fofos` (5/5), `macbruno` (1/1), `production-pertento-bridge` (1/1,
|
||||||
|
`officer-gitea`), `edge-pertento-bridge` (2/2, `officer-offscale`) and `deadpool-rezio-bridge` (2/2).
|
||||||
|
`macbookpro` has no plugins installed. So more than `officer` bounced on most hosts — that is the
|
||||||
|
updater's own behaviour, not a restart this rollout asked for. The only restart it issued was `pm2
|
||||||
|
restart officer`, per host, by name.
|
||||||
|
|
||||||
|
`production-pertento-bridge`: all eight non-Officer Pertento processes were still `online` after the
|
||||||
|
restart. Nothing there was named or touched.
|
||||||
|
|
||||||
|
## Notes, not problems
|
||||||
|
|
||||||
|
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
|
||||||
|
cannot touch it. No host had tracked changes; no work was discarded anywhere.
|
||||||
|
- `production-pertento-bridge` prints an nvm warning (`has a globalconfig and/or a prefix setting…`) on
|
||||||
|
every `bun`/`node` invocation. Pre-existing, unrelated to this release, harmless to it.
|
||||||
|
- `macbruno`: `officer`'s PM2 environment already carries `/opt/homebrew/bin`, so the newly installed
|
||||||
|
ffmpeg is on the restarted process's PATH. Verified, not assumed.
|
||||||
|
|
||||||
|
## For the owner
|
||||||
|
|
||||||
|
- **`officer-dev` is deliberately a step behind the fleet.** Its checkout is already at `58f50a52`, but
|
||||||
|
nothing was pulled, installed or restarted on it, and `bun install` has NOT run there — so sharp is
|
||||||
|
not necessarily present for the running process. Restart it deliberately when you are happy with the
|
||||||
|
rest, and run `bun install` first or the thumbnails endpoint will not have its dependency.
|
||||||
|
- The `plugins:update` ordering above is worth fixing in the prompt before the next release that adds a
|
||||||
|
dependency.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# 2026.08.29.02 — a plugin can ship a commander
|
||||||
|
|
||||||
|
**This one changes the database.** Two columns, both nullable additions, on a table that already exists.
|
||||||
|
There is no rename in it, nothing is dropped, and no existing row changes — but `db:push` is still not
|
||||||
|
to be trusted on its exit code, so there is a per-host query below and the report must carry its answer
|
||||||
|
for each host.
|
||||||
|
|
||||||
|
No dependency change: `package.json` and `bun.lock` are untouched since `2026.08.29.01`. **There is no
|
||||||
|
`bun install` step in this plan** — which also sidesteps the step-order problem the last rollout found,
|
||||||
|
where the standard `plugins:update` ran before the plan's install.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **Plugins can ship commanders.** A plugin with `commanders/<name>/COMMANDER.md` offers one; it appears
|
||||||
|
in Command HQ under a new **From plugins** section, arrives **not running**, and must be configured
|
||||||
|
before it can arm.
|
||||||
|
- **av-club ships the first one** — *Convert on arrival*: watches a folder, converts what lands in it.
|
||||||
|
It reaches each host through the standard `bun run plugins:update` step, not through this plan.
|
||||||
|
- Marketplace, file browser and Convert Image work from earlier today rides along.
|
||||||
|
|
||||||
|
## Schema — CHANGED. `db:push` is required.
|
||||||
|
|
||||||
|
```
|
||||||
|
command_hq_sessions
|
||||||
|
+ plugin text null which plugin's template made this commander
|
||||||
|
+ plugin_config jsonb null the owner's answers to that template's inputs
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified as the whole of it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only deploy/2026.08.29.01..deploy/2026.08.29.02 | grep -iE 'schema|/db/|databases'
|
||||||
|
# → src/databases/officer_db/src/command-hq/queries.ts
|
||||||
|
# src/databases/officer_db/src/command-hq/schema.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
`queries.ts` is code, not schema. The only structural change is the two columns above.
|
||||||
|
|
||||||
|
**Both are nullable with no default and no constraint**, so every existing row is valid the moment they
|
||||||
|
exist and there is nothing to backfill. This is the shape `db:push` handles cleanly — there is no
|
||||||
|
create-then-constrain here, and no `--force` is needed or permitted.
|
||||||
|
|
||||||
|
## Every host — the commands
|
||||||
|
|
||||||
|
After the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin --tags`,
|
||||||
|
`git merge --ff-only deploy/2026.08.29.02`, `bun run plugins:update`), in the checkout from PM2's
|
||||||
|
`pm_cwd`:
|
||||||
|
|
||||||
|
### 1. Back the database up first
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_dump "$(grep -m1 -E '^(OFFICERDEV_)?POSTGRES_URL=' .env | cut -d= -f2-)" > ~/db-backups/officer-$(date +%Y%m%d-%H%M).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the variable name rather than assuming it: **several hosts use `OFFICERDEV_POSTGRES_URL`**, not
|
||||||
|
`POSTGRES_URL`. `mkdir -p ~/db-backups` first if it is not there.
|
||||||
|
|
||||||
|
### 2. Push the schema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun db:push < /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**stdin closed**, so it can never sit waiting on a prompt in an unattended run — it fails instead, which
|
||||||
|
is the outcome you want. Expect `[✓] Changes applied` and then the two post-push scripts.
|
||||||
|
|
||||||
|
### 3. VERIFY BY QUERY. The exit code is not the answer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun -e "
|
||||||
|
const { db } = await import('officerdb/db'); const { sql } = await import('drizzle-orm');
|
||||||
|
const r = await db.execute(sql\`select column_name from information_schema.columns
|
||||||
|
where table_name='command_hq_sessions' and column_name in ('plugin','plugin_config') order by column_name\`);
|
||||||
|
console.log([...((r as any).rows ?? r)].map((x:any)=>x.column_name).join(' '));
|
||||||
|
process.exit(0)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Must print exactly:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugin plugin_config
|
||||||
|
```
|
||||||
|
|
||||||
|
**Put that line in the report, per host.** Not "schema ✓" — the output. A host printing one column, or
|
||||||
|
none, is a **failed host**: report it, move on, and do not attempt a repair you invented.
|
||||||
|
|
||||||
|
### 4. Restart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart officer
|
||||||
|
```
|
||||||
|
|
||||||
|
`officer` only. `plugins:update` has already restarted the plugin sidecars itself.
|
||||||
|
|
||||||
|
### 5. Check it came back
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
|
||||||
|
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are both healthy.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
| Host | Note |
|
||||||
|
| --- | --- |
|
||||||
|
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. Its schema is already pushed; nothing else is needed. Name it as skipped. |
|
||||||
|
| `alpha` | Nothing special. |
|
||||||
|
| `fofos` | The one that matters this time — `av-club` is installed there and the commander template is the point of the release. |
|
||||||
|
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
|
||||||
|
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
|
||||||
|
| `production-pertento-bridge` | Port **9010**. |
|
||||||
|
| `edge-pertento-bridge` | Nothing special. |
|
||||||
|
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||||
|
|
||||||
|
## What "done" looks like
|
||||||
|
|
||||||
|
Per host: HEAD before and after, whether the backup was written, `db:push` result, **the exact output of
|
||||||
|
the verification query**, and `officer` back online with a moved `restart_time`.
|
||||||
|
|
||||||
|
The line that matters in the summary: **which hosts print `plugin plugin_config`**. That is the only
|
||||||
|
thing this release actually changes about a machine.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 2026.08.29.02 — deploy report
|
||||||
|
|
||||||
|
**7 of 7 target hosts deployed. `officer-dev` skipped by design.** Every host prints
|
||||||
|
`plugin plugin_config` — the two new `command_hq_sessions` columns exist on all eight databases.
|
||||||
|
|
||||||
|
All eight checkouts were at `58f50a52` (the `2026.08.29.01` tag) before this rollout, and all are at
|
||||||
|
`78bf63c2` (`deploy/2026.08.29.02`) now. No host was ahead, none diverged, none was dirty.
|
||||||
|
|
||||||
|
This was the first release under a new watcher loop. The rollout itself behaved as written.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
| Host | HEAD before → after | plugins:update | backup | db:push | verification query | officer | health |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `officer-dev` | — **SKIPPED** | — | — | — | `plugin plugin_config` | not restarted | — |
|
||||||
|
| `alpha` | `58f50a52` → `78bf63c2` | 15/15, exit 0 | 12,309,974 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (9000) |
|
||||||
|
| `fofos` | `58f50a52` → `78bf63c2` | 5/5, exit 0 | 2,792,097 B | exit 0 | `plugin plugin_config` | 26 → 27 restarts, online | 401 (9000) |
|
||||||
|
| `macbookpro` | `58f50a52` → `78bf63c2` | no plugins installed | 5,179,143 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (**9010**) |
|
||||||
|
| `macbruno` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 86,480 B | exit 0 | `plugin plugin_config` | 6 → 7 restarts, online | 401 (9000) |
|
||||||
|
| `production-pertento-bridge` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 187,488 B, **second attempt** | exit 0 | `plugin plugin_config` | 4 → 5 restarts, online | 401 (**9010**) |
|
||||||
|
| `edge-pertento-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 5,916,050 B | exit 0 | `plugin plugin_config` | 5 → 6 restarts, online | 401 (9000) |
|
||||||
|
| `deadpool-rezio-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 92,979 B | exit 0 | `plugin plugin_config` | 22 → 23 restarts, online | 401 (9000) |
|
||||||
|
|
||||||
|
`401` is the healthy answer from `/api/health` behind the session gate, as the plan says.
|
||||||
|
|
||||||
|
## What the release was for, confirmed on the host that matters
|
||||||
|
|
||||||
|
`fofos` carries `av-club`, and the commander template it now ships is on disk:
|
||||||
|
`plugins/av-club/commanders/convert-on-arrival/COMMANDER.md`, plugin checkout at `5b2fe03`
|
||||||
|
("Convert on arrival — the first plugin-shipped commander"). The plugin arrived through the standard
|
||||||
|
`bun run plugins:update` step, as the plan predicted — nothing in this plan installed it.
|
||||||
|
|
||||||
|
## What did not go to plan
|
||||||
|
|
||||||
|
**`production-pertento-bridge` failed its backup on the first attempt and the host stopped there.**
|
||||||
|
The plan's `pg_dump` is Ubuntu's 16.15; that host's Postgres is 18.3, and `pg_dump` refuses a newer
|
||||||
|
server outright:
|
||||||
|
|
||||||
|
```
|
||||||
|
pg_dump: error: aborting because of server version mismatch
|
||||||
|
pg_dump: detail: server version: 18.3; pg_dump version: 16.15
|
||||||
|
```
|
||||||
|
|
||||||
|
The host was left correct but half-done: code at the tag, plugins updated, **no `db:push` and no
|
||||||
|
restart**, so the running process was still serving the old code against the old schema. Nothing was
|
||||||
|
broken by stopping there.
|
||||||
|
|
||||||
|
Postgres on that host runs in a docker container (`postgres:18.3-alpine`), so a version-matched client
|
||||||
|
was already present. The backup was retaken with `docker exec postgres pg_dump` — same dump, correct
|
||||||
|
tool, read-only — and the plan then continued unchanged from step 2. Result above.
|
||||||
|
|
||||||
|
**This is a per-host detail worth writing down**, because it will recur on every release whose plan
|
||||||
|
backs the database up: the plan's `pg_dump` line is host-portable only where the client is at least as
|
||||||
|
new as the server. `production-pertento-bridge` is the one host where it is not.
|
||||||
|
|
||||||
|
**`fofos` health read `000` immediately after the restart** and `401` fifteen seconds later. The check
|
||||||
|
was racing a cold start, not finding a fault. The health check now retries for up to thirty seconds
|
||||||
|
before it reports a number.
|
||||||
|
|
||||||
|
**`fofos` has one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could not have
|
||||||
|
disturbed it.
|
||||||
|
|
||||||
|
## Skipped
|
||||||
|
|
||||||
|
`officer-dev` — the rollout ran on it. Its checkout was already at `78bf63c2` and its database already
|
||||||
|
carries both columns (verified by the same query: `plugin plugin_config`). **Its `officer` process was
|
||||||
|
not restarted**, so it is running the previous build. The owner restarts it deliberately:
|
||||||
|
|
||||||
|
```
|
||||||
|
pm2 restart officer
|
||||||
|
```
|
||||||
|
|
||||||
|
`docs/servers.md` lists nothing under "Deliberately skipped" today, and no host was unreachable —
|
||||||
|
`macbookpro` was awake.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "officer",
|
"name": "officer",
|
||||||
"version": "2026.08.29.01",
|
"version": "2026.08.29.02",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ export function App() {
|
|||||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||||
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
|
||||||
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
||||||
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
|
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
|
||||||
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
|
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
|
||||||
import { WorkspaceView } from 'officerdev';
|
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
|
||||||
import { defaultLayout } from './defaultLayout';
|
|
||||||
|
|
||||||
// /todos — the Kanban board over the repo's own `todo/` tree (one file per item, todo/README.md).
|
|
||||||
// Selection and the theme filter are `?selected=` / `?theme=`, read by the panel itself.
|
|
||||||
export const TodosScreen = () => {
|
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/todos', defaultLayout);
|
|
||||||
|
|
||||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['todo-board'], fallback: 'todo-board' }} />;
|
|
||||||
};
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
|
||||||
|
|
||||||
// One panel: the board carries its own filter bar and detail pane, driven by the URL.
|
|
||||||
export const defaultLayout: LayoutNode = {
|
|
||||||
type: 'panel',
|
|
||||||
id: 'todo-board',
|
|
||||||
appType: 'todo-board',
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './TodosScreen';
|
|
||||||
@@ -21,5 +21,4 @@ export * from './Terminal';
|
|||||||
export * from './Jobs';
|
export * from './Jobs';
|
||||||
export * from './QrTransfer';
|
export * from './QrTransfer';
|
||||||
export * from './SendOff';
|
export * from './SendOff';
|
||||||
export * from './Todos';
|
|
||||||
export * from './Bots';
|
export * from './Bots';
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export type CommandHqSessionRow = {
|
|||||||
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||||
name: string | null;
|
name: string | null;
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
|
plugin: string | null;
|
||||||
|
pluginConfig: Record<string, string> | null;
|
||||||
watcherArmed: boolean;
|
watcherArmed: boolean;
|
||||||
watcherTaskId: string | null;
|
watcherTaskId: string | null;
|
||||||
watcherArmedAt: Date | null;
|
watcherArmedAt: Date | null;
|
||||||
@@ -78,6 +80,8 @@ const SESSION_ROW_COLUMNS = {
|
|||||||
referencePaths: commandHqSessions.referencePaths,
|
referencePaths: commandHqSessions.referencePaths,
|
||||||
name: commandHqSessions.name,
|
name: commandHqSessions.name,
|
||||||
categoryId: commandHqSessions.categoryId,
|
categoryId: commandHqSessions.categoryId,
|
||||||
|
plugin: commandHqSessions.plugin,
|
||||||
|
pluginConfig: commandHqSessions.pluginConfig,
|
||||||
watcherArmed: commandHqSessions.watcherArmed,
|
watcherArmed: commandHqSessions.watcherArmed,
|
||||||
watcherTaskId: commandHqSessions.watcherTaskId,
|
watcherTaskId: commandHqSessions.watcherTaskId,
|
||||||
watcherArmedAt: commandHqSessions.watcherArmedAt,
|
watcherArmedAt: commandHqSessions.watcherArmedAt,
|
||||||
@@ -85,7 +89,16 @@ const SESSION_ROW_COLUMNS = {
|
|||||||
createdAt: commandHqSessions.createdAt,
|
createdAt: commandHqSessions.createdAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NewCommander = { userId: number; name: string; referencePath: string; categoryId?: number | null };
|
export type NewCommander = {
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
referencePath: string;
|
||||||
|
categoryId?: number | null;
|
||||||
|
/** The plugin whose template this was made from, or null for one somebody made themselves. */
|
||||||
|
plugin?: string | null;
|
||||||
|
/** The owner's answers to that template's inputs. Null when there was no template. */
|
||||||
|
pluginConfig?: Record<string, string> | null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
|
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
|
||||||
@@ -109,6 +122,8 @@ export async function createCommander(input: NewCommander): Promise<CommandHqSes
|
|||||||
referencePaths: [input.referencePath],
|
referencePaths: [input.referencePath],
|
||||||
name: input.name,
|
name: input.name,
|
||||||
categoryId: input.categoryId ?? null,
|
categoryId: input.categoryId ?? null,
|
||||||
|
plugin: input.plugin ?? null,
|
||||||
|
pluginConfig: input.pluginConfig ?? null,
|
||||||
})
|
})
|
||||||
.returning({ id: commandHqSessions.id });
|
.returning({ id: commandHqSessions.id });
|
||||||
const [row] = await tx
|
const [row] = await tx
|
||||||
@@ -155,9 +170,12 @@ export async function getCommandHqSessionById(userId: number, id: number): Promi
|
|||||||
export async function listCommandHqSessions(
|
export async function listCommandHqSessions(
|
||||||
userId: number,
|
userId: number,
|
||||||
categoryId?: number | null,
|
categoryId?: number | null,
|
||||||
|
/** Instead of a category: only commanders shipped by this plugin. The two never combine. */
|
||||||
|
plugin?: string,
|
||||||
): Promise<CommandHqSessionRow[]> {
|
): Promise<CommandHqSessionRow[]> {
|
||||||
const where =
|
const where = plugin
|
||||||
categoryId === undefined
|
? and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.plugin, plugin))
|
||||||
|
: categoryId === undefined
|
||||||
? eq(commandHqSessions.userId, userId)
|
? eq(commandHqSessions.userId, userId)
|
||||||
: categoryId === null
|
: categoryId === null
|
||||||
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
|
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
|
||||||
@@ -257,6 +275,33 @@ export async function getCommandHqSession(userId: number, sessionId: string): Pr
|
|||||||
* longer the current one.
|
* longer the current one.
|
||||||
*/
|
*/
|
||||||
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
|
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
|
||||||
|
/*
|
||||||
|
A commander made from a plugin's template cannot arm until it has been configured.
|
||||||
|
|
||||||
|
Enforced HERE rather than in a route because arming is not a button: it happens when the agent
|
||||||
|
starts a tracked background job, and the platform notices. A check anywhere else would be a check
|
||||||
|
the agent could route around simply by starting its loop, which is exactly what it does.
|
||||||
|
|
||||||
|
Creating a half-configured commander stays fine and useful — it exists, it is visible, and it can be
|
||||||
|
finished later. It is ARMING one that would run somebody's disk against a blank setting.
|
||||||
|
|
||||||
|
A row with no template is unaffected: `plugin` is null and this returns immediately.
|
||||||
|
*/
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ plugin: commandHqSessions.plugin, pluginConfig: commandHqSessions.pluginConfig })
|
||||||
|
.from(commandHqSessions)
|
||||||
|
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing?.plugin) {
|
||||||
|
// Only the presence of answers is checked here, not which ones are required — that needs the
|
||||||
|
// template, which is a filesystem read and does not belong in the query layer. The route that
|
||||||
|
// creates the commander writes every input it was given, so an empty object means nothing was
|
||||||
|
// configured at all, which is the case worth stopping.
|
||||||
|
const answered = Object.values(existing.pluginConfig ?? {}).filter((v) => String(v ?? '').trim());
|
||||||
|
if (answered.length === 0) return false;
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.update(commandHqSessions)
|
.update(commandHqSessions)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
doublePrecision,
|
doublePrecision,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
serial,
|
serial,
|
||||||
text,
|
text,
|
||||||
@@ -117,10 +118,7 @@ export const commandHqSessions = pgTable(
|
|||||||
* its own, and an array supports appending in place (`array_append`) without reading the whole column
|
* its own, and an array supports appending in place (`array_append`) without reading the whole column
|
||||||
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
|
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
|
||||||
*/
|
*/
|
||||||
referencePaths: text('reference_paths')
|
referencePaths: text('reference_paths').array().notNull().default([]),
|
||||||
.array()
|
|
||||||
.notNull()
|
|
||||||
.default([]),
|
|
||||||
/**
|
/**
|
||||||
* What the owner called it, held here rather than only in the transcript's summary record.
|
* What the owner called it, held here rather than only in the transcript's summary record.
|
||||||
*
|
*
|
||||||
@@ -137,6 +135,29 @@ export const commandHqSessions = pgTable(
|
|||||||
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
|
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
|
||||||
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
|
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
|
||||||
watcherArmed: boolean('watcher_armed').notNull().default(false),
|
watcherArmed: boolean('watcher_armed').notNull().default(false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin that shipped this commander's template, or null for one somebody made themselves.
|
||||||
|
*
|
||||||
|
* A COLUMN rather than a reserved category name, for the reason tasks carry the same field:
|
||||||
|
* provenance should be structural. A category called "av-club" is a string an owner can rename,
|
||||||
|
* merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones —
|
||||||
|
* with nothing on disk saying otherwise.
|
||||||
|
*
|
||||||
|
* It also survives the plugin being uninstalled, which is the point: the commander is still there,
|
||||||
|
* still in its directory, and the UI can say what it came from rather than pretending it was
|
||||||
|
* always hand-made.
|
||||||
|
*/
|
||||||
|
plugin: text('plugin'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner's answers to the template's `inputs`, or null when there was no template.
|
||||||
|
*
|
||||||
|
* Held here rather than only in the generated watcher script because the form has to be
|
||||||
|
* re-openable: somebody changing which directory is watched should see what it is watching now,
|
||||||
|
* not an empty form. The script is generated FROM this, so this is the source of truth.
|
||||||
|
*/
|
||||||
|
pluginConfig: jsonb('plugin_config').$type<Record<string, string>>(),
|
||||||
/**
|
/**
|
||||||
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
|
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
|
||||||
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
|
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { readdir } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { capabilitySources } from '../../capability-sources';
|
||||||
|
|
||||||
|
// Bun's own YAML, the same one `task-files.ts` uses. Not the `yaml` package — it is not a dependency,
|
||||||
|
// and adding one to read four lines of frontmatter is not a trade worth making.
|
||||||
|
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commanders a plugin ships, ready to be created but not running.
|
||||||
|
*
|
||||||
|
* ── Presence is the declaration ──
|
||||||
|
*
|
||||||
|
* A plugin with a `commanders/<name>/COMMANDER.md` offers one. Nothing is added to `manifest.ts`, for
|
||||||
|
* the same reason `tasks/` and `skills/` are not declared there: the directory already says it, and a
|
||||||
|
* second place to say it is a second place for the two to disagree.
|
||||||
|
*
|
||||||
|
* Read IN PLACE, and only from installed-and-enabled plugins — `capabilitySources` handles both. So a
|
||||||
|
* disabled plugin stops offering its commander, and `plugins:update` changes the template without
|
||||||
|
* anything having to be republished.
|
||||||
|
*
|
||||||
|
* ── A template is not a commander ──
|
||||||
|
*
|
||||||
|
* What is here is an OFFER. Nothing runs, nothing is scheduled, and no directory exists until the
|
||||||
|
* owner creates one from it and fills in the inputs. That is the whole shape the owner asked for:
|
||||||
|
* a plugin may propose recurring work; only a person may start it.
|
||||||
|
*
|
||||||
|
* ── Why the inputs reuse TASK.md's vocabulary ──
|
||||||
|
*
|
||||||
|
* `inputs:` here is the same structure a task declares, so the same form renderer draws it and the
|
||||||
|
* same validation applies. A commander asking "which folder, which extensions, what output" is asking
|
||||||
|
* exactly what a task asks, and inventing a second schema for it would mean two form renderers to
|
||||||
|
* keep in step for no gain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CommanderInput = {
|
||||||
|
type: string;
|
||||||
|
description?: string;
|
||||||
|
default?: string;
|
||||||
|
options?: string[];
|
||||||
|
/** Unset and required means the commander cannot arm. See `missingInputs`. */
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginCommanderTemplate = {
|
||||||
|
/** Directory name, unique within its plugin — `heic-to-webp`. */
|
||||||
|
dirName: string;
|
||||||
|
/** The plugin that ships it. Never null: an owner's own commanders do not come from a template. */
|
||||||
|
plugin: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
/** What it watches, in a sentence, for the card. */
|
||||||
|
watches?: string;
|
||||||
|
inputs: Record<string, CommanderInput>;
|
||||||
|
/** The prose that becomes the commander's `CLAUDE.md`. */
|
||||||
|
body: string;
|
||||||
|
/** Absolute path to the template directory, so the watcher script can be read at creation. */
|
||||||
|
dir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parse(raw: string): { fm: Record<string, unknown>; body: string } {
|
||||||
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||||
|
if (!match) return { fm: {}, body: raw };
|
||||||
|
try {
|
||||||
|
return { fm: (YAML.parse(match[1]!) as Record<string, unknown>) ?? {}, body: match[2]! };
|
||||||
|
} catch {
|
||||||
|
// A template with unparseable frontmatter is skipped by the caller rather than crashing the list —
|
||||||
|
// one plugin's bad YAML must not take away every other plugin's commanders.
|
||||||
|
return { fm: {}, body: match[2]! };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPluginCommanders(): Promise<PluginCommanderTemplate[]> {
|
||||||
|
const sources = await capabilitySources('commanders');
|
||||||
|
const found: PluginCommanderTemplate[] = [];
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
// Only plugins offer these. The owner's own `capabilities/commanders/` is not a thing that exists:
|
||||||
|
// a commander somebody makes themselves is made in the UI, not from a template on disk.
|
||||||
|
if (!source.plugin) continue;
|
||||||
|
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(source.dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue; // most plugins ship none — ordinary, not a fault
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const dir = join(source.dir, entry.name);
|
||||||
|
const file = Bun.file(join(dir, 'COMMANDER.md'));
|
||||||
|
if (!(await file.exists())) continue;
|
||||||
|
|
||||||
|
const { fm, body } = parse(await file.text());
|
||||||
|
if (!fm.name) continue; // no name means the frontmatter did not parse — skip it quietly
|
||||||
|
|
||||||
|
found.push({
|
||||||
|
dirName: entry.name,
|
||||||
|
plugin: source.plugin,
|
||||||
|
name: String(fm.name),
|
||||||
|
description: String(fm.description ?? ''),
|
||||||
|
watches: fm.watches ? String(fm.watches) : undefined,
|
||||||
|
inputs: (fm.inputs as Record<string, CommanderInput>) ?? {},
|
||||||
|
body,
|
||||||
|
dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginCommander(plugin: string, dirName: string): Promise<PluginCommanderTemplate | null> {
|
||||||
|
// Both halves reach this from a request body and are joined onto a path below.
|
||||||
|
const unsafe = (v: string) => !v || v.includes('/') || v.includes('\\') || v === '.' || v === '..';
|
||||||
|
if (unsafe(plugin) || unsafe(dirName)) return null;
|
||||||
|
|
||||||
|
const all = await listPluginCommanders();
|
||||||
|
return all.find((t) => t.plugin === plugin && t.dirName === dirName) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which required inputs have no answer.
|
||||||
|
*
|
||||||
|
* This is the "configure before running" rule, and it is enforced at ARM time rather than at creation.
|
||||||
|
* Creating a half-configured commander is fine and useful — it exists, it is visible, and it can be
|
||||||
|
* finished later. Arming one is what would run somebody's disk against a blank setting.
|
||||||
|
*/
|
||||||
|
export function missingInputs(
|
||||||
|
inputs: Record<string, CommanderInput>,
|
||||||
|
config: Record<string, string> | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
return Object.entries(inputs)
|
||||||
|
.filter(([key, spec]) => spec.required && !(config?.[key] ?? spec.default ?? '').trim())
|
||||||
|
.map(([key]) => key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a created commander needs on disk beyond its `CLAUDE.md`.
|
||||||
|
*
|
||||||
|
* ── `config.env`, not a JSON file ──
|
||||||
|
*
|
||||||
|
* The watcher is a shell script and this is what it sources. A JSON config would mean the script
|
||||||
|
* parsing JSON in bash, which is either `jq` (another dependency for four values) or something
|
||||||
|
* hand-rolled that breaks on the first path with a space in it. The database row remains the source
|
||||||
|
* of truth; this is a projection of it that bash can read.
|
||||||
|
*
|
||||||
|
* ── Every value is quoted, and that is not decoration ──
|
||||||
|
*
|
||||||
|
* `WATCH_DIR` is a path the owner typed, and this fleet has directories called `ABOUT US` and
|
||||||
|
* `70´S`. Unquoted, the first space ends the assignment and the watcher silently watches the wrong
|
||||||
|
* folder — or, with `rm` involved downstream, the wrong folder's contents.
|
||||||
|
*/
|
||||||
|
export function configEnv(
|
||||||
|
inputs: Record<string, CommanderInput>,
|
||||||
|
config: Record<string, string> | null | undefined,
|
||||||
|
): string {
|
||||||
|
const lines = ['# Written by Officer when this commander was created. Edit through the UI, not here.'];
|
||||||
|
|
||||||
|
for (const [key, spec] of Object.entries(inputs)) {
|
||||||
|
const value = config?.[key] ?? spec.default ?? '';
|
||||||
|
// Single quotes, with any embedded single quote closed and re-opened around an escaped one — the
|
||||||
|
// standard shell trick, and the only form that is safe for arbitrary text.
|
||||||
|
const quoted = `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||||
|
lines.push(`${key.toUpperCase()}=${quoted}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { rmSync } from 'node:fs';
|
import { rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { checkBudget } from '../sub-agents/budget';
|
import { checkBudget } from '../sub-agents/budget';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { resolveHomeDir } from '@@/user-home';
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
import { toShellUsername, getCommanderDir } from '@@/data-path';
|
import { toShellUsername, getCommanderDir, getCommanderStateDir } from '@@/data-path';
|
||||||
import {
|
import {
|
||||||
forgetChatSessions,
|
forgetChatSessions,
|
||||||
getChatEventsSince,
|
getChatEventsSince,
|
||||||
@@ -17,6 +18,7 @@ import * as sidecar from '@@/sidecar-registry';
|
|||||||
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
||||||
import { sessionKeyForTranscript } from '../chat/websocket';
|
import { sessionKeyForTranscript } from '../chat/websocket';
|
||||||
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
|
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
|
||||||
|
import { configEnv, getPluginCommander, listPluginCommanders, missingInputs } from './plugin-commanders';
|
||||||
import { createCommanderDirectory } from './directory';
|
import { createCommanderDirectory } from './directory';
|
||||||
import { commanderKeyName } from './credential';
|
import { commanderKeyName } from './credential';
|
||||||
import type { ServerMessage } from '../chat/types';
|
import type { ServerMessage } from '../chat/types';
|
||||||
@@ -201,6 +203,17 @@ type CommanderSummary = ClaudeSessionSummary & {
|
|||||||
watcherLastFiredAt: Date | null;
|
watcherLastFiredAt: Date | null;
|
||||||
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
|
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
|
||||||
cost: CommandHqCostSummary;
|
cost: CommandHqCostSummary;
|
||||||
|
/** Where it came from, when a plugin's template made it. Null for one somebody wrote themselves. */
|
||||||
|
plugin?: string | null;
|
||||||
|
/** The owner's answers to that template's inputs, so the card can say what it is set to watch. */
|
||||||
|
pluginConfig?: Record<string, string> | null;
|
||||||
|
/**
|
||||||
|
* Created, never opened — so there is no transcript behind it and its id is not a session id.
|
||||||
|
*
|
||||||
|
* Only ever true for a plugin's commander. A hand-made row without a transcript is one whose
|
||||||
|
* conversation failed to start, and the design deliberately refuses to draw a tombstone for it.
|
||||||
|
*/
|
||||||
|
pending?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function isRunning(sessionId: string): Promise<boolean> {
|
async function isRunning(sessionId: string): Promise<boolean> {
|
||||||
@@ -222,7 +235,15 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
|||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const who = await commandHqIdentity(user);
|
const who = await commandHqIdentity(user);
|
||||||
const categoryParam = ctx.req.query('category');
|
const categoryParam = ctx.req.query('category');
|
||||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
|
||||||
|
/*
|
||||||
|
`plugin:<appName>` is a filter on provenance, not a category, and is parsed BEFORE the numeric branch.
|
||||||
|
Without this it reached `Number('plugin:av-club')` — NaN — and the query compared `category_id = NaN`,
|
||||||
|
which matches nothing and looks exactly like "this plugin has no commanders".
|
||||||
|
*/
|
||||||
|
const pluginFilter = categoryParam?.startsWith('plugin:') ? categoryParam.slice('plugin:'.length) : undefined;
|
||||||
|
const categoryId =
|
||||||
|
pluginFilter || categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
|
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
|
||||||
@@ -235,7 +256,7 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
|||||||
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
|
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
|
||||||
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
|
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
|
||||||
*/
|
*/
|
||||||
const rows = await listCommandHqSessions(user.id, categoryId);
|
const rows = await listCommandHqSessions(user.id, categoryId, pluginFilter);
|
||||||
const withTranscript = rows.filter((row) => row.sessionId !== null);
|
const withTranscript = rows.filter((row) => row.sessionId !== null);
|
||||||
const summaries = withTranscript
|
const summaries = withTranscript
|
||||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
|
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
|
||||||
@@ -258,6 +279,39 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
A commander CREATED but never opened has no transcript, and every branch above drops it.
|
||||||
|
|
||||||
|
That was right when the only way to get a row was to start a conversation. It is wrong now: a
|
||||||
|
commander made from a plugin's template arrives deliberately not running, and dropping it means the
|
||||||
|
thing somebody just created is invisible — with a toast saying it worked. So a row with a `plugin`
|
||||||
|
and no session is projected from the ROW alone, with the zeroes that are actually true of it.
|
||||||
|
|
||||||
|
Ordinary commanders keep the old behaviour. A hand-made row with no transcript is a row whose
|
||||||
|
conversation failed to start, and a tombstone for it is what the design deliberately refused.
|
||||||
|
*/
|
||||||
|
const pending = rows
|
||||||
|
.filter((row) => row.sessionId === null && row.plugin)
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
({
|
||||||
|
id: `pending-${row.id}`,
|
||||||
|
title: row.name ?? 'Commander',
|
||||||
|
dirName: row.dirName,
|
||||||
|
categoryId: row.categoryId,
|
||||||
|
tagIds: [],
|
||||||
|
running: false,
|
||||||
|
watcherArmed: false,
|
||||||
|
watcherArmedAt: null,
|
||||||
|
watcherStoppedAt: null,
|
||||||
|
watcherLastFiredAt: null,
|
||||||
|
cost: { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
|
||||||
|
plugin: row.plugin,
|
||||||
|
pluginConfig: row.pluginConfig,
|
||||||
|
pending: true,
|
||||||
|
}) as unknown as CommanderSummary,
|
||||||
|
);
|
||||||
|
|
||||||
const commanders: CommanderSummary[] = filed
|
const commanders: CommanderSummary[] = filed
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
...liveById.get(row.sessionId!)!,
|
...liveById.get(row.sessionId!)!,
|
||||||
@@ -277,6 +331,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
|||||||
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
|
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
|
||||||
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
||||||
// beside the transcript.
|
// beside the transcript.
|
||||||
|
// Provenance, so the UI can put a plugin's commanders in their own section rather than inferring
|
||||||
|
// it from a category name somebody can rename.
|
||||||
|
plugin: row.plugin,
|
||||||
|
pluginConfig: row.pluginConfig,
|
||||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||||
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
|
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
|
||||||
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
|
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
|
||||||
@@ -288,7 +346,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
|
||||||
return ctx.json({ commanders });
|
// Pending ones FIRST: a commander that was just created and needs a decision is the thing somebody came
|
||||||
|
// to the screen for, and sorting it by `updatedAt` among running ones would bury it on a busy account.
|
||||||
|
return ctx.json({ commanders: [...pending, ...commanders] });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -463,6 +523,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
|||||||
categoryId: row.categoryId,
|
categoryId: row.categoryId,
|
||||||
tagIds: tagsByRow.get(row.id) ?? [],
|
tagIds: tagsByRow.get(row.id) ?? [],
|
||||||
running,
|
running,
|
||||||
|
plugin: row.plugin,
|
||||||
|
pluginConfig: row.pluginConfig,
|
||||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||||
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
||||||
watcherArmedAt: row.watcherArmedAt,
|
watcherArmedAt: row.watcherArmedAt,
|
||||||
@@ -525,6 +587,21 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
|
|||||||
* design removes — a client that has to remember to follow through, on a value the server already knows
|
* design removes — a client that has to remember to follow through, on a value the server already knows
|
||||||
* first.
|
* first.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* What the installed plugins OFFER. Not commanders — templates for them.
|
||||||
|
*
|
||||||
|
* Separate from `GET /commanders`, which lists what exists. Merging the two would put a thing that
|
||||||
|
* runs and a thing that could be made in one list, distinguished by a flag, and every consumer would
|
||||||
|
* have to remember which it was holding.
|
||||||
|
*/
|
||||||
|
commandHqRouter.get('/plugin-commanders', async (ctx) => {
|
||||||
|
const templates = await listPluginCommanders();
|
||||||
|
// The body is the commander's future CLAUDE.md and can be pages long; the list does not need it.
|
||||||
|
return ctx.json({
|
||||||
|
templates: templates.map(({ body: _body, dir: _dir, ...rest }) => rest),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
commandHqRouter.post('/commanders', async (ctx) => {
|
commandHqRouter.post('/commanders', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const who = await commandHqIdentity(user);
|
const who = await commandHqIdentity(user);
|
||||||
@@ -535,6 +612,11 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
|||||||
/** A chat context to watch, instead of a path — see below. */
|
/** A chat context to watch, instead of a path — see below. */
|
||||||
context?: string;
|
context?: string;
|
||||||
contextId?: string;
|
contextId?: string;
|
||||||
|
/** Create from a plugin's template: the plugin's appName and the template's directory name. */
|
||||||
|
plugin?: string;
|
||||||
|
template?: string;
|
||||||
|
/** The owner's answers to that template's inputs. */
|
||||||
|
config?: Record<string, string>;
|
||||||
};
|
};
|
||||||
const body = await ctx.req.json<Body>().catch(() => ({}) as Body);
|
const body = await ctx.req.json<Body>().catch(() => ({}) as Body);
|
||||||
const name = body.name?.trim();
|
const name = body.name?.trim();
|
||||||
@@ -553,15 +635,73 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
|||||||
commander at a mailbox previously meant knowing its path and typing it, which meant a file browser
|
commander at a mailbox previously meant knowing its path and typing it, which meant a file browser
|
||||||
and an account allowed to walk the disk. The button asks for neither.
|
and an account allowed to walk the disk. The button asks for neither.
|
||||||
*/
|
*/
|
||||||
const referencePath = body.context ? chatContextDir(body.context, body.contextId, who) : body.referencePath?.trim();
|
/*
|
||||||
|
A template can supply the reference path from its own configuration.
|
||||||
|
|
||||||
|
`watch_dir` is what this kind of commander is about, so making the owner give a reference path AND a
|
||||||
|
watch directory would be asking the same question twice and inviting the two to disagree.
|
||||||
|
*/
|
||||||
|
const template = body.plugin && body.template ? await getPluginCommander(body.plugin, body.template) : null;
|
||||||
|
if (body.plugin && body.template && !template) {
|
||||||
|
throw errors.BAD_REQUEST(`No commander template "${body.template}" in plugin "${body.plugin}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromConfig = template ? (body.config?.watch_dir ?? '').trim() : '';
|
||||||
|
const referencePath = body.context
|
||||||
|
? chatContextDir(body.context, body.contextId, who)
|
||||||
|
: body.referencePath?.trim() || fromConfig;
|
||||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath or context is required');
|
if (!referencePath) throw errors.BAD_REQUEST('referencePath or context is required');
|
||||||
|
|
||||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
const row = await createCommander({
|
||||||
|
userId: user.id,
|
||||||
|
name,
|
||||||
|
referencePath,
|
||||||
|
categoryId: body.categoryId ?? null,
|
||||||
|
plugin: template?.plugin ?? null,
|
||||||
|
pluginConfig: template ? (body.config ?? {}) : null,
|
||||||
|
});
|
||||||
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
||||||
name,
|
name,
|
||||||
referencePath,
|
referencePath,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
/*
|
||||||
|
A template's own files, written after the directory exists.
|
||||||
|
|
||||||
|
Three things, and each is deliberately a COPY rather than a reference into the plugin:
|
||||||
|
|
||||||
|
state/config.env what the owner answered, in the form the watcher sources
|
||||||
|
watch.sh the loop, copied so `plugins:update` cannot change the script a commander is
|
||||||
|
already running — a watcher whose code moves under it while armed is a thing
|
||||||
|
nobody would be able to debug
|
||||||
|
AGENTS.md the template's prose, appended to the brief already written
|
||||||
|
|
||||||
|
A failure here is reported and does not undo the commander. It exists, it is visible, and a missing
|
||||||
|
config.env is exactly what the watcher refuses to start without — which is the configure-before-
|
||||||
|
running rule enforcing itself rather than a half-created row nobody can see.
|
||||||
|
*/
|
||||||
|
let templateError: string | null = null;
|
||||||
|
if (template) {
|
||||||
|
try {
|
||||||
|
const { writeFile, copyFile, appendFile } = await import('node:fs/promises');
|
||||||
|
const stateDir = getCommanderStateDir(who.email, row.dirName);
|
||||||
|
await writeFile(join(stateDir, 'config.env'), configEnv(template.inputs, body.config), { mode: 0o600 });
|
||||||
|
|
||||||
|
const script = join(template.dir, 'watch.sh');
|
||||||
|
if (await Bun.file(script).exists()) {
|
||||||
|
await copyFile(script, join(dir, 'watch.sh'));
|
||||||
|
await (await import('node:fs/promises')).chmod(join(dir, 'watch.sh'), 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.body.trim()) {
|
||||||
|
await appendFile(join(dir, 'AGENTS.md'), `\n\n---\n\n${template.body.trim()}\n`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
templateError = err instanceof Error ? err.message : 'could not write the template files';
|
||||||
|
console.warn(`[command-hq] ${row.dirName}: template not fully written — ${templateError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
|
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
|
||||||
if (ownershipError) {
|
if (ownershipError) {
|
||||||
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
|
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
|
||||||
|
|||||||
@@ -43,7 +43,18 @@ import sharp from 'sharp';
|
|||||||
* two are not exotic — HEIC is what every recent iPhone writes, and a folder of them showing generic
|
* two are not exotic — HEIC is what every recent iPhone writes, and a folder of them showing generic
|
||||||
* file icons was the most visible gap this had.
|
* file icons was the most visible gap this had.
|
||||||
*/
|
*/
|
||||||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg', 'heic', 'heif']);
|
/*
|
||||||
|
No `heic` or `heif`, and that is not an oversight.
|
||||||
|
|
||||||
|
sharp's bundled libheif is built without the HEVC decoder, so it parses a HEIC's header and then
|
||||||
|
fails on the pixels. Listing them here made `ls` promise a thumbnail core cannot produce: every HEIC
|
||||||
|
cost a request that 422'd before falling back to the icon, and the promise was the only reason the
|
||||||
|
request happened at all.
|
||||||
|
|
||||||
|
Decoding them needs ImageMagick and `libheif-plugin-libde265` — a system dependency the core has no
|
||||||
|
business acquiring for one format. That belongs to a plugin, and av-club already does it.
|
||||||
|
*/
|
||||||
|
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg']);
|
||||||
/*
|
/*
|
||||||
`ts` is NOT here, and that is the one entry worth explaining.
|
`ts` is NOT here, and that is the one entry worth explaining.
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { createRouter } from '../../create-router';
|
|
||||||
import * as errors from '@@/custom-errors';
|
|
||||||
import { isSuperAdmin } from '../../super-admin';
|
|
||||||
import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './todo-tree';
|
|
||||||
|
|
||||||
/*
|
|
||||||
/api/todos — the board over `todo/`, the platform's own backlog (one file per item, todo/README.md).
|
|
||||||
|
|
||||||
The FILESYSTEM is the database, deliberately: the items are already versioned, reviewed and synced
|
|
||||||
between machines by git, and a second store would be a second truth. So this router is thin — read the
|
|
||||||
tree, splice frontmatter — and everything it writes is a small, reviewable git diff.
|
|
||||||
|
|
||||||
Two rules from the format's contract, enforced here rather than trusted to callers:
|
|
||||||
|
|
||||||
- The BODY is the owner's half and is never rewritten. Only `patchFrontmatter` touches a file, and it
|
|
||||||
stops at the closing fence.
|
|
||||||
- `TODO.md` is generated output. Every write regenerates it via `writeIndex()`, so the index cannot
|
|
||||||
drift from the tree — the board never edits it directly.
|
|
||||||
|
|
||||||
── Owner only, explicitly ──
|
|
||||||
|
|
||||||
`todos` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner.
|
|
||||||
This is the same belt-and-braces the other admin routers add: a registry mistake must not turn into
|
|
||||||
members reading — or editing — the platform's own backlog.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const todosRouter = createRouter();
|
|
||||||
|
|
||||||
todosRouter.use(async (ctx, next) => {
|
|
||||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The todo board is owner-only');
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** `<theme>/<slug>`, both kebab-case — also the path traversal guard, since it admits no dot or slash pair. */
|
|
||||||
const ID_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
|
|
||||||
|
|
||||||
const fileForId = (id: string): string => {
|
|
||||||
if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`);
|
|
||||||
const [theme, slug] = id.split('/');
|
|
||||||
const path = resolve(process.cwd(), join('todo', theme!, `${slug}.md`));
|
|
||||||
if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`);
|
|
||||||
return path;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** GET /api/todos — the whole tree, no bodies. What the board renders. */
|
|
||||||
todosRouter.get('/', (ctx) => ctx.json(readTree()));
|
|
||||||
|
|
||||||
/** GET /api/todos/item?id=<theme>/<slug> — one item, body included. Query param because the id holds a slash. */
|
|
||||||
todosRouter.get('/item', (ctx) => {
|
|
||||||
const text = readFileSync(fileForId(ctx.req.query('id') ?? ''), 'utf8');
|
|
||||||
return ctx.json({ meta: frontmatter(text), body: bodyOf(text) });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /api/todos/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`.
|
|
||||||
* Regenerates TODO.md on every write. The body is untouchable through this API, on purpose.
|
|
||||||
*/
|
|
||||||
todosRouter.patch('/item', async (ctx) => {
|
|
||||||
const { id, status, priority, due, order } = await ctx.req.json<{
|
|
||||||
id: string;
|
|
||||||
status?: string;
|
|
||||||
priority?: string;
|
|
||||||
due?: string;
|
|
||||||
order?: number;
|
|
||||||
}>();
|
|
||||||
const path = fileForId(id);
|
|
||||||
|
|
||||||
const updates: Record<string, string> = {};
|
|
||||||
if (status !== undefined) {
|
|
||||||
if (!(TODO_STATUSES as readonly string[]).includes(status)) throw errors.BAD_REQUEST(`not a status: ${status}`);
|
|
||||||
updates.status = status;
|
|
||||||
}
|
|
||||||
// `urgent` or nothing — the format deliberately has no scale (todo/README.md).
|
|
||||||
if (priority !== undefined) {
|
|
||||||
if (priority !== '' && priority !== 'urgent') throw errors.BAD_REQUEST(`not a priority: ${priority}`);
|
|
||||||
updates.priority = priority;
|
|
||||||
}
|
|
||||||
if (due !== undefined) {
|
|
||||||
if (due !== '' && !/^20\d\d-\d\d-\d\d$/.test(due)) throw errors.BAD_REQUEST(`not a date: ${due}`);
|
|
||||||
updates.due = due;
|
|
||||||
}
|
|
||||||
if (order !== undefined) {
|
|
||||||
if (!Number.isInteger(order) || order < 0) throw errors.BAD_REQUEST(`not an order: ${order}`);
|
|
||||||
updates.order = String(order);
|
|
||||||
}
|
|
||||||
if (Object.keys(updates).length === 0) throw errors.BAD_REQUEST('nothing to update');
|
|
||||||
|
|
||||||
writeFileSync(path, patchFrontmatter(readFileSync(path, 'utf8'), updates));
|
|
||||||
writeIndex();
|
|
||||||
return ctx.json({ ok: true });
|
|
||||||
});
|
|
||||||
@@ -10,8 +10,20 @@ import { join, resolve } from 'node:path';
|
|||||||
// the closing fence.
|
// the closing fence.
|
||||||
// - `TODO.md` at the repo root is GENERATED output (`writeIndex`), never a write target.
|
// - `TODO.md` at the repo root is GENERATED output (`writeIndex`), never a write target.
|
||||||
//
|
//
|
||||||
// This module is the one reader and one writer. `scripts/todo-index.ts` (the CLI) and the `/api/todos`
|
// ── This module has ONE caller now, and that is deliberate ──
|
||||||
// router both import it, so the board and the generated index cannot disagree about what the tree holds.
|
//
|
||||||
|
// `scripts/todo-index.ts` — the CLI behind `bun todo:index`, which regenerates TODO.md from a terminal
|
||||||
|
// with no Officer running. The `/api/todos` router and the board are gone: they became the `kanban`
|
||||||
|
// plugin on 2026-08-29, which generalised this reader over a root so it can show any project's folder
|
||||||
|
// rather than only the one the server was started in.
|
||||||
|
//
|
||||||
|
// This copy stayed because the folder it reads is the PLATFORM'S OWN backlog, committed with the
|
||||||
|
// platform's own code. Moving it would have made regenerating this repository's TODO.md depend on a
|
||||||
|
// plugin being installed, which is the wrong way round for a repo's own documentation.
|
||||||
|
//
|
||||||
|
// So there are two copies of this logic now, and they will drift. That is a known cost, recorded here
|
||||||
|
// and in the plugin's README rather than discovered later. The honest fix, on the day it matters, is to
|
||||||
|
// delete the CLI and let the plugin own the format outright.
|
||||||
|
|
||||||
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
|
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
|
||||||
const todoDir = () => resolve(process.cwd(), 'todo');
|
const todoDir = () => resolve(process.cwd(), 'todo');
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export type CapabilitySource = {
|
|||||||
plugin: string | null;
|
plugin: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise<CapabilitySource[]> {
|
export async function capabilitySources(
|
||||||
|
type: ItemType | 'affordances' | 'notifications' | 'commanders',
|
||||||
|
): Promise<CapabilitySource[]> {
|
||||||
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
|
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
|
||||||
|
|
||||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { themeFavoritesRouter } from './api/themes/favorites';
|
|||||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||||
import { usersRouter } from './api/users/users-router';
|
import { usersRouter } from './api/users/users-router';
|
||||||
import { rolesRouter } from './api/roles/router';
|
import { rolesRouter } from './api/roles/router';
|
||||||
import { todosRouter } from './api/todos/router';
|
|
||||||
import { apiKeysRouter } from './api/api-keys/router';
|
import { apiKeysRouter } from './api/api-keys/router';
|
||||||
import { skillsRouter } from './api/skills/skills';
|
import { skillsRouter } from './api/skills/skills';
|
||||||
import { tasksRouter } from './api/tasks/tasks';
|
import { tasksRouter } from './api/tasks/tasks';
|
||||||
@@ -131,7 +130,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
|||||||
['/theme-favorites', themeFavoritesRouter],
|
['/theme-favorites', themeFavoritesRouter],
|
||||||
['/users', usersRouter],
|
['/users', usersRouter],
|
||||||
['/roles', rolesRouter],
|
['/roles', rolesRouter],
|
||||||
['/todos', todosRouter],
|
|
||||||
['/skills', skillsRouter],
|
['/skills', skillsRouter],
|
||||||
['/tasks', tasksRouter],
|
['/tasks', tasksRouter],
|
||||||
['/agents', agentsRouter],
|
['/agents', agentsRouter],
|
||||||
|
|||||||
@@ -435,17 +435,6 @@ const CORE_REGISTRY: Permission[] = [
|
|||||||
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
|
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
|
||||||
routes: ['/marketplace', '/plugins'],
|
routes: ['/marketplace', '/plugins'],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'todos',
|
|
||||||
label: 'Todo board',
|
|
||||||
description: "The platform's own backlog — todo/ in the repo, browsed and edited at /todos",
|
|
||||||
// Admin because the subject is the platform's development, not the account's data: the tree lives in
|
|
||||||
// the server's own checkout and a write is a git-visible edit to it. The router double-gates on
|
|
||||||
// isSuperAdmin, same belt-and-braces as the rest of this kind.
|
|
||||||
kind: 'admin',
|
|
||||||
api: ['/todos'],
|
|
||||||
routes: ['/todos'],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'server-admin',
|
key: 'server-admin',
|
||||||
label: 'Server settings',
|
label: 'Server settings',
|
||||||
|
|||||||
@@ -502,6 +502,32 @@ const BUNDLED: CatalogueEntry[] = [
|
|||||||
],
|
],
|
||||||
has: { api: true, schema: true, sidecar: true, web: true },
|
has: { api: true, schema: true, sidecar: true, web: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
appName: 'kanban',
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '2026.08.29.01',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
label: 'Kanban',
|
||||||
|
summary: 'A board over folders of markdown todo files — as many projects as you point it at',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
icon: 'SquareKanban',
|
||||||
|
source: 'https://gitea.officer.dev/plugins/kanban.git',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'kanban',
|
||||||
|
label: 'Kanban',
|
||||||
|
description: 'Read and edit todo boards over folders you have added',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
/*
|
||||||
|
`schema: true` for one table, and it is worth saying what is in it: a list of FOLDERS. the items
|
||||||
|
are markdown on disk and this plugin keeps no copy of them, so an uninstall with "delete its data"
|
||||||
|
ticked loses the list and never a line of anybody's work.
|
||||||
|
|
||||||
|
`sidecar: false` — it reads and writes files with node's own fs. there is nothing to run.
|
||||||
|
*/
|
||||||
|
has: { api: true, schema: true, sidecar: false, web: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
appName: 'example',
|
appName: 'example',
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
|||||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||||
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
||||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||||
import { appRegistryMetas as todoBoardMetas } from '../apps/TodoBoard';
|
|
||||||
import type { QueryClient } from '@tanstack/react-query';
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
import { globalQueryKey } from 'hooks/useGlobal';
|
import { globalQueryKey } from 'hooks/useGlobal';
|
||||||
import { metasToRegistry } from './useAppRegistry';
|
import { metasToRegistry } from './useAppRegistry';
|
||||||
@@ -31,7 +30,6 @@ export const apps = [
|
|||||||
...qrTransferMetas,
|
...qrTransferMetas,
|
||||||
...sendOffMetas,
|
...sendOffMetas,
|
||||||
...commandHqMetas,
|
...commandHqMetas,
|
||||||
...todoBoardMetas,
|
|
||||||
...appStoreMetas,
|
...appStoreMetas,
|
||||||
...pluginsMetas,
|
...pluginsMetas,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Plus } from 'lucide-react';
|
|||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||||
|
import { usePluginCommanderTemplates } from './useCommandHq';
|
||||||
import { useCommandHqCategories } from './useCommandHq';
|
import { useCommandHqCategories } from './useCommandHq';
|
||||||
import type { CommandHqCategoryFilter } from './types';
|
import type { CommandHqCategoryFilter } from './types';
|
||||||
import { useTranslation } from '../../locales';
|
import { useTranslation } from '../../locales';
|
||||||
@@ -36,6 +37,11 @@ export const CommandHqCategories = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
||||||
|
|
||||||
|
// Which plugins have anything to offer. Derived from the templates rather than from the installed
|
||||||
|
// plugin list: a plugin that ships no commander should not get a row that is always empty.
|
||||||
|
const { templates } = usePluginCommanderTemplates();
|
||||||
|
const plugins = [...new Set(templates.map((template) => template.plugin))].sort();
|
||||||
const { categories, createCategory } = useCommandHqCategories();
|
const { categories, createCategory } = useCommandHqCategories();
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -96,6 +102,34 @@ export const CommandHqCategories = () => {
|
|||||||
to={commandHqListPath(category.id)}
|
to={commandHqListPath(category.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
One row per plugin that offers commanders, below the owner's own categories and separated from
|
||||||
|
them.
|
||||||
|
|
||||||
|
Separated because they are not the same kind of thing. A category is the owner's filing and they
|
||||||
|
can rename or delete it; a plugin is where something came from and they cannot. Mixing them into
|
||||||
|
one list would invite the reasonable assumption that "av-club" is a folder they made, and the
|
||||||
|
first thing somebody does with a folder they made is rename it.
|
||||||
|
|
||||||
|
Shown even when the plugin has no commanders yet — that is the whole point. The row is how you
|
||||||
|
find out a plugin is offering one.
|
||||||
|
*/}
|
||||||
|
{plugins.length > 0 && (
|
||||||
|
<div className="mt-2 border-t border-border pt-2">
|
||||||
|
<span className="block px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
{t('commandHq.fromPlugins')}
|
||||||
|
</span>
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<Row
|
||||||
|
key={plugin}
|
||||||
|
label={plugin}
|
||||||
|
active={active === `plugin:${plugin}`}
|
||||||
|
to={commandHqListPath(`plugin:${plugin}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{adding && (
|
{adding && (
|
||||||
// No submit button — a form with exactly one text field still submits on Enter.
|
// No submit button — a form with exactly one text field still submits on Enter.
|
||||||
<form onSubmit={submitNew}>
|
<form onSubmit={submitNew}>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
useCommandHqCommanderActions,
|
useCommandHqCommanderActions,
|
||||||
useCreateCommander,
|
useCreateCommander,
|
||||||
} from './useCommandHq';
|
} from './useCommandHq';
|
||||||
import type { CommandHqCommanderCreated } from './types';
|
import type { CommandHqCommanderCreated, CommandHqCategoryFilter } from './types';
|
||||||
import { formatCommanderCost, formatTokens } from './format';
|
import { formatCommanderCost, formatTokens } from './format';
|
||||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||||
import { useTranslation } from '../../locales';
|
import { useTranslation } from '../../locales';
|
||||||
@@ -246,7 +246,7 @@ function NewCommanderSetup({
|
|||||||
category,
|
category,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: {
|
}: {
|
||||||
category: number | 'none' | undefined;
|
category: CommandHqCategoryFilter;
|
||||||
onCreated: (commander: CommandHqCommanderCreated) => void;
|
onCreated: (commander: CommandHqCommanderCreated) => void;
|
||||||
}) {
|
}) {
|
||||||
/*
|
/*
|
||||||
@@ -335,7 +335,7 @@ function ActiveNewCommander({
|
|||||||
onStarted,
|
onStarted,
|
||||||
}: {
|
}: {
|
||||||
commander: CommandHqCommanderCreated;
|
commander: CommandHqCommanderCreated;
|
||||||
category: number | 'none' | undefined;
|
category: CommandHqCategoryFilter;
|
||||||
onStarted: (id: string) => void;
|
onStarted: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, tUi, locale } = useTranslation();
|
const { t, tUi, locale } = useTranslation();
|
||||||
@@ -446,13 +446,7 @@ function ActiveNewCommander({
|
|||||||
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
|
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
|
||||||
* socket dropped").
|
* socket dropped").
|
||||||
*/
|
*/
|
||||||
function NewCommander({
|
function NewCommander({ category, onStarted }: { category: CommandHqCategoryFilter; onStarted: (id: string) => void }) {
|
||||||
category,
|
|
||||||
onStarted,
|
|
||||||
}: {
|
|
||||||
category: number | 'none' | undefined;
|
|
||||||
onStarted: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
|
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
|
||||||
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
|
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
|
||||||
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
|
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Check, Plus, Trash2, X } from 'lucide-react';
|
|||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
|
import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
|
||||||
|
import { PluginCommanderOffers } from './PluginCommanderOffers';
|
||||||
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
|
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
|
||||||
import { formatCommanderCost, formatTokens } from './format';
|
import { formatCommanderCost, formatTokens } from './format';
|
||||||
import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
|
import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
|
||||||
@@ -170,6 +171,12 @@ export const CommandHqGrid = () => {
|
|||||||
row.
|
row.
|
||||||
*/}
|
*/}
|
||||||
<div className="flex flex-col gap-3 p-4">
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
{/* Only on a plugin's own filter. On "All commanders" this would put every plugin's offers above
|
||||||
|
a list that is mostly not theirs, which is a different screen pretending to be this one. */}
|
||||||
|
{typeof category === 'string' && category.startsWith('plugin:') && (
|
||||||
|
<PluginCommanderOffers plugin={category.slice('plugin:'.length)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{commanders.map((commander) => (
|
{commanders.map((commander) => (
|
||||||
<Card
|
<Card
|
||||||
key={commander.id}
|
key={commander.id}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Loader2, Package, Plus } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from 'officerdev';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { usePluginCommanderTemplates } from './useCommandHq';
|
||||||
|
import type { PluginCommanderTemplate } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a plugin offers but has not been started.
|
||||||
|
*
|
||||||
|
* ── Offers sit ABOVE the grid, not inside it ──
|
||||||
|
*
|
||||||
|
* A commander in the grid is running, has a transcript and has cost something. An offer is a
|
||||||
|
* description on disk. Drawing them as the same kind of card, distinguished by a badge, makes the two
|
||||||
|
* look interchangeable — and the first consequence is somebody looking for the transcript of a thing
|
||||||
|
* that has never run.
|
||||||
|
*
|
||||||
|
* ── An offer stays visible after it has been used ──
|
||||||
|
*
|
||||||
|
* Deliberately: a plugin's commander is a template, and there is no reason to have only one. Watching
|
||||||
|
* two different folders for two different formats is two commanders from the same offer, and hiding
|
||||||
|
* the offer once used would make the second one look impossible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PluginCommanderOffers = ({ plugin }: { plugin: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { templates, createFromTemplate } = usePluginCommanderTemplates();
|
||||||
|
const [configuring, setConfiguring] = useState<PluginCommanderTemplate | null>(null);
|
||||||
|
|
||||||
|
const offers = templates.filter((template) => template.plugin === plugin);
|
||||||
|
if (offers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||||
|
<Package className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
|
{t('commandHq.available')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 p-3 pt-1">
|
||||||
|
{offers.map((offer) => (
|
||||||
|
<div key={offer.dirName} className="rounded-lg border border-border bg-card p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{offer.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{offer.description}</p>
|
||||||
|
{offer.watches && <p className="pt-0.5 text-xs text-muted-foreground/70">{offer.watches}</p>}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={configuring?.dirName === offer.dirName ? 'secondary' : 'outline'}
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => setConfiguring(configuring?.dirName === offer.dirName ? null : offer)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
{t('commandHq.configureAndCreate')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{configuring?.dirName === offer.dirName && (
|
||||||
|
<ConfigureForm
|
||||||
|
template={offer}
|
||||||
|
busy={createFromTemplate.isPending}
|
||||||
|
onCancel={() => setConfiguring(null)}
|
||||||
|
onSubmit={(name, config) => {
|
||||||
|
createFromTemplate.mutate(
|
||||||
|
{ plugin: offer.plugin, template: offer.dirName, name, config },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setConfiguring(null);
|
||||||
|
// Named, because it is now in the grid below among things that look alike.
|
||||||
|
toast.success(`${name} created — it is not running yet`);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'could not create it'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The template's own inputs, drawn from its declaration.
|
||||||
|
*
|
||||||
|
* Nothing here knows what av-club's commander wants. `options` becomes a select, `boolean` becomes
|
||||||
|
* yes/no, everything else is a text field — the same three shapes a task's inputs use, because they
|
||||||
|
* are the same declaration.
|
||||||
|
*/
|
||||||
|
const ConfigureForm = ({
|
||||||
|
template,
|
||||||
|
busy,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
template: PluginCommanderTemplate;
|
||||||
|
busy: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (name: string, config: Record<string, string>) => void;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [name, setName] = useState(template.name);
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(Object.entries(template.inputs).map(([key, spec]) => [key, spec.default ?? ''])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const missing = Object.entries(template.inputs)
|
||||||
|
.filter(([key, spec]) => spec.required && !values[key]?.trim())
|
||||||
|
.map(([key]) => key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-3 flex flex-col gap-3 border-t border-border pt-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (missing.length === 0 && name.trim()) onSubmit(name.trim(), values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('commandHq.name')}</span>
|
||||||
|
<Input value={name} onChange={(event) => setName(event.target.value)} className="text-sm" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{Object.entries(template.inputs).map(([key, spec]) => (
|
||||||
|
<label key={key} className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{key.replace(/_/g, ' ')}
|
||||||
|
{spec.required && <span className="text-destructive"> *</span>}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{spec.options ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{spec.options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
|
||||||
|
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
values[key] === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : spec.type === 'boolean' ? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['false', 'true'].map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
|
||||||
|
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
(values[key] || 'false') === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option === 'true' ? 'Yes' : 'No'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={values[key] ?? ''}
|
||||||
|
onChange={(event) => setValues((v) => ({ ...v, [key]: event.target.value }))}
|
||||||
|
placeholder={spec.type === 'directory' ? '/home/you/folder' : ''}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{spec.description && <span className="text-[11px] text-muted-foreground/70">{spec.description}</span>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="submit" size="sm" disabled={busy || missing.length > 0 || !name.trim()}>
|
||||||
|
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
{t('commandHq.configureAndCreate')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{/* Named, so it is obvious WHICH field is stopping the button rather than hunting for a red mark. */}
|
||||||
|
{missing.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">needs {missing.join(', ').replace(/_/g, ' ')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -40,6 +40,9 @@ export const commandHqSessionPath = (sessionId: string, category?: CommandHqCate
|
|||||||
export const categoryFromParam = (raw: string | null): CommandHqCategoryFilter => {
|
export const categoryFromParam = (raw: string | null): CommandHqCategoryFilter => {
|
||||||
if (raw === null) return undefined;
|
if (raw === null) return undefined;
|
||||||
if (raw === 'none') return 'none';
|
if (raw === 'none') return 'none';
|
||||||
|
// `plugin:av-club`. Checked before the numeric parse, since an appName never parses as a number and
|
||||||
|
// would otherwise fall through to `undefined` and silently show every commander.
|
||||||
|
if (raw.startsWith('plugin:') && raw.length > 'plugin:'.length) return raw as CommandHqCategoryFilter;
|
||||||
const id = Number(raw);
|
const id = Number(raw);
|
||||||
return Number.isFinite(id) ? id : undefined;
|
return Number.isFinite(id) ? id : undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,8 +26,16 @@ export type CommandHqCostSummary = {
|
|||||||
totalUsd: number;
|
totalUsd: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** `category` query value: omitted (all), `'none'` (uncategorised), or a category id. */
|
/**
|
||||||
export type CommandHqCategoryFilter = number | 'none' | undefined;
|
* `category` query value: omitted (all), `'none'` (uncategorised), a category id, or `plugin:<appName>`.
|
||||||
|
*
|
||||||
|
* The plugin form is not a category and deliberately does not pretend to be one. A commander's category
|
||||||
|
* is the owner's filing; which plugin shipped it is a fact about where it came from, and the two are
|
||||||
|
* independent — a plugin's commander can also be filed under "Nightly" without either meaning changing.
|
||||||
|
* One query parameter carries both because they are both "which subset of the grid", and that is what
|
||||||
|
* this parameter has always meant.
|
||||||
|
*/
|
||||||
|
export type CommandHqCategoryFilter = number | 'none' | `plugin:${string}` | undefined;
|
||||||
|
|
||||||
/** One grid card — `ClaudeSessionSummary` plus what Command HQ knows about it. See `GET /commanders`. */
|
/** One grid card — `ClaudeSessionSummary` plus what Command HQ knows about it. See `GET /commanders`. */
|
||||||
export type CommandHqCommander = {
|
export type CommandHqCommander = {
|
||||||
@@ -100,3 +108,23 @@ export type CommandHqFire = {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
firedAt: string;
|
firedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A commander a plugin OFFERS but which does not exist yet.
|
||||||
|
*
|
||||||
|
* Deliberately a different type from `CommandHqCommander`, and fetched from a different endpoint. One
|
||||||
|
* runs, has a transcript and costs money; the other is a description on disk. Modelling them as one
|
||||||
|
* type with an `exists` flag would mean every consumer checking the flag before touching anything, and
|
||||||
|
* the one that forgets renders a card with a cost of zero for something that was never started.
|
||||||
|
*/
|
||||||
|
export type PluginCommanderTemplate = {
|
||||||
|
dirName: string;
|
||||||
|
plugin: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
watches?: string;
|
||||||
|
inputs: Record<
|
||||||
|
string,
|
||||||
|
{ type: string; description?: string; default?: string; options?: string[]; required?: boolean }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CommandHqCommander,
|
CommandHqCommander,
|
||||||
CommandHqCommanderCreated,
|
CommandHqCommanderCreated,
|
||||||
CommandHqCommanderDetail,
|
CommandHqCommanderDetail,
|
||||||
|
PluginCommanderTemplate,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
|
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
|
||||||
@@ -173,3 +174,36 @@ export function useCommandHqChildren(sessionId: string | undefined) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the installed plugins offer, and making one real.
|
||||||
|
*
|
||||||
|
* The list is cheap and changes only when a plugin is installed, updated or disabled — so it is not
|
||||||
|
* refetched on a window focus. A plugin the owner just installed appears when Command HQ next mounts,
|
||||||
|
* which is the same moment its dock tile does.
|
||||||
|
*/
|
||||||
|
export const usePluginCommanderTemplates = () => {
|
||||||
|
const { get, post } = useClient();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ templates: PluginCommanderTemplate[] }>({
|
||||||
|
queryKey: ['command-hq', 'plugin-commanders'],
|
||||||
|
queryFn: () => get('/command-hq/plugin-commanders'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createFromTemplate = useMutation({
|
||||||
|
mutationFn: (input: { plugin: string; template: string; name: string; config: Record<string, string> }) =>
|
||||||
|
post<CommandHqCommanderCreated>('/command-hq/commanders', input),
|
||||||
|
// The grid reads `commanders`, and a newly created one has to appear in it without a reload —
|
||||||
|
// creating from a template is the one moment somebody is certain something should have happened.
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { templates: data?.templates ?? EMPTY_TEMPLATES, isLoading, createFromTemplate };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Module-level, so an empty result is the SAME array every render. A fresh `[]` in the fallback is a new
|
||||||
|
// identity each time, which re-runs every effect and memo that depends on it — the trap this file already
|
||||||
|
// avoids for categories and commanders.
|
||||||
|
const EMPTY_TEMPLATES: PluginCommanderTemplate[] = [];
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
import type { DragEvent } from 'react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import { AlertTriangle, CalendarClock, X } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { TodoItem } from './useTodos';
|
|
||||||
import { useTodoItem, useTodoPatch, useTodoTree } from './useTodos';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The board over `todo/` — the platform's own backlog, one file per item (todo/README.md).
|
|
||||||
*
|
|
||||||
* Selection and the theme filter live in the URL (`?selected=`, `?theme=`), per the navigation audit:
|
|
||||||
* a card is a real link, a filtered board is an addressable page, and back means what it should.
|
|
||||||
*
|
|
||||||
* Writes go through PATCH /todos/item, which touches frontmatter only and regenerates TODO.md — this
|
|
||||||
* board never holds state the files do not; a drag is a file edit and a refetch.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const COLUMNS = [
|
|
||||||
{ status: 'open', label: 'Open' },
|
|
||||||
{ status: 'doing', label: 'Doing' },
|
|
||||||
{ status: 'blocked', label: 'Blocked' },
|
|
||||||
{ status: 'backburner', label: 'Backburner' },
|
|
||||||
{ status: 'done', label: 'Done' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const TodoBoardPanel = () => {
|
|
||||||
const { data, isLoading, error } = useTodoTree();
|
|
||||||
const patch = useTodoPatch();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
const selected = params.get('selected');
|
|
||||||
const themeFilter = params.get('theme');
|
|
||||||
const [dragOver, setDragOver] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const href = (changes: Record<string, string | null>): string => {
|
|
||||||
const next = new URLSearchParams(params);
|
|
||||||
for (const [key, value] of Object.entries(changes)) {
|
|
||||||
if (value === null) next.delete(key);
|
|
||||||
else next.set(key, value);
|
|
||||||
}
|
|
||||||
const qs = next.toString();
|
|
||||||
return qs ? `?${qs}` : '.';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-muted-foreground">
|
|
||||||
The todo board is owner-only, and this account was refused. Nothing here is broken — there is simply nothing to
|
|
||||||
show.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isLoading || !data) {
|
|
||||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Reading todo/…</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const themes = data.themes.filter((t) => t.items.length > 0);
|
|
||||||
const items = themes.flatMap((t) => t.items);
|
|
||||||
const visible = themeFilter ? items.filter((i) => i.theme === themeFilter) : items;
|
|
||||||
|
|
||||||
const drop = (status: string) => (ev: DragEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
setDragOver(null);
|
|
||||||
const id = ev.dataTransfer.getData('text/todo-id');
|
|
||||||
if (id) patch.mutate({ id, status });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0">
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 border-b border-border px-4 py-2">
|
|
||||||
<Link
|
|
||||||
to={href({ theme: null })}
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-2.5 py-0.5 text-xs',
|
|
||||||
!themeFilter
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-muted text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
All · {items.filter((i) => i.status !== 'done').length}
|
|
||||||
</Link>
|
|
||||||
{themes.map((t) => {
|
|
||||||
const open = t.items.filter((i) => i.status !== 'done').length;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={t.theme}
|
|
||||||
to={href({ theme: t.theme === themeFilter ? null : t.theme })}
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-2.5 py-0.5 text-xs',
|
|
||||||
themeFilter === t.theme
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-muted text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t.heading.split('—')[0]?.trim()} · {open}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
|
|
||||||
{COLUMNS.map((col) => {
|
|
||||||
const cards = visible
|
|
||||||
.filter((i) => i.status === col.status)
|
|
||||||
.sort((a, b) => (b.priority === 'urgent' ? 1 : 0) - (a.priority === 'urgent' ? 1 : 0));
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col.status}
|
|
||||||
onDragOver={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
setDragOver(col.status);
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setDragOver(null)}
|
|
||||||
onDrop={drop(col.status)}
|
|
||||||
className={cn(
|
|
||||||
'flex w-64 shrink-0 flex-col rounded-lg border border-border bg-muted/30',
|
|
||||||
dragOver === col.status && 'border-primary bg-primary/5',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline justify-between px-3 py-2 text-xs font-semibold text-muted-foreground">
|
|
||||||
<span>{col.label}</span>
|
|
||||||
<span>{cards.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
|
|
||||||
{cards.map((item) => (
|
|
||||||
<TodoCard
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
href={href({ selected: item.id })}
|
|
||||||
selected={selected === item.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selected ? <TodoDetail id={selected} closeHref={href({ selected: null })} /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type TodoCardProps = { item: TodoItem; href: string; selected: boolean };
|
|
||||||
|
|
||||||
const TodoCard = ({ item, href, selected }: TodoCardProps) => (
|
|
||||||
<Link
|
|
||||||
to={href}
|
|
||||||
draggable
|
|
||||||
onDragStart={(ev) => ev.dataTransfer.setData('text/todo-id', item.id)}
|
|
||||||
className={cn(
|
|
||||||
'block rounded-md border border-border bg-card p-2.5 text-left shadow-sm hover:border-primary/50',
|
|
||||||
selected && 'border-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="text-xs leading-snug text-card-foreground">{item.title}</div>
|
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground">
|
|
||||||
<span className="rounded bg-muted px-1 py-px">{item.theme}</span>
|
|
||||||
{item.priority === 'urgent' && (
|
|
||||||
<span className="flex items-center gap-0.5 rounded bg-destructive/15 px-1 py-px text-destructive">
|
|
||||||
<AlertTriangle className="h-2.5 w-2.5" /> urgent
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.due && (
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<CalendarClock className="h-2.5 w-2.5" /> {item.due}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{item.created}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
type TodoDetailProps = { id: string; closeHref: string };
|
|
||||||
|
|
||||||
const TodoDetail = ({ id, closeHref }: TodoDetailProps) => {
|
|
||||||
const { data, isLoading } = useTodoItem(id);
|
|
||||||
const patch = useTodoPatch();
|
|
||||||
const status = data?.meta.status ?? 'open';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-[28rem] shrink-0 flex-col border-l border-border">
|
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
|
|
||||||
<span className="truncate font-mono text-xs text-muted-foreground">{id}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={status}
|
|
||||||
onChange={(ev) => patch.mutate({ id, status: ev.target.value })}
|
|
||||||
className="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
|
|
||||||
>
|
|
||||||
{COLUMNS.map((c) => (
|
|
||||||
<option key={c.status} value={c.status}>
|
|
||||||
{c.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Link to={closeHref} className="text-muted-foreground hover:text-foreground">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="file-viewer-md min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm">
|
|
||||||
{isLoading || !data ? (
|
|
||||||
<span className="text-sm text-muted-foreground">Reading…</span>
|
|
||||||
) : (
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.body}</ReactMarkdown>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
|
||||||
import { SquareKanban } from 'lucide-react';
|
|
||||||
import { TodoBoardPanel } from './TodoBoardPanel';
|
|
||||||
|
|
||||||
export { TodoBoardPanel } from './TodoBoardPanel';
|
|
||||||
export { useTodoTree, useTodoItem, useTodoPatch } from './useTodos';
|
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
|
||||||
{ key: 'todo-board', name: 'Todo board', icon: SquareKanban, component: TodoBoardPanel },
|
|
||||||
];
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
|
|
||||||
export type TodoItem = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
theme: string;
|
|
||||||
status: string;
|
|
||||||
created: string;
|
|
||||||
priority: string;
|
|
||||||
due: string;
|
|
||||||
order: number;
|
|
||||||
file: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] };
|
|
||||||
|
|
||||||
export type TodoPatch = { id: string; status?: string; priority?: string; due?: string; order?: number };
|
|
||||||
|
|
||||||
export const useTodoTree = () => {
|
|
||||||
const client = useClient();
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['todos'],
|
|
||||||
queryFn: () => client.get<{ themes: TodoTheme[] }>('/todos'),
|
|
||||||
// The tree is files on disk that other agents edit while the board is open; refetch keeps the board
|
|
||||||
// honest without a watcher, which the sync contract allows (todo/README.md — poll is the cheapest
|
|
||||||
// thing that works).
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useTodoItem = (id: string | null) => {
|
|
||||||
const client = useClient();
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['todos', 'item', id],
|
|
||||||
queryFn: () =>
|
|
||||||
client.get<{ meta: Record<string, string>; body: string }>(`/todos/item?id=${encodeURIComponent(id!)}`),
|
|
||||||
enabled: id !== null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useTodoPatch = () => {
|
|
||||||
const client = useClient();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (patch: TodoPatch) => client.patch<{ ok: boolean }>('/todos/item', patch),
|
|
||||||
onSuccess: (_data, patch) => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['todos', 'item', patch.id] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { ChevronRight, CornerLeftUp, Folder, FolderPlus, Loader2 } from 'lucide-react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
// The platform's own formatter rather than fofos-admin's, which knew about that plugin's three
|
||||||
|
// specific failures. Here the only failures are "cannot read that folder" and "not allowed".
|
||||||
|
import { errorText } from 'helpers/error-text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a directory, and make one if it is not there yet.
|
||||||
|
*
|
||||||
|
* ── Shared, because two plugins wanted it and a plugin cannot import from another ──
|
||||||
|
*
|
||||||
|
* Written inside fofos-admin, then wanted verbatim by kanban's project form. A plugin cannot import
|
||||||
|
* from a sibling plugin, so the choice was to copy it or to move it here — and there is nothing
|
||||||
|
* fofos-specific in it: it browses the owner's home over the platform's own file API. Copying would
|
||||||
|
* have meant two of them drifting apart, which for a component whose whole job is "do not let somebody
|
||||||
|
* type a path wrong" is the wrong kind of thing to have two of.
|
||||||
|
*
|
||||||
|
* ── It uses the PLATFORM's file API, not routes of any plugin's own ──
|
||||||
|
*
|
||||||
|
* `/api/file-browser/ls` and `/api/file-browser/mkdir` already exist, already refuse to leave the
|
||||||
|
* home directory, and already distinguish "missing" from "not allowed" — a distinction that cost
|
||||||
|
* somebody an afternoon to get right and would have been re-guessed here. A plugin that shipped its
|
||||||
|
* own listing and mkdir would be a second filesystem surface to audit for the sake of one form.
|
||||||
|
*
|
||||||
|
* The consequence is worth stating: this browses the OWNER'S HOME and nothing above it. The file API
|
||||||
|
* is an execution capability and never grantable, so for anybody but the owner these calls 403 — the
|
||||||
|
* component says so instead of showing an empty tree, because an empty listing is data and must never
|
||||||
|
* be how a refusal looks.
|
||||||
|
*
|
||||||
|
* A path outside home is still reachable by TYPING it in the field this sits under. The picker is a
|
||||||
|
* convenience over the common case, not the only way in.
|
||||||
|
*
|
||||||
|
* ── `rootDir` comes from the server, so the full path is real ──
|
||||||
|
*
|
||||||
|
* `ls` answers with the absolute `rootDir` it resolved as well as the relative path inside it. Joining
|
||||||
|
* those two is what lets this hand back an absolute path without the browser ever guessing at what
|
||||||
|
* home means — which it cannot know and would get wrong for anybody but the owner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Entry = { name: string; type: 'directory' | 'file' };
|
||||||
|
type Listing = { path: string; rootDir?: string; entries: Entry[] };
|
||||||
|
|
||||||
|
/** `/a/b` under `/home/x` → `/home/x/a/b`. The root itself is `/`, which must not become a trailing slash. */
|
||||||
|
const absolute = (rootDir: string, path: string) => `${rootDir}${path === '/' ? '' : path}`.replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
|
export const DirectoryPicker = ({
|
||||||
|
startAt,
|
||||||
|
onPick,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
/** Relative to home, e.g. `/fofos`. Where the browser opens. */
|
||||||
|
startAt?: string;
|
||||||
|
/** The chosen directory, ABSOLUTE. */
|
||||||
|
onPick: (absolutePath: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) => {
|
||||||
|
const client = useClient();
|
||||||
|
const clientRef = useRef(client);
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
const [path, setPath] = useState(startAt ?? '/');
|
||||||
|
const [listing, setListing] = useState<Listing | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async (target: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await clientRef.current.get<Listing>(
|
||||||
|
`/file-browser/ls?root=home&path=${encodeURIComponent(target)}`,
|
||||||
|
);
|
||||||
|
setListing(result);
|
||||||
|
// `ls` resets to the root when a path has gone, and reports where it actually ended up. Following
|
||||||
|
// its answer rather than our request is what stops a stale path showing the wrong folder's contents.
|
||||||
|
setPath(result.path);
|
||||||
|
} catch (failure) {
|
||||||
|
setError(errorText(failure, 'could not read that folder'));
|
||||||
|
setListing(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(startAt ?? '/');
|
||||||
|
}, [load, startAt]);
|
||||||
|
|
||||||
|
const rootDir = listing?.rootDir ?? '';
|
||||||
|
const directories = (listing?.entries ?? []).filter((entry) => entry.type === 'directory');
|
||||||
|
const parent = path === '/' ? null : path.slice(0, path.lastIndexOf('/')) || '/';
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const target = path === '/' ? `/${name}` : `${path}/${name}`;
|
||||||
|
await clientRef.current.post('/file-browser/mkdir?root=home', { path: target });
|
||||||
|
setNewName('');
|
||||||
|
setCreating(false);
|
||||||
|
// Straight into it. Making a folder here means meaning to use it, and stopping at its parent
|
||||||
|
// makes somebody find what they just made in a list.
|
||||||
|
await load(target);
|
||||||
|
} catch (failure) {
|
||||||
|
setError(errorText(failure, 'could not read that folder'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-md border border-border">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
|
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">{absolute(rootDir, path)}</span>
|
||||||
|
{loading && <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-56 overflow-auto px-1">
|
||||||
|
{parent !== null && (
|
||||||
|
<button
|
||||||
|
onClick={() => void load(parent)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<CornerLeftUp className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
Up one level
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{directories.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.name}
|
||||||
|
onClick={() => void load(path === '/' ? `/${entry.name}` : `${path}/${entry.name}`)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Only when there is genuinely nothing, and worded as a fact about this folder. Files are not
|
||||||
|
listed at all — this picks a directory, and showing them would be a list of things that
|
||||||
|
cannot be chosen. */}
|
||||||
|
{!loading && !error && directories.length === 0 && (
|
||||||
|
<p className="px-2 py-2 text-xs text-muted-foreground">No folders here.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="px-3 text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2">
|
||||||
|
{creating ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={(event) => setNewName(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') void create();
|
||||||
|
if (event.key === 'Escape') setCreating(false);
|
||||||
|
}}
|
||||||
|
placeholder="New folder name"
|
||||||
|
className="h-7 min-w-0 flex-1 text-xs"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<Button size="sm" className="h-7" onClick={() => void create()} disabled={!newName.trim()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7" onClick={() => setCreating(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" className="h-7" onClick={() => setCreating(true)}>
|
||||||
|
<FolderPlus className="size-3.5" />
|
||||||
|
New folder
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button variant="ghost" size="sm" className="h-7" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{/* Chooses the folder currently OPEN, not one highlighted in the list. Opening a folder to
|
||||||
|
look inside it and then choosing it is one gesture rather than two, and it is the only
|
||||||
|
way to choose a folder that has nothing in it yet — which a freshly made one always is. */}
|
||||||
|
<Button size="sm" className="h-7" disabled={!rootDir} onClick={() => onPick(absolute(rootDir, path))}>
|
||||||
|
Use this folder
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ export { panelCatalogs } from './locales';
|
|||||||
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
||||||
export type { PageTitleOverride } from './page-title';
|
export type { PageTitleOverride } from './page-title';
|
||||||
export * from './AppRegistry';
|
export * from './AppRegistry';
|
||||||
|
/** Shared UI a plugin can use. `DirectoryPicker` is here because two plugins wanted the same one. */
|
||||||
|
export { DirectoryPicker } from './components/DirectoryPicker';
|
||||||
export * from './WidgetRegistry';
|
export * from './WidgetRegistry';
|
||||||
|
|
||||||
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
|
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
},
|
},
|
||||||
"startHint": "Er sieht sich zunächst an, worauf Sie ihn hingewiesen haben, und fragt Sie dann, worauf er achten soll.",
|
"startHint": "Er sieht sich zunächst an, worauf Sie ihn hingewiesen haben, und fragt Sie dann, worauf er achten soll.",
|
||||||
"startBlocked": "{{missing}} — dann startet dies ihn.",
|
"startBlocked": "{{missing}} — dann startet dies ihn.",
|
||||||
"confirmDeletingNamed": "Löschen von {{name}} bestätigen"
|
"confirmDeletingNamed": "Löschen von {{name}} bestätigen",
|
||||||
|
"fromPlugins": "Von Plugins",
|
||||||
|
"available": "Verfügbar",
|
||||||
|
"configureAndCreate": "Einrichten und erstellen",
|
||||||
|
"notCreatedYet": "Noch nicht erstellt — richten Sie es ein, um zu starten."
|
||||||
},
|
},
|
||||||
"chatHistory": {
|
"chatHistory": {
|
||||||
"newChat": "Neuer Chat",
|
"newChat": "Neuer Chat",
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
},
|
},
|
||||||
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
|
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
|
||||||
"startBlocked": "{{missing}} — then this starts it.",
|
"startBlocked": "{{missing}} — then this starts it.",
|
||||||
"confirmDeletingNamed": "Confirm deleting {{name}}"
|
"confirmDeletingNamed": "Confirm deleting {{name}}",
|
||||||
|
"fromPlugins": "From plugins",
|
||||||
|
"available": "Available",
|
||||||
|
"configureAndCreate": "Configure and create",
|
||||||
|
"notCreatedYet": "Not created yet — configure it to start."
|
||||||
},
|
},
|
||||||
"chatHistory": {
|
"chatHistory": {
|
||||||
"newChat": "New Chat",
|
"newChat": "New Chat",
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
},
|
},
|
||||||
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
|
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
|
||||||
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
|
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
|
||||||
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}"
|
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}",
|
||||||
|
"fromPlugins": "De plugins",
|
||||||
|
"available": "Disponible",
|
||||||
|
"configureAndCreate": "Configurar y crear",
|
||||||
|
"notCreatedYet": "Aún no creado — configúralo para empezar."
|
||||||
},
|
},
|
||||||
"chatHistory": {
|
"chatHistory": {
|
||||||
"newChat": "Nuevo chat",
|
"newChat": "Nuevo chat",
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
},
|
},
|
||||||
"startHint": "Il va regarder ce vers quoi vous l'avez orienté, puis vous demander ce qu'il doit surveiller.",
|
"startHint": "Il va regarder ce vers quoi vous l'avez orienté, puis vous demander ce qu'il doit surveiller.",
|
||||||
"startBlocked": "{{missing}} — ensuite ceci le démarre.",
|
"startBlocked": "{{missing}} — ensuite ceci le démarre.",
|
||||||
"confirmDeletingNamed": "Confirmer la suppression de {{name}}"
|
"confirmDeletingNamed": "Confirmer la suppression de {{name}}",
|
||||||
|
"fromPlugins": "Depuis les plugins",
|
||||||
|
"available": "Disponible",
|
||||||
|
"configureAndCreate": "Configurer et créer",
|
||||||
|
"notCreatedYet": "Pas encore créé — configurez-le pour commencer."
|
||||||
},
|
},
|
||||||
"chatHistory": {
|
"chatHistory": {
|
||||||
"newChat": "Nouveau Chat",
|
"newChat": "Nouveau Chat",
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
},
|
},
|
||||||
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
|
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
|
||||||
"startBlocked": "{{missing}} — depois é isto que o inicia.",
|
"startBlocked": "{{missing}} — depois é isto que o inicia.",
|
||||||
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}"
|
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}",
|
||||||
|
"fromPlugins": "De plugins",
|
||||||
|
"available": "Disponível",
|
||||||
|
"configureAndCreate": "Configurar e criar",
|
||||||
|
"notCreatedYet": "Ainda não criado — configure para começar."
|
||||||
},
|
},
|
||||||
"chatHistory": {
|
"chatHistory": {
|
||||||
"newChat": "Novo chat",
|
"newChat": "Novo chat",
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
},
|
},
|
||||||
"startHint": "Den tittar på det du pekade den mot, och frågar dig sedan vad den ska bevaka.",
|
"startHint": "Den tittar på det du pekade den mot, och frågar dig sedan vad den ska bevaka.",
|
||||||
"startBlocked": "{{missing}} — sedan startar detta den.",
|
"startBlocked": "{{missing}} — sedan startar detta den.",
|
||||||
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}"
|
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}",
|
||||||
|
"fromPlugins": "Från plugin",
|
||||||
|
"available": "Tillgänglig",
|
||||||
|
"configureAndCreate": "Konfigurera och skapa",
|
||||||
|
"notCreatedYet": "Inte skapad än — konfigurera för att starta."
|
||||||
},
|
},
|
||||||
"chatHistory": {
|
"chatHistory": {
|
||||||
"newChat": "Ny chatt",
|
"newChat": "Ny chatt",
|
||||||
|
|||||||
Reference in New Issue
Block a user