Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
007cc9a1f2 | ||
|
|
8e2670ff1d | ||
|
|
6b8cf848ff | ||
|
|
f56b9de0d6 | ||
|
|
c4cd42eb29 | ||
|
|
21bc460892 | ||
|
|
31151e7aa6 | ||
|
|
2b720cb2c2 | ||
|
|
c4dba8cb60 | ||
|
|
7d2b8b644d | ||
|
|
ffb4c3eedd | ||
|
|
538a623bb8 | ||
|
|
01cecdad60 | ||
|
|
fc474d3061 | ||
|
|
ea4b8a1e5f | ||
|
|
d1a41d6534 | ||
|
|
de0590c25b | ||
|
|
1072e17193 | ||
|
|
efe01e3019 | ||
|
|
ab79470356 | ||
|
|
a35df2afb2 | ||
|
|
141e470aee | ||
|
|
92fec50fcb | ||
|
|
4d2ee284ff | ||
|
|
a3fced25c4 | ||
|
|
27a8c29fe4 | ||
|
|
4564d6eb60 | ||
|
|
941259ce4f | ||
|
|
ed2a343676 | ||
|
|
02d44b93a4 | ||
|
|
22a9d58d5c | ||
|
|
f16aa87d8b | ||
|
|
7a651aee77 | ||
|
|
be56c0c7cd | ||
|
|
9bf31eb53c | ||
|
|
95dd9740da | ||
|
|
92841efac9 | ||
|
|
f07a1c8cc7 | ||
|
|
5a4fcd5a24 | ||
|
|
0096392cde | ||
|
|
b5c91a81c5 | ||
|
|
1b2a2d0a78 | ||
|
|
f4e983693e | ||
|
|
ee1cd006af | ||
|
|
70acccfe0c | ||
|
|
47db0383c7 | ||
|
|
33e8e70e06 | ||
|
|
e201bceafb | ||
|
|
d4b26be706 | ||
|
|
0fe02c84af | ||
|
|
27bd2ac768 | ||
|
|
e87323f71a | ||
|
|
20b270ef07 | ||
|
|
3e10c43bd5 | ||
|
|
6041379e98 | ||
|
|
facf1a5f9e | ||
|
|
af41144689 | ||
|
|
2c0e23d4fb | ||
|
|
68f4fe61fa | ||
|
|
58dbfc8313 | ||
|
|
d6d102111e | ||
|
|
db96f53bc2 | ||
|
|
03601db69c | ||
|
|
ddabf46bf3 | ||
|
|
887f702c05 | ||
|
|
9dfa83e607 | ||
|
|
420cd07507 | ||
|
|
fdf10f02f7 | ||
|
|
236572107b | ||
|
|
879edbdfb0 | ||
|
|
61c546ab79 | ||
|
|
fdd53b0dd9 | ||
|
|
a321babb28 | ||
|
|
955837b5bb | ||
|
|
a9dd264a72 | ||
|
|
46ae8051d4 | ||
|
|
7e50134200 | ||
|
|
dd6d0a8e12 | ||
|
|
5ee09a3fb6 | ||
|
|
0a242e15ba | ||
|
|
c6a92735d3 | ||
|
|
cb2dcfa4cc | ||
|
|
de633f8697 | ||
|
|
16d02ffee1 | ||
|
|
40a03d987f | ||
|
|
2d5e9835f5 | ||
|
|
478ddb13e9 | ||
|
|
00baa83a2c | ||
|
|
a3a8516e66 | ||
|
|
12a9605b01 | ||
|
|
e29a4ee380 | ||
|
|
247291eeb9 | ||
|
|
121ddb9964 | ||
|
|
6d673a1321 | ||
|
|
50e96e0d77 | ||
|
|
c427cb5b61 | ||
|
|
d74e07ef08 |
@@ -212,7 +212,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:
|
||||
`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
|
||||
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 3 execution, 5 admin. 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). **22 entries as of 2026-08-24** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 3 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). `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
|
||||
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`.
|
||||
@@ -541,7 +541,7 @@ explaining why it was safe.
|
||||
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||
- `docs/plugin-capabilities.md` — **designed, not built**: a plugin's tasks, skills and affordances are
|
||||
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
|
||||
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
|
||||
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
|
||||
a namespace makes provenance readable, separate directories make it structural
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Deferred work. **The items live in `todo/`, one file each**, grouped by theme; this file is the map
|
||||
over them and is GENERATED — run `bun run todo:index` after any change rather than editing it here.
|
||||
Format and the two-way-sync contract: `todo/README.md`.
|
||||
Format and the two-way-sync contract: `todo/README.md`. Browse and edit it at `/todos` in the app.
|
||||
|
||||
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
|
||||
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
||||
@@ -17,17 +17,19 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
## Urgent
|
||||
|
||||
- **due 2026-08-22** — **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
|
||||
- **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||
|
||||
## Themes
|
||||
|
||||
**90 open, 25 closed**, across 29 themes.
|
||||
**97 open, 31 closed**, across 32 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
||||
| [Backburner](todo/backburner/) | 2 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 3 | 0 |
|
||||
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||
| [Agents coordinating agents](todo/agent-coordination/) | 3 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
@@ -35,20 +37,22 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 3 | 2 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 16 | 5 |
|
||||
| [Known bugs](todo/known-bugs/) | 5 | 4 |
|
||||
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 4 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||
| [SendOff](todo/sendoff/) | 1 | 0 |
|
||||
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
|
||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
|
||||
@@ -63,13 +67,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `2026-08-22` **'A commander picks the cheapest model that will do the job — for itself and for what it spawns'** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md)
|
||||
- `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md)
|
||||
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
|
||||
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
|
||||
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
|
||||
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
|
||||
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
|
||||
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
|
||||
- `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
|
||||
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
|
||||
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
|
||||
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
|
||||
- `2026-08-25` **Refresh mid-turn folds the live tool call — it reads as finished while still running** — [known-bugs/refresh-mid-tool-call-renders-the-turn-folded](todo/known-bugs/refresh-mid-tool-call-renders-the-turn-folded.md)
|
||||
- `2026-08-25` **The model picker shows `claude-code` instead of the model that is running** — [known-bugs/the-model-picker-shows-the-bare-harness](todo/known-bugs/the-model-picker-shows-the-bare-harness.md)
|
||||
- `2026-08-25` **Understand how Claude and opencode do memory, then centralize both into one vector database** — [memory/understand-and-centralize-agent-memory-into-a](todo/memory/understand-and-centralize-agent-memory-into-a.md)
|
||||
- `2026-08-25` **Deep-link a todo notification straight to that item's markdown** — [mobile-apps/deep-link-a-todo-notification-to-its-file](todo/mobile-apps/deep-link-a-todo-notification-to-its-file.md)
|
||||
- `2026-08-25` **Add a favorites tab/panel to the file browser** — [multi-user/add-a-favorites-tab-panel-to-the-file-browser](todo/multi-user/add-a-favorites-tab-panel-to-the-file-browser.md)
|
||||
- `2026-08-23` **A worker cannot verify that the owner delegated to a manager, so it has to ask every time** — [agent-coordination/a-peer-cannot-verify-a-delegation](todo/agent-coordination/a-peer-cannot-verify-a-delegation.md)
|
||||
- `2026-08-23` **An agent cannot learn its own transcript id, so it cannot sign its own work** — [agent-coordination/an-agent-cannot-learn-its-own-session-id](todo/agent-coordination/an-agent-cannot-learn-its-own-session-id.md)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 2026.08.24.02 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by plan, neither a failure.
|
||||
|
||||
Rolled out `deploy/2026.08.24.02` → `e0d80fd3`. Tag and `package.json` agreed. Every target moved
|
||||
`c813eef6` → `e0d80fd3` — one commit, no host ahead, no host behind.
|
||||
|
||||
Speech-to-text left the platform. Nothing was installed in its place: the `transcribe` plugin is in the
|
||||
catalogue and available, and per the plan no host was given it. That is the owner's decision, per host.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Before | After | db:push | plugins | officer restart | processes |
|
||||
| ---- | ------ | ----- | ------- | ------- | --------------- | --------- |
|
||||
| `alpha` | `c813eef6` | `e0d80fd3` | no changes | 13/13 | 74 → 75 | 20/20 online |
|
||||
| `fofos` | `c813eef6` | `e0d80fd3` | no changes | 1/1 | 27 → 28 | 8/8 online |
|
||||
| `production-pertento-bridge` | `c813eef6` | `e0d80fd3` | no changes | 1/1 | 6 → 7 | 15/15 online |
|
||||
| `deadpool-rezio-bridge` | `c813eef6` | `e0d80fd3` | no changes | 2/2 | 12 → 13 | 9/9 online |
|
||||
| `macbookpro` | `c813eef6` | `e0d80fd3` | no changes | none installed | 14 → 15 | 6/6 online |
|
||||
|
||||
Every host ran the plan's four steps in order, all four exited 0, and none was stopped early. Checkout
|
||||
paths were taken from each `officer` process's `pm_cwd`, not assumed — `alpha` resolved to
|
||||
`~/officerdev/platform`, not its stale second checkout. `deadpool-rezio-bridge` was reached as `br`.
|
||||
`macbookpro` was awake; its `bun` commands ran with `/opt/homebrew/bin` appended to PATH and `pm2` as a
|
||||
separate command.
|
||||
|
||||
## Schema — verified, not assumed
|
||||
|
||||
The plan says no schema changed and `bun db:push` must report no changes. **It reported none on all five
|
||||
hosts**, and the evidence is that it emitted no DDL: no `ALTER`, no `CREATE`, no statement of any kind
|
||||
between reading the config and finishing. `[✓] Changes applied` alone was not treated as evidence —
|
||||
`drizzle-kit push` prints that line whether or not it did anything, and it can exit 0 on a half-applied
|
||||
change.
|
||||
|
||||
`stdin` was `/dev/null` on every host, so no prompt could be answered and none was. No direct SQL was
|
||||
needed, none was authorised, and none was run. `--force` was not used anywhere.
|
||||
|
||||
The seed steps that follow `push` were no-ops everywhere, which is the same answer from a second angle:
|
||||
`0 seeded, 4 already present` for roles on all five, and every transcript already recorded.
|
||||
|
||||
## Skipped
|
||||
|
||||
- **`officer-dev`** — the host this rollout ran on. Not pulled, nothing restarted. It already carries
|
||||
`e0d80fd3` and has the `transcribe` plugin installed. Restarting `officer` here ends the rollout
|
||||
mid-flight, so the whole host is left alone deliberately. **The owner restarts it, as the last step,
|
||||
once happy with the rest.**
|
||||
- **`edge-pertento-bridge`** — deliberately skipped per `docs/servers.md`, down since 2026-08-23 by the
|
||||
owner's call. Not contacted at all, so it cost nothing this time.
|
||||
|
||||
## Notes — nothing here needs action
|
||||
|
||||
- **`plugins:update` bounced far more than `officer`.** Sequential per plugin, each printing
|
||||
`sidecar: started`: 13 sidecars on `alpha`, 2 on `deadpool`, 1 each on `fofos` and
|
||||
`production-pertento-bridge`. A report listing only `officer` under "restarted" would understate what
|
||||
moved. All came back online.
|
||||
- **`fofos` has one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could never
|
||||
touch it, and nobody's work was discarded to ship this.
|
||||
- **`production-pertento-bridge` prints an nvm warning** on every interactive shell: `.npmrc` carries a
|
||||
`globalconfig`/`prefix` setting incompatible with nvm. Cosmetic, pre-existing, unrelated to this
|
||||
release, and it did not affect any step. Not fixed — that is not a rollout's job.
|
||||
|
||||
## What was not verified
|
||||
|
||||
The plan's visible check — that Settings no longer offers Speech to Text — is a **browser** check, and
|
||||
this rollout has no browser. The code carrying that screen is gone from every host's tree, which is
|
||||
verified; that the rendered UI reflects it is not, and a stale JS bundle would look identical to a failed
|
||||
deploy. Hard refresh before concluding anything from it.
|
||||
@@ -0,0 +1,124 @@
|
||||
# 2026.08.25.01
|
||||
|
||||
Commanders become usable, agents get a credential that can be bounded, and a nudge finally arrives.
|
||||
|
||||
96 commits — the largest release so far, and most of it is one night's work on commanders driven by
|
||||
watching real setup runs.
|
||||
|
||||
**One schema change**, additive and safe. **No dependency change** — `bun.lock` is untouched.
|
||||
**Do not run `bun install`.**
|
||||
|
||||
**`edge-pertento-bridge` is NOT a target for this release.** It needs a schema step no other host does.
|
||||
See "Edge is excluded, and why" — this is the important part of this plan.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Commanders.** The template a new commander is created with was rewritten across the night: it asks
|
||||
one question per turn in the owner's own language, proves its action on a single item before arming,
|
||||
writes its own `CLASSIFICATION.md` and `DISPATCHING.md` rather than editing the platform's file, and
|
||||
records per step in its ledger. The Stop control works, a deleted commander stays deleted, and its
|
||||
children no longer appear in `/chat`.
|
||||
- **Every agent session gets a key, scoped to `notify`.** A chat can send you a notification through
|
||||
the platform instead of having no sanctioned way to reach you. It is minted per session and revoked
|
||||
with it.
|
||||
- **Credential narrowing** — `api_keys.permissions` (an allow list) and `api_keys.excluded_kinds`
|
||||
(resolved against the live registry). Applied **before** the owner bypass, which makes it the only
|
||||
thing that can bound a Super Admin — the case that matters, because a commander holds the owner's
|
||||
identity unattended for months. This is the schema change.
|
||||
- **`notify` moved from `app` to `core`.** It was grantable, nothing granted it, and every Member's app
|
||||
was silently 403'd registering its own push token. On the bridges that is four accounts.
|
||||
- **The loopback notify door is closed.** `/_officer/` paths now require a producer secret the sidecar
|
||||
issues at registration. It previously accepted any caller that set a header.
|
||||
- **Sidecar proxy timeouts are OFF.** `TIMEOUTS_ON = false` in `create-proxy.ts`, one line to reverse.
|
||||
Bun's `server.timeout()` stores one byte, so declaring 1800 seconds gave a _shorter_ timeout than
|
||||
declaring nothing. More permissive than what these hosts run today — a long transcription or a film
|
||||
no longer gets cut.
|
||||
- **A nudge always arrives.** Three refusals removed from the nudger. Read that commit before judging
|
||||
it: the old guards meant no nudge could ever fire on a machine running commanders, silently.
|
||||
- **Plugin tasks appear in the file browser**, and a direct task run is direct — no modal, no job row.
|
||||
- **`/todos`**, a kanban board over the repo's own `todo/` tree, owner-only.
|
||||
- **Two editor fixes** — one buffer per file across panels, and the model picker reading the right id.
|
||||
|
||||
## The schema change
|
||||
|
||||
Two nullable columns on `api_keys`:
|
||||
|
||||
```
|
||||
permissions text[] null
|
||||
excluded_kinds text[] null
|
||||
```
|
||||
|
||||
Both null means unnarrowed, which is what every existing key is — including the mobile apps'. **No
|
||||
existing credential changes behaviour.** Nothing is dropped and nothing is rewritten.
|
||||
|
||||
`bun db:push` applies it. **Do not take the exit code as the answer** — drizzle-kit can print an error
|
||||
and still exit 0. Verify on each host, against that host's own database:
|
||||
|
||||
```sql
|
||||
select column_name from information_schema.columns
|
||||
where table_name = 'api_keys' and column_name in ('permissions','excluded_kinds');
|
||||
```
|
||||
|
||||
Two rows, or the host is not done. `permissions` alone is a half-applied push — stop and report it.
|
||||
|
||||
## Every host except edge
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.25.01`
|
||||
2. `bun db:push` — then **run the query above**. Two rows.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. It is
|
||||
a laptop and may be asleep — unreachable is a skip with a note, not a failure.
|
||||
|
||||
## Edge is excluded, and why
|
||||
|
||||
`edge-pertento-bridge` came back on 2026-08-24 at `deploy/2026.08.22.02`, **288 commits behind**. It is
|
||||
a deploy target again and its row is in `docs/servers.md`, but it must not be rolled out with the rest,
|
||||
and the reason was verified on the host rather than inferred:
|
||||
|
||||
- **It has no `roles` table.** `to_regclass('roles')` is null.
|
||||
- **Its two accounts hold `Super Admin` and `Developer`.**
|
||||
|
||||
Current schema makes `users.role` a foreign key onto `roles.name`. Every other host got `roles` created
|
||||
and seeded by `2026.08.23.04`, then the foreign key added by `2026.08.23.05` — two releases, in order,
|
||||
against an already-populated table. Edge would do both in one `db:push`, and the seeder runs from
|
||||
`postdb:push`, which is **after** the push completes. So the constraint would be created against an
|
||||
empty `roles` while `users.role` holds two values that are not in it.
|
||||
|
||||
That fails. Worse, it can fail and still exit 0, leaving the schema half-applied — which is exactly the
|
||||
warning `2026.08.23.05` carried.
|
||||
|
||||
**It also missed two plans with one-off steps** that a single fast-forward does not replay:
|
||||
`2026.08.23.05`'s pre-flight SQL, and `2026.08.23.06`'s orphan-affordance cleanup.
|
||||
|
||||
So: **name edge as skipped, do not attempt it, and do not report it as a failure.** Bringing it forward
|
||||
is a watched, by-hand operation — push, seed, push again, verify the constraint — and it is being done
|
||||
separately by the owner and this session, immediately after this rollout.
|
||||
|
||||
**One more thing about edge, for whoever reaches it later:** from `officer-dev` the name
|
||||
`edge-pertento-bridge` does not resolve. It answers on **`100.64.0.15`**. `docs/servers.md` has the
|
||||
address; use it.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
**A commander can be created and asks its first question in your language.** That is the release, and
|
||||
it is the thing most likely to reveal a stale bundle — hard refresh before deciding anything failed.
|
||||
|
||||
**A Member's phone can register for notifications.** `notify` is core now, so an account that was
|
||||
403'ing should simply work.
|
||||
|
||||
**Nothing else should have changed for an ordinary signed-in owner.** Every narrowing column is null on
|
||||
every existing key.
|
||||
|
||||
No direct SQL is needed on any host in this release, and none is authorised. The query in "The schema
|
||||
change" is a read.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`officer-dev`** — skip it. Restarting `officer` there ends the rollout. It is already on this code.
|
||||
- **`edge-pertento-bridge`** — skipped, see above. Not a failure.
|
||||
- **`alpha`**, **`fofos`**, **`production-pertento-bridge`**, **`deadpool-rezio-bridge`** — the targets.
|
||||
- **`macbookpro`** — a target if it is awake.
|
||||
@@ -22,20 +22,79 @@ Two details from that report do most of the diagnostic work:
|
||||
|
||||
Do not re-investigate these without new evidence.
|
||||
|
||||
| Candidate | Why it is out |
|
||||
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. |
|
||||
| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. |
|
||||
| Two Monaco editors sharing a model | The app's only other mount, `FileViewer/renderers/EditorRenderer.tsx`, passes no `path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`. |
|
||||
| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. |
|
||||
| Candidate | Why it is out |
|
||||
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. |
|
||||
| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. |
|
||||
| ~~Two Monaco editors sharing a model~~ **— WRONG, see below** | This asked which OTHER component mounts Monaco and answered `EditorRenderer` (still true: no `path`, so `inmemory://model/N`, no collision). It never asked whether the **code editor collides with ITSELF**. It does — the panel is addable to a dashboard twice. |
|
||||
| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. |
|
||||
|
||||
That leaves **Monaco's own `onChange`** — the model was edited the way a paste or a programmatic
|
||||
`executeEdits` edits it. Which is why this needs to be caught in the act rather than reasoned about.
|
||||
|
||||
One thing to check first, because it is recent and it is ours: `2b70a570` gave `<Editor>` a real `path`,
|
||||
so models are keyed by file URI globally rather than per mount. Two panels open on the same file now share
|
||||
one model. That should be an improvement, and it is a change in exactly the layer that turns out to be
|
||||
involved.
|
||||
## Two code-editor panels share one model and two buffers (2026-08-25)
|
||||
|
||||
The suspicion recorded here — `2b70a570` giving `<Editor>` a real `path`, so models are keyed by file URI
|
||||
**globally** rather than per mount — is correct, and it is worse than "should be an improvement". Verified
|
||||
by reading `@monaco-editor/react@4.7.0`'s built source, not inferred:
|
||||
|
||||
- **Model lookup is `getModel(Uri.parse(path)) || createModel(value, …)`.** An existing model for that path
|
||||
wins and the second mount's `value` is **ignored at creation**. So the second panel to open a file adopts
|
||||
the first panel's buffer.
|
||||
- **The value-sync effect then overwrites it**, because `value !== editor.getValue()`:
|
||||
`executeEdits('', [{ range: getFullModelRange(), text: value }])`.
|
||||
- **The guard that suppresses `onChange` during that write is per-INSTANCE.** The package sets an internal
|
||||
flag around its own `executeEdits` and checks it inside `onDidChangeModelContent`. Panel B's flag is not
|
||||
panel A's. So B's programmatic write **fires A's `onChange`** with B's content.
|
||||
- **Unmount disposes the shared model.** `keepCurrentModel` defaults to `false` and we never set it, so the
|
||||
cleanup calls `editor.getModel()?.dispose()`. And `CodeEditor` mounts `<Editor key={activeFile.path}>`,
|
||||
so this runs on every **tab switch**, not only on panel close — switching tabs in one panel disposes the
|
||||
model another panel is still displaying.
|
||||
|
||||
The other half is ours: `useEditorState` holds `files` in a plain `useState`, so it is **per panel
|
||||
instance**. Its own comment says so — "this app also mounts as a dashboard panel, and a dashboard can hold
|
||||
two of them". One shared model, two independent `content` values, and an `onChange` that crosses between
|
||||
them. `CodeEditor`'s handler does `setContent(activeFile.path, value)`, so panel A's buffer silently becomes
|
||||
panel B's content and `isDirty` flips with no keystroke — dirty tab, content the user never typed, disk
|
||||
untouched, fixed by reopening. That is the reported symptom's exact shape.
|
||||
|
||||
**What this does NOT yet explain is how JOB OUTPUT becomes one of those `value`s.** Nothing in the app
|
||||
writes job output into `useEditorState` — `openFile`/`setContent`/`reloadFile` are called only from
|
||||
`CodeEditor.tsx`, and the task log renders through shiki. So this is a real defect that produces the right
|
||||
shape, and it is not proven to be the reported instance. **The trap is still the thing that names the
|
||||
text's source.** Do not delete it on the strength of this section.
|
||||
|
||||
## Fixed 2026-08-25: the buffer is shared, because the model already was
|
||||
|
||||
`useEditorState` now keys buffers by path in one store shared across panels, and keeps only the tab set and
|
||||
the active path per panel. The rule that matters: **`openFile` discards the content it is handed when the
|
||||
file is already open somewhere else.** That mirrors what Monaco does with the model, and it is what removes
|
||||
the divergence — with both panels reading one buffer there is never a `value` for the sync effect to
|
||||
reconcile, so the `executeEdits` never runs and the cross-panel `onChange` never fires.
|
||||
|
||||
A buffer outlives the tab that opened it. `holders` is the set of panels with the file open, and the entry
|
||||
is dropped when the last one closes, so closing a tab in one panel cannot take unsaved work out from under
|
||||
another. Closing the last one still discards unsaved edits, exactly as before.
|
||||
|
||||
`<Editor keepCurrentModel>` is the other half, and it was needed under any fix: the default disposes the
|
||||
shared model on unmount, and `key={activeFile.path}` makes that every **tab switch**, not just a panel
|
||||
close. The cost is one retained model per file opened in a session.
|
||||
|
||||
Two things worth knowing:
|
||||
|
||||
- **Writes go through `queryClient.setQueryData`, not `useGlobal`'s setter.** `useGlobal` applies a
|
||||
functional update to the `data` captured in render rather than to what is in the cache now. With one
|
||||
writer that is invisible; this slot has one writer per panel, which is the whole scenario.
|
||||
- **The trap can now fire legitimately.** Two panels on one file share a model, so a large paste in one
|
||||
fires the other's `onChange` while it is unfocused — which is what `reportSuspiciousEdit` looks for. A
|
||||
report whose `stack` runs through the editor's own `onChange` with another panel open is that, not the
|
||||
bug. This is the reason to keep reading the `stack` rather than the count.
|
||||
|
||||
Pinned by `useEditorState.test.ts` (9 tests): adoption, cross-panel edit, close-with-holders, drop on last
|
||||
close, release on unmount, and that the tab set stays per panel.
|
||||
|
||||
**Still not closed.** This removes the mechanism, not the mystery: where the log text came from is still
|
||||
unknown, and nothing found so far writes job output into an editor buffer. Keep the trap.
|
||||
|
||||
## When it is reported
|
||||
|
||||
|
||||
+57
-1
@@ -1,6 +1,10 @@
|
||||
# Localization
|
||||
|
||||
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
|
||||
**Status: BUILT and shipping six locales** — en-GB, pt-PT, es-ES, de-DE, fr-FR, sv-SE. The mechanism
|
||||
is done; what remains is coverage, which is the count-taking below.
|
||||
|
||||
**To add a seventh language, read "Adding a locale" at the bottom and nothing else.** It is seven
|
||||
files and the order does not matter, but missing one is silent — see the failure it is written from.
|
||||
|
||||
This is an execution plan. It is written to be handed to an agent who has not had the conversation
|
||||
that produced it, so it states the decisions and the reasons for them, not just the steps.
|
||||
@@ -412,3 +416,55 @@ else will ever notice.
|
||||
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
|
||||
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
|
||||
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
|
||||
|
||||
---
|
||||
|
||||
## Adding a locale
|
||||
|
||||
Seven files. Six are mechanical; the seventh is the one that gets missed.
|
||||
|
||||
**Three catalogues, one per workspace.** Copy `en-GB.json` beside itself and translate the values,
|
||||
keeping every key:
|
||||
|
||||
```
|
||||
src/apps/officer-web/locales/<code>.json app.* the shell, screens, settings
|
||||
src/workspaces/officerdev/src/locales/<code>.json panel.* the windowed apps
|
||||
src/workspaces/components/locales/<code>.json ui.* shared components
|
||||
```
|
||||
|
||||
**Three registrations, one per catalogue.** Each is an `import` plus a line in a map:
|
||||
|
||||
```
|
||||
src/apps/officer-web/lib/catalogues.ts
|
||||
src/workspaces/officerdev/src/locales/index.ts
|
||||
src/workspaces/components/locales/index.ts
|
||||
```
|
||||
|
||||
**And the picker**, which is the one that gets missed the other way round:
|
||||
|
||||
```
|
||||
src/apps/officer-web/lib/locales.ts SHIPPED_LOCALES
|
||||
```
|
||||
|
||||
### Why this is spelled out rather than left to reading the code
|
||||
|
||||
es-ES, de-DE and fr-FR once shipped translated, in perfect key parity, listed in the picker — and
|
||||
completely dead. All three registration files still imported only en-GB and pt-PT, so the merge
|
||||
produced three empty catalogues and the UI rendered raw keys in three of five languages.
|
||||
|
||||
Nothing caught it. The parity tests compare the JSON files against each other and every file was
|
||||
correct; a locale that is never imported is invisible to them by construction. The picker was right.
|
||||
Four edits in four files with no single place that knew all four had happened.
|
||||
|
||||
### What checks you now
|
||||
|
||||
Run `bun test` and it is settled. Two things stand where nothing stood:
|
||||
|
||||
- `src/apps/officer-web/lib/catalogues.test.ts` asserts every locale in `SHIPPED_LOCALES` resolves to
|
||||
a populated catalogue in all three namespaces, and matches en-GB's size. A locale in the picker but
|
||||
not imported fails here. So does one imported but empty.
|
||||
- The three `parity.test.ts` files read their own directory rather than listing locales by hand, so a
|
||||
new `<code>.json` is checked the moment it exists — for missing keys AND extra ones, because
|
||||
`findMissingKeys` alone reports half of a rename.
|
||||
|
||||
Then `bunx tsgo` and `bunx prettier --write` on what you touched, as everywhere else.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# A plugin's capabilities are LOADED, never copied
|
||||
|
||||
**Status: designed, not built.** What exists today is the copying this replaces.
|
||||
**Status: built.** Skills, affordances and notification catalogues landed in `2026.08.23.06`; tasks — the
|
||||
read-write one, and the last piece — landed on 2026-08-24. The four publishers are gone.
|
||||
|
||||
## The problem, as it is on disk right now
|
||||
|
||||
@@ -32,8 +33,8 @@ plugins/<name>/ the plugin's own repository, cloned on instal
|
||||
```
|
||||
|
||||
Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart —
|
||||
which is the whole reason this beats scoping the paths. A namespace makes provenance *readable*; separate
|
||||
directories make it *structural*.
|
||||
which is the whole reason this beats scoping the paths. A namespace makes provenance _readable_; separate
|
||||
directories make it _structural_.
|
||||
|
||||
Four consequences, and each of them is a bug that stops existing:
|
||||
|
||||
@@ -64,6 +65,21 @@ silently writes into a directory the next `plugins:update` overwrites.
|
||||
A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to
|
||||
search every source rather than assuming one root.
|
||||
|
||||
**How it went in, since it turned out to be one function rather than several.** Every path that runs a
|
||||
task — the file browser's context menu, both runner sockets, the job queue, the pipeline executor —
|
||||
resolves it through `getTaskByDirName` and then reads `implementation` off the record. So the sources go
|
||||
in there and in `listTasks`, and a plugin's task becomes runnable everywhere at once with its identity
|
||||
unchanged: still a directory name, still what `pipeline_jobs` stores.
|
||||
|
||||
The record carries `plugin` — the app name, or null for the owner's own. That one field does the rest:
|
||||
`updateTask` and `deleteTask` throw on it, `DELETE /api/tasks/:name` answers 409 naming the plugin, and
|
||||
the capability screen replaces the edit and delete controls with a line saying whose it is. Without the
|
||||
delete guard the old code was quietly a no-op — `rm -rf` on a path in the owner's store that does not
|
||||
exist, then `{ ok: true }`, and the task still in the menu.
|
||||
|
||||
`getTaskByDirName` also refuses a name containing a separator. It always joined a caller-supplied string
|
||||
onto a path; searching several roots is the moment that stops being one directory nobody could escape.
|
||||
|
||||
**The four publishers are deleted**, along with their install and uninstall wiring:
|
||||
`affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The
|
||||
last was written on 2026-08-23 and is superseded before it ever shipped.
|
||||
@@ -87,6 +103,7 @@ what happens today and needs no change. Scoping them is a separate question and
|
||||
force it either way.
|
||||
|
||||
**Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a
|
||||
task with the same directory name, and the loader has to break that tie — first-come, as the items store
|
||||
has always done, or by qualifying with the plugin name at read time. Worth settling when a second plugin
|
||||
ships a task; not before.
|
||||
task with the same directory name. The tie-break shipped as first-come with the owner's store searched
|
||||
first, which settles the case that matters — a plugin cannot take over a name its owner is already
|
||||
using — and leaves two plugins colliding with each other resolved by discovery order, which is arbitrary.
|
||||
Worth revisiting when a second plugin ships a task; not before.
|
||||
|
||||
+6
-8
@@ -30,6 +30,7 @@ Seven, all running.
|
||||
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
||||
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
|
||||
|
||||
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
|
||||
@@ -40,15 +41,12 @@ reachable from inside only.
|
||||
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
|
||||
and it must not be reported as one.
|
||||
|
||||
| Host | Since | Why |
|
||||
| ---------------------- | ---------- | ---------------------------------------------------------------- |
|
||||
| `edge-pertento-bridge` | 2026-08-23 | Down, and staying down for some days. Owner's call, not a fault. |
|
||||
None today. `edge-pertento-bridge` was here from 2026-08-23 and came back up on 2026-08-24; it is a
|
||||
deploy target again and its row is in the table above.
|
||||
|
||||
Its row, kept so restoring it is one move:
|
||||
|
||||
```
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
```
|
||||
It was down long enough to matter, so expect it to be behind: when it returned it was 215 commits back at
|
||||
`2026.08.22.02`, with no `roles` table and no commanders. A host coming off this list needs its schema
|
||||
brought forward before it is treated as ordinary.
|
||||
|
||||
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
|
||||
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.24.02",
|
||||
"version": "2026.08.25.01",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
Give a credential to every commander that predates them.
|
||||
|
||||
A commander created before `credential.ts` existed has no `officer.env` and no key row, so it cannot
|
||||
reach the platform at all — which is the state that made the first one curl the whisper container
|
||||
directly. Deleting and recreating it would fix that and throw away the conversation, which is the one
|
||||
thing a commander is: a long-running agreement about a job.
|
||||
|
||||
So this is additive and re-runnable:
|
||||
|
||||
- a commander that already has `officer.env` is left alone, whole;
|
||||
- `CLAUDE.md` is APPENDED to, never rewritten — it is a living document its commander has been
|
||||
extending, and the whole point of not recreating it is that it survives;
|
||||
- the key is named `commander:<dirName>`, so it revokes with the commander like any other.
|
||||
|
||||
Run it after a deploy that introduces credentials, on each host:
|
||||
|
||||
bun run --env-file=.env scripts/backfill-commander-credentials.ts
|
||||
*/
|
||||
import { readFileSync, existsSync, appendFileSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getUsers, listCommandHqSessions } from 'officerdb';
|
||||
import { writeCommanderCredential, commanderEnvPath } from '../src/servers/api/command-hq/credential';
|
||||
import { credentialSection, notifySection } from '../src/servers/api/command-hq/directory';
|
||||
import { getCommanderDir } from '../src/servers/data-path';
|
||||
import { lookupOsUser } from '../src/servers/os-user';
|
||||
|
||||
/** Best effort, and only for a member: the owner already owns everything under DATA_PATH. */
|
||||
async function giveToAccount(osUser: string | null, path: string): Promise<void> {
|
||||
if (!osUser) return;
|
||||
const ids = await lookupOsUser(osUser);
|
||||
if (!ids) return;
|
||||
Bun.spawnSync(['sudo', '-n', 'chown', `${ids.uid}:${ids.gid}`, path]);
|
||||
}
|
||||
|
||||
// Per user, because `listCommandHqSessions` is scoped by one — which is the right shape everywhere else
|
||||
// and only awkward here. There are few accounts and few commanders; this runs once per deploy.
|
||||
const rows: Array<{
|
||||
dirName: string | null;
|
||||
name: string | null;
|
||||
userId: number;
|
||||
email: string;
|
||||
osUser: string | null;
|
||||
}> = [];
|
||||
for (const user of await getUsers()) {
|
||||
for (const row of await listCommandHqSessions(user.id)) {
|
||||
rows.push({ dirName: row.dirName, name: row.name, userId: user.id, email: user.email, osUser: user.osUser });
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) console.log('no commanders on this host');
|
||||
|
||||
for (const row of rows) {
|
||||
const label = `${row.dirName} (${row.name})`;
|
||||
|
||||
if (!row.dirName) {
|
||||
console.log(`skip ${label} — no directory name`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dir = getCommanderDir(row.email, row.dirName);
|
||||
if (!existsSync(dir)) {
|
||||
console.log(`skip ${label} — no directory on disk`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const claudeMd = join(dir, 'CLAUDE.md');
|
||||
|
||||
/**
|
||||
* Append a section only when it is not already described.
|
||||
*
|
||||
* The marker is a string the section itself contains, so a commander whose CLAUDE.md was hand-edited to
|
||||
* describe the same thing does not end up with it twice.
|
||||
*/
|
||||
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
|
||||
const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : '';
|
||||
if (existing.includes(marker)) return false;
|
||||
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
|
||||
await giveToAccount(row.osUser, claudeMd);
|
||||
return true;
|
||||
};
|
||||
|
||||
const envPath = commanderEnvPath(row.email, row.dirName);
|
||||
|
||||
if (!existsSync(envPath)) {
|
||||
const { path } = await writeCommanderCredential({ userId: row.userId, email: row.email, dirName: row.dirName });
|
||||
chmodSync(path, 0o600);
|
||||
await giveToAccount(row.osUser, path);
|
||||
await appendIfMissing('officer.env', credentialSection(path));
|
||||
console.log(`MINT ${label} — ${path}`);
|
||||
} else {
|
||||
console.log(`ok ${label} — already has a credential`);
|
||||
}
|
||||
|
||||
/*
|
||||
Separately, because it arrived later and the commanders that need it most already have a credential.
|
||||
Without it a commander does not know Officer HAS a notification route, so it reaches for whatever its
|
||||
harness offers — a different channel, with its own rules about when it may interrupt.
|
||||
*/
|
||||
if (await appendIfMissing('/api/notify/_officer/notify', notifySection())) {
|
||||
console.log(`NOTIFY ${label} — told how to reach the owner`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
+46
-9
@@ -6,13 +6,25 @@
|
||||
* bun run notify:test --user 2 # someone else
|
||||
* bun run notify:test --code officer/core:job --id 412
|
||||
*
|
||||
* Talks to the sidecar over loopback, which is how every other producer reaches it — so this exercises
|
||||
* the real path rather than a special one. It reports what the channels actually did, because the thing
|
||||
* that wastes a day here is that a send with nothing configured returns success and delivers nothing.
|
||||
* Goes through the PLATFORM, with a key it mints and revokes for the one send — the same door an app or
|
||||
* a commander uses, so this exercises the real path rather than a special one. It reports what the
|
||||
* channels actually did, because the thing that wastes a day here is that a send with nothing configured
|
||||
* returns success and delivers nothing.
|
||||
*
|
||||
* It used to POST to the sidecar directly on loopback. That stopped working on 2026-08-25, when the
|
||||
* sidecar began requiring proof that the platform is the caller — anything on the machine could otherwise
|
||||
* notify any user, which on a box running agents is anything with a shell. The secret lives only in the
|
||||
* server's memory, so a CLI cannot present it and should not be able to.
|
||||
*
|
||||
* Minting a key is legitimate HERE and nowhere else: this runs as the owner, on the owner's machine, with
|
||||
* the database open in front of it. Anything that cannot already do that has no business sending either.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../src/servers/data-path';
|
||||
import { mintApiKey } from '../src/servers/auth-token';
|
||||
import { createApiKey, revokeApiKeysByName } from 'officerdb';
|
||||
import { PORT, OFFICER_API_URL } from '../src/servers/officer-url.mjs';
|
||||
|
||||
const arg = (name: string): string | undefined => {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
@@ -69,12 +81,37 @@ const body = {
|
||||
|
||||
console.log(`[notify:test] sending ${JSON.stringify(body)}`);
|
||||
|
||||
const res = await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
// A key for this one send, revoked immediately afterwards whatever happens. Named so a leftover from a
|
||||
// killed run is obvious in the key list rather than looking like something a person made.
|
||||
const KEY_NAME = 'notify:test (temporary)';
|
||||
await revokeApiKeysByName(userId, KEY_NAME).catch(() => {});
|
||||
const { secret, keyHash, prefix } = mintApiKey();
|
||||
await createApiKey({ userId, name: KEY_NAME, keyHash, prefix });
|
||||
|
||||
const publicHost = (() => {
|
||||
try {
|
||||
return process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL.replace(/^"|"$/g, '')).host : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${OFFICER_API_URL}/api/notify/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
Authorization: `Bearer ${secret}`,
|
||||
// Origin validation matches Host against PUBLIC_URL, so a bare loopback request is refused.
|
||||
Host: publicHost ?? `127.0.0.1:${PORT}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
} finally {
|
||||
await revokeApiKeysByName(userId, KEY_NAME).catch(() => {});
|
||||
}
|
||||
|
||||
const out = (await res.json()) as { ok?: boolean; error?: string; results?: Array<Record<string, unknown>> };
|
||||
|
||||
|
||||
@@ -81,6 +81,12 @@ LAST_SKIPPED=()
|
||||
# distribution's default alongside it. It stays in core because anything that
|
||||
# compiles a native module needs it, but it is the one to move out first if that
|
||||
# ever bites.
|
||||
#
|
||||
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
|
||||
# moment a file lands instead of polling for it. Without it a watcher can only
|
||||
# ask "is there anything new?" on a timer, which is both slower and more work —
|
||||
# and the commander has no way to know the tool is missing until it tries. macOS
|
||||
# has no equivalent in the package sets here; a commander there polls.
|
||||
pkgs_core() {
|
||||
case "$PM" in
|
||||
apt)
|
||||
@@ -103,17 +109,17 @@ pkgs_core() {
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
poppler-utils radicale fail2ban unattended-upgrades
|
||||
inotify-tools poppler-utils radicale fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
poppler radicale fail2ban
|
||||
inotify-tools poppler radicale fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
poppler-utils radicale fail2ban
|
||||
inotify-tools poppler-utils radicale fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
|
||||
@@ -466,32 +466,73 @@ disable_sleep() {
|
||||
# systemd-networkd actually manages the network — a server or cloud image, via
|
||||
# cloud-init and netplan — it does that in milliseconds and is load-bearing.
|
||||
#
|
||||
# Where NetworkManager owns the network instead, systemd-networkd runs nothing,
|
||||
# but the wait-online unit is still enabled and waits for a link that will never
|
||||
# be configured. It gives up after its full timeout, on every boot.
|
||||
# Where NetworkManager owns the network instead, systemd-networkd configures
|
||||
# nothing, but the wait-online unit is still enabled and waits for a link that
|
||||
# will never come up. It gives up after its full timeout, on every boot.
|
||||
#
|
||||
# So the fix is masking one unit, and only on the second stack. Do NOT
|
||||
# `systemctl disable --now systemd-networkd` to achieve the same thing: on a
|
||||
# networkd-managed box that brings it up with no network on the next boot, no
|
||||
# ssh, and nothing but the provider's rescue console.
|
||||
#
|
||||
# ── "is networkd running" is the wrong question ──
|
||||
#
|
||||
# It was the test here until 2026-08-24, and it is why this section did nothing
|
||||
# on the machine it was written for. Both stacks can be ACTIVE at once: on the
|
||||
# dev box NetworkManager owned enp1s0 and wlp2s0, systemd-networkd was up and
|
||||
# managing not one of eighty links, and wait-online still burned its full two
|
||||
# minutes on every boot — `Timeout occurred while waiting for network
|
||||
# connectivity`, at 18:31:05 to 18:33:05, in the journal.
|
||||
#
|
||||
# What matters is whether networkd CONFIGURED anything, which `networkctl` will
|
||||
# say. A link it owns reads `configured`; a link it is ignoring reads
|
||||
# `unmanaged`. If every link is unmanaged, the wait cannot ever be satisfied and
|
||||
# masking it is safe.
|
||||
|
||||
WAIT_ONLINE_UNIT=systemd-networkd-wait-online.service
|
||||
|
||||
# Reported to the operator, so it has to describe the machine rather than pick a
|
||||
# winner: both daemons running with networkd idle is the exact case this section
|
||||
# used to get wrong, and printing "NetworkManager" for it hid that.
|
||||
network_manager_name() {
|
||||
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
|
||||
local nm=no nd=no
|
||||
systemctl is-active --quiet NetworkManager.service 2>/dev/null && nm=yes
|
||||
systemctl is-active --quiet systemd-networkd.service 2>/dev/null && nd=yes
|
||||
|
||||
if [[ $nm == yes && $nd == yes ]]; then
|
||||
if networkd_configures_a_link; then
|
||||
echo "both — networkd configures at least one link"
|
||||
else
|
||||
echo "NetworkManager (networkd is up but configures nothing)"
|
||||
fi
|
||||
elif [[ $nm == yes ]]; then
|
||||
echo "NetworkManager"
|
||||
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
|
||||
elif [[ $nd == yes ]]; then
|
||||
echo "systemd-networkd"
|
||||
else
|
||||
echo "neither — unclear"
|
||||
fi
|
||||
}
|
||||
|
||||
# Is the wait actually pointless here? NetworkManager in charge and networkd not.
|
||||
# Anything else, including "cannot tell", is left alone.
|
||||
# Does networkd actually configure a link on this machine?
|
||||
#
|
||||
# `networkctl list --no-legend` puts the SETUP state last on each line. Anything
|
||||
# other than unmanaged/pending/n-a means networkd owns that link and the wait is
|
||||
# real work. Missing networkctl counts as "yes, it does" — an unanswerable
|
||||
# question must not be read as permission to mask.
|
||||
networkd_configures_a_link() {
|
||||
command -v networkctl &>/dev/null || return 0
|
||||
networkctl list --no-legend 2>/dev/null | awk '{ print $NF }' |
|
||||
grep -qvE '^(unmanaged|pending|n/a)$'
|
||||
}
|
||||
|
||||
# Is the wait actually pointless here? NetworkManager in charge, and networkd
|
||||
# either absent or configuring nothing. Anything else, including "cannot tell",
|
||||
# is left alone.
|
||||
wait_online_is_spurious() {
|
||||
systemctl is-active --quiet NetworkManager.service 2>/dev/null &&
|
||||
! systemctl is-active --quiet systemd-networkd.service 2>/dev/null
|
||||
systemctl is-active --quiet NetworkManager.service 2>/dev/null || return 1
|
||||
systemctl is-active --quiet systemd-networkd.service 2>/dev/null || return 0
|
||||
! networkd_configures_a_link
|
||||
}
|
||||
|
||||
# What that unit actually cost on this boot, straight from systemd's own
|
||||
|
||||
@@ -1170,18 +1170,22 @@ elif ! skip; then
|
||||
echo ""
|
||||
info "Boot hang — a unit that can add two minutes to every boot"
|
||||
echo " On a machine where NetworkManager owns the network, systemd-networkd"
|
||||
echo " runs nothing — but systemd-networkd-wait-online is still enabled, and"
|
||||
echo " waits for a link that will never come up. It gives up after its full"
|
||||
echo " timeout, every single boot."
|
||||
echo " configures nothing — but systemd-networkd-wait-online is still"
|
||||
echo " enabled, and waits for a link that will never come up. It gives up"
|
||||
echo " after its full timeout, every single boot."
|
||||
echo ""
|
||||
echo " Note both daemons can be running at once. What decides it is whether"
|
||||
echo " networkd CONFIGURED a link, not whether it is up."
|
||||
echo ""
|
||||
echo " network managed by: $(network_manager_name)"
|
||||
echo " that unit took: ${WAIT_ONLINE_TIME:-it did not run} on this boot"
|
||||
|
||||
if ! wait_online_is_spurious; then
|
||||
echo ""
|
||||
echo " systemd-networkd is live here, so that unit is doing real work."
|
||||
echo " systemd-networkd configures a link here, so that unit is doing"
|
||||
echo " real work."
|
||||
echo " Masking it would be how this machine comes up with no network."
|
||||
SUMMARY+=("Boot hang: nothing to fix — networkd is in charge")
|
||||
SUMMARY+=("Boot hang: nothing to fix — networkd configures a link")
|
||||
else
|
||||
echo ""
|
||||
if is_role dev; then
|
||||
|
||||
+6
-136
@@ -1,140 +1,10 @@
|
||||
/**
|
||||
* Regenerate `TODO.md` — the index over `todo/`.
|
||||
* Regenerate `TODO.md` — the index over `todo/`. `bun run todo:index` after any change to the tree.
|
||||
*
|
||||
* The index is the one file everybody reads, so it is the one most able to lie. It is generated rather
|
||||
* than maintained: `bun run todo:index` after any change to `todo/`, and it cannot drift from the tree
|
||||
* it describes.
|
||||
* The logic lives in `src/servers/api/todos/todo-tree.ts`, shared with the `/api/todos` router so the
|
||||
* board and this CLI cannot disagree about the format; this file is only the command.
|
||||
*/
|
||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { writeIndex } from '../src/servers/api/todos/todo-tree';
|
||||
|
||||
const ROOT = new URL('..', import.meta.url).pathname;
|
||||
const OUT = join(ROOT, 'todo');
|
||||
|
||||
export type Item = {
|
||||
id: string;
|
||||
title: string;
|
||||
theme: string;
|
||||
status: string;
|
||||
created: string;
|
||||
/** `urgent` or empty — see todo/README.md for why there is no scale. */
|
||||
priority: string;
|
||||
due: string;
|
||||
order: number;
|
||||
file: string;
|
||||
};
|
||||
|
||||
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
|
||||
export function frontmatter(text: string): Record<string, string> {
|
||||
const end = text.indexOf('\n---', 4);
|
||||
if (!text.startsWith('---') || end === -1) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.slice(4, end).split('\n')) {
|
||||
const m = line.match(/^([a-z_]+):\s*(.*)$/);
|
||||
if (!m || !m[1]) continue;
|
||||
const raw = (m[2] ?? '').trim();
|
||||
out[m[1]] = raw.startsWith('"') ? (JSON.parse(raw) as string) : raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function readTree(): { themes: { theme: string; heading: string; items: Item[] }[] } {
|
||||
const themes = readdirSync(OUT)
|
||||
.filter((d) => statSync(join(OUT, d)).isDirectory())
|
||||
.sort()
|
||||
.map((theme) => {
|
||||
const files = readdirSync(join(OUT, theme)).filter((f) => f.endsWith('.md'));
|
||||
const meta = frontmatter(readFileSync(join(OUT, theme, '_index.md'), 'utf8'));
|
||||
const items = files
|
||||
.filter((f) => f !== '_index.md')
|
||||
.map((f) => {
|
||||
const fm = frontmatter(readFileSync(join(OUT, theme, f), 'utf8'));
|
||||
return {
|
||||
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
|
||||
title: fm.title ?? f,
|
||||
theme,
|
||||
status: fm.status ?? 'open',
|
||||
created: fm.created ?? '',
|
||||
priority: fm.priority ?? '',
|
||||
due: fm.due ?? '',
|
||||
order: Number(fm.order ?? 0),
|
||||
file: `todo/${theme}/${f}`,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return { theme, heading: meta.heading ?? theme, items };
|
||||
});
|
||||
return { themes };
|
||||
}
|
||||
|
||||
const HEADER = `# TODO — the index
|
||||
|
||||
Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map
|
||||
over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here.
|
||||
Format and the two-way-sync contract: \`todo/README.md\`.
|
||||
|
||||
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
|
||||
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
||||
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
|
||||
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
|
||||
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
|
||||
deleting it have been removed rather than left to mislead the next reader.
|
||||
|
||||
What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as
|
||||
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
||||
`;
|
||||
|
||||
function render(): string {
|
||||
const { themes } = readTree();
|
||||
const open = themes.reduce((n, t) => n + t.items.filter((i) => i.status !== 'done').length, 0);
|
||||
const done = themes.reduce((n, t) => n + t.items.filter((i) => i.status === 'done').length, 0);
|
||||
|
||||
const rows = themes
|
||||
.filter((t) => t.items.length)
|
||||
.map((t) => {
|
||||
const o = t.items.filter((i) => i.status !== 'done').length;
|
||||
const d = t.items.length - o;
|
||||
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${d} |`;
|
||||
});
|
||||
|
||||
const recent = themes
|
||||
.flatMap((t) => t.items)
|
||||
.filter((i) => i.status !== 'done')
|
||||
.sort((a, b) => b.created.localeCompare(a.created))
|
||||
.slice(0, 10)
|
||||
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
|
||||
|
||||
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode this
|
||||
// whole split risks. List them explicitly rather than letting them age out of view.
|
||||
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
|
||||
|
||||
// Urgent goes above everything. An item the owner called urgent, sorted in among seventy-five others
|
||||
// by theme, is an item nobody sees on the day it matters.
|
||||
const urgent = themes
|
||||
.flatMap((t) => t.items)
|
||||
.filter((i) => i.status !== 'done' && i.priority === 'urgent')
|
||||
.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
|
||||
.map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`);
|
||||
|
||||
return [
|
||||
HEADER,
|
||||
...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []),
|
||||
`## Themes\n`,
|
||||
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
|
||||
`| Theme | Open | Closed |`,
|
||||
`| --- | --- | --- |`,
|
||||
...rows,
|
||||
``,
|
||||
`## Records — no items, kept for the reasoning\n`,
|
||||
...notes,
|
||||
``,
|
||||
`## Newest ten\n`,
|
||||
...recent,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
writeFileSync(join(ROOT, 'TODO.md'), render());
|
||||
console.log('TODO.md regenerated');
|
||||
}
|
||||
writeIndex();
|
||||
console.log('TODO.md regenerated');
|
||||
|
||||
@@ -107,6 +107,7 @@ export function App() {
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export type CapabilityDetail = CapabilitySummary & {
|
||||
rawFrontmatter: string;
|
||||
filePath: string;
|
||||
chatSessionId: string | null;
|
||||
/** Only tasks carry this today: the plugin that ships it, or null/absent when it is the owner's own. */
|
||||
plugin?: string | null;
|
||||
};
|
||||
|
||||
type CapabilityListProps = {
|
||||
@@ -538,7 +540,17 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
||||
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
|
||||
</span>
|
||||
{detail && (
|
||||
{/*
|
||||
A plugin's capability is read where it lives, in a git checkout that `plugins:update`
|
||||
pulls. So there is nothing to offer here: an edit would be overwritten by the next update,
|
||||
and the delete refuses server-side (409). Say whose it is instead of showing two controls
|
||||
that cannot work.
|
||||
*/}
|
||||
{detail?.plugin ? (
|
||||
<span className="text-xs text-duck-dark/40">
|
||||
{t('capability.fromPlugin', { plugin: detail.plugin })}
|
||||
</span>
|
||||
) : detail ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setEditing(!editing)}
|
||||
@@ -553,7 +565,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { WorkspaceView, useCommanderFilesPanel } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
|
||||
|
||||
export const CommandHqScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
|
||||
const ephemeral = useCommanderFilesPanel();
|
||||
|
||||
/*
|
||||
Move a saved three-column layout onto the two-column one.
|
||||
@@ -26,6 +27,9 @@ export const CommandHqScreen = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
{/* The commander's own file browser is a layout of its own, rendered beside this one — it never
|
||||
enters `workspace.value`, so this screen stays locked and the allow-list does not have to know
|
||||
about it. Same trade `/files` makes for the file viewer. */}
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
@@ -33,6 +37,7 @@ export const CommandHqScreen = () => {
|
||||
allowed: ['command-hq-categories', 'command-hq-grid', 'command-hq-detail'],
|
||||
fallback: 'command-hq-detail',
|
||||
}}
|
||||
ephemeral={ephemeral}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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' }} />;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
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',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './TodosScreen';
|
||||
@@ -23,3 +23,4 @@ export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './SendOff';
|
||||
export * from './Todos';
|
||||
|
||||
@@ -682,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Keine Treffer",
|
||||
"emptyFile": "Leere Datei",
|
||||
"fromPlugin": "Gehört zum Plugin {{plugin}} — schreibgeschützt",
|
||||
"createFailed": "{{kind}} konnte nicht erstellt werden",
|
||||
"deleteFailed": "{{kind}} konnte nicht gelöscht werden",
|
||||
"searchPlaceholder": "{{kind}} durchsuchen…",
|
||||
|
||||
@@ -682,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "No matches",
|
||||
"emptyFile": "Empty file",
|
||||
"fromPlugin": "Ships with the {{plugin}} plugin — read-only",
|
||||
"createFailed": "Could not create the {{kind}}",
|
||||
"deleteFailed": "Could not delete the {{kind}}",
|
||||
"searchPlaceholder": "Search {{kind}}…",
|
||||
|
||||
@@ -682,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Sin coincidencias",
|
||||
"emptyFile": "Archivo vacío",
|
||||
"fromPlugin": "Viene del plugin {{plugin}} — solo lectura",
|
||||
"createFailed": "No se pudo crear el {{kind}}",
|
||||
"deleteFailed": "No se pudo eliminar el {{kind}}",
|
||||
"searchPlaceholder": "Buscar {{kind}}…",
|
||||
|
||||
@@ -682,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Aucun résultat",
|
||||
"emptyFile": "Fichier vide",
|
||||
"fromPlugin": "Fourni par le plugin {{plugin}} — lecture seule",
|
||||
"createFailed": "Impossible de créer le {{kind}}",
|
||||
"deleteFailed": "Impossible de supprimer le {{kind}}",
|
||||
"searchPlaceholder": "Rechercher {{kind}}…",
|
||||
|
||||
@@ -682,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Sem correspondências",
|
||||
"emptyFile": "Ficheiro vazio",
|
||||
"fromPlugin": "Vem do plugin {{plugin}} — só de leitura",
|
||||
"createFailed": "Não foi possível criar o {{kind}}",
|
||||
"deleteFailed": "Não foi possível eliminar o {{kind}}",
|
||||
"searchPlaceholder": "Pesquisar {{kind}}…",
|
||||
|
||||
@@ -682,6 +682,7 @@
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Inga träffar",
|
||||
"emptyFile": "Tom fil",
|
||||
"fromPlugin": "Följer med plugin-programmet {{plugin}} — skrivskyddad",
|
||||
"createFailed": "Det gick inte att skapa {{kind}}",
|
||||
"deleteFailed": "Det gick inte att ta bort {{kind}}",
|
||||
"searchPlaceholder": "Sök {{kind}}…",
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
createApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
revokeApiKeysByName,
|
||||
deleteApiKey,
|
||||
touchApiKey,
|
||||
type ApiKeyIdentity,
|
||||
|
||||
@@ -14,6 +14,10 @@ export type ApiKeyIdentity = {
|
||||
email: string;
|
||||
username: string | null;
|
||||
lastUsedAt: Date | null;
|
||||
/** Permission keys this credential may reach, or null for "anything not excluded". */
|
||||
permissions: string[] | null;
|
||||
/** Permission kinds it may never reach, resolved against the live registry. */
|
||||
excludedKinds: string[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -31,6 +35,8 @@ export async function findLiveApiKeyByHash(keyHash: string): Promise<ApiKeyIdent
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
permissions: apiKeys.permissions,
|
||||
excludedKinds: apiKeys.excludedKinds,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.innerJoin(users, eq(users.id, apiKeys.userId))
|
||||
@@ -51,6 +57,10 @@ type CreateApiKeyParams = {
|
||||
keyHash: string;
|
||||
prefix: string;
|
||||
expiresAt?: Date | null;
|
||||
/** Omit or pass null for "anything not excluded"; a list narrows the key to those permission keys. */
|
||||
permissions?: string[] | null;
|
||||
/** Permission kinds this key may never reach, whatever the registry grows to hold. */
|
||||
excludedKinds?: string[] | null;
|
||||
};
|
||||
|
||||
export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySelect> {
|
||||
@@ -62,6 +72,8 @@ export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySe
|
||||
keyHash: params.keyHash,
|
||||
prefix: params.prefix,
|
||||
expiresAt: params.expiresAt ?? null,
|
||||
permissions: params.permissions ?? null,
|
||||
excludedKinds: params.excludedKinds ?? null,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
@@ -75,6 +87,8 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
|
||||
userId: apiKeys.userId,
|
||||
name: apiKeys.name,
|
||||
prefix: apiKeys.prefix,
|
||||
permissions: apiKeys.permissions,
|
||||
excludedKinds: apiKeys.excludedKinds,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
@@ -85,6 +99,24 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
|
||||
.orderBy(sql`${apiKeys.createdAt} desc`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke every live key a user holds under one name.
|
||||
*
|
||||
* For credentials the PLATFORM mints on someone's behalf rather than the user minting for themselves — a
|
||||
* commander's key is named `commander:<dirName>`, so deleting that commander revokes exactly its own and
|
||||
* recreating one under a reused directory name cannot leave a live key nothing points at.
|
||||
*
|
||||
* Revokes rather than deletes, the same as the by-id path: the row is what the audit reads afterwards.
|
||||
*/
|
||||
export async function revokeApiKeysByName(userId: number, name: string): Promise<number> {
|
||||
const rows = await db
|
||||
.update(apiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(apiKeys.userId, userId), eq(apiKeys.name, name), isNull(apiKeys.revokedAt)))
|
||||
.returning({ id: apiKeys.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke one of the caller's own keys. Scoped by userId in the same statement as the id, so a caller
|
||||
* naming someone else's key id revokes nothing and is told the same "not found" as for an id that never
|
||||
|
||||
@@ -21,10 +21,27 @@ import { users } from '../auth/schema';
|
||||
//
|
||||
// ── Authority ──
|
||||
//
|
||||
// A key currently carries its owner's full authority — deliberately, as the first cut. That is not a
|
||||
// hole: it is exactly what the password that made it could already do, so a key is never an escalation.
|
||||
// A `scopes` column is the intended next step, and the resolver is written so adding one touches the
|
||||
// resolver and this table, nothing else.
|
||||
// A key carries its owner's authority, NARROWED by two columns when they are set. Both null means
|
||||
// unnarrowed — what every key minted by a human still is, and what the mobile apps hold: exactly what the
|
||||
// password that made it could already do, so a key is never an escalation.
|
||||
//
|
||||
// There are two of them because there are two shapes of "less than everything", and only one of them
|
||||
// survives contact with a platform that keeps growing:
|
||||
//
|
||||
// `permissions` — an ALLOW list. Exact, and a snapshot. Right for a credential with one job.
|
||||
// `excludedKinds` — kinds this key may never reach, whatever exists. Right for a credential whose job
|
||||
// is open-ended, which is every commander: it should reach the notifier, the mailer,
|
||||
// sub-agents and a plugin installed six months from now, and never the plugin
|
||||
// INSTALLER. An allow list cannot say that — it names what exists today, so tomorrow's
|
||||
// plugin is missing from it and fails as a bare 403 with nothing saying why.
|
||||
//
|
||||
// Both narrow and neither widens: the result is still intersected with what the account itself holds. See
|
||||
// `permissions/authorize.ts` → `decideApiRequest`.
|
||||
//
|
||||
// **The owner is the reason this exists.** A Super Admin bypasses every role check, so a credential
|
||||
// carrying the owner's identity is unbounded by construction, and a commander is an agent holding one
|
||||
// unattended for months. The narrowing is applied BEFORE the owner bypass, which makes it the only thing
|
||||
// in the platform that can bound them.
|
||||
export const apiKeys = pgTable(
|
||||
'api_keys',
|
||||
{
|
||||
@@ -38,6 +55,21 @@ export const apiKeys = pgTable(
|
||||
keyHash: text('key_hash').notNull(),
|
||||
/** First characters of the secret, in the clear, for display only. */
|
||||
prefix: text('prefix').notNull(),
|
||||
/**
|
||||
* Permission keys this credential may reach, or null for "anything not excluded below".
|
||||
*
|
||||
* An empty array is NOT the same as null and is not a mistake to guard against: it is a key that can
|
||||
* reach nothing, which is a coherent thing to mint and a safe thing to default to.
|
||||
*/
|
||||
permissions: text('permissions').array(),
|
||||
/**
|
||||
* Permission KINDS this credential may never reach — `admin`, `execution`, `confined`, `app`, `core`.
|
||||
*
|
||||
* Resolved at request time against the live registry rather than expanded into `permissions` at
|
||||
* creation, which is the whole difference: a commander minted today is bounded away from
|
||||
* administration forever, and still reaches a plugin installed next year without being re-issued.
|
||||
*/
|
||||
excludedKinds: text('excluded_kinds').array(),
|
||||
/**
|
||||
* Updated on use, but at most once a minute — see touchApiKey. Without the debounce every
|
||||
* authenticated request would carry a write, which is a lot of churn to answer "is this still in use".
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
getCommandHqSessionById,
|
||||
listCommandHqSessions,
|
||||
listCommandHqSessionIds,
|
||||
listCommandHqOwnedSessionIds,
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
deleteCommandHqSession,
|
||||
|
||||
@@ -186,6 +186,30 @@ export async function listCommandHqSessionIds(userId: number): Promise<Set<strin
|
||||
return new Set(rows.map((r) => r.sessionId!));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every transcript that belongs to a commander: the commanders themselves AND the agents they spawned.
|
||||
*
|
||||
* What `/chat`'s list hides. The set above is not enough — it holds the commanders, and a commander that
|
||||
* fires once an hour spawns a child per run, each with its own transcript titled after whatever its first
|
||||
* message happened to be. Thirteen of them under one directory was what made this obvious: twelve reading
|
||||
* `Read /home/.../procedures/classify-t…`, filling the conversation list with a loop's bookkeeping.
|
||||
*
|
||||
* Separate from `listCommandHqSessionIds` rather than replacing it, because a CHILD is not a commander —
|
||||
* `isCommander` on a session must stay false for one, or the composer would offer it the handoff watcher.
|
||||
*/
|
||||
export async function listCommandHqOwnedSessionIds(userId: number): Promise<Set<string>> {
|
||||
const [commanders, children] = await Promise.all([
|
||||
listCommandHqSessionIds(userId),
|
||||
db
|
||||
.select({ id: commandHqSpawned.spawnedSessionId })
|
||||
.from(commandHqSpawned)
|
||||
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
|
||||
.where(eq(commandHqSessions.userId, userId)),
|
||||
]);
|
||||
for (const row of children) commanders.add(row.id);
|
||||
return commanders;
|
||||
}
|
||||
|
||||
// drizzle's `eq` cannot express `IS NOT NULL` either.
|
||||
const isNotNullSessionId = () => sql`${commandHqSessions.sessionId} is not null`;
|
||||
|
||||
|
||||
+1
-1
@@ -391,7 +391,7 @@ async function upgradeWs(req: Request, server: any, provider: UpgradableProvider
|
||||
// whole point: the two doors cannot disagree about what a role holds, because there is only one
|
||||
// declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here
|
||||
// by being `execution` permissions, not by being absent from an array someone has to maintain.
|
||||
if (!(await isWsProviderAllowed(user.id, provider))) {
|
||||
if (!(await isWsProviderAllowed(user.id, provider, user.scope ?? null))) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,17 @@ export const permissionGateMiddleware: MiddlewareHandler = async function (ctx,
|
||||
//
|
||||
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
|
||||
// platform account, so there is nothing here to resolve.
|
||||
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path) && !isPluginPublicPath(path)) {
|
||||
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
|
||||
// A NARROWED credential is checked even for the owner — that narrowing is the only bound a Super Admin
|
||||
// has, so skipping the check for them would make it decorative on the one account it exists for. An
|
||||
// unnarrowed owner still short-circuits, which keeps the common request free of a database read.
|
||||
const credential = payload?.scope ?? null;
|
||||
// Stashed for the routes that pass authority on rather than just using it. Set unconditionally, so a
|
||||
// reader never has to distinguish "unnarrowed" from "the gate did not run".
|
||||
ctx.set('credentialScope', credential);
|
||||
const mustCheck = payload && (!isOwner || credential);
|
||||
|
||||
if (mustCheck && path.startsWith('/api') && !isExemptApiPath(path) && !isPluginPublicPath(path)) {
|
||||
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path, credential);
|
||||
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter } from '../../create-router';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
// Bug reports land on disk and nowhere else.
|
||||
//
|
||||
@@ -42,10 +43,10 @@ bugReportRouter.post('/', async (ctx) => {
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
|
||||
await writeAtomic(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
|
||||
|
||||
if (screenshot instanceof File) {
|
||||
await Bun.write(join(reportDir, 'screenshot.png'), Buffer.from(await screenshot.arrayBuffer()));
|
||||
await writeAtomic(join(reportDir, 'screenshot.png'), Buffer.from(await screenshot.arrayBuffer()));
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true, id: dirName });
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getChatSessionModel,
|
||||
getChatSessionModels,
|
||||
listCommandHqSessionIds,
|
||||
listCommandHqOwnedSessionIds,
|
||||
getChatNotifyPreference,
|
||||
getChatNotifyPreferences,
|
||||
setChatNotifyPreference,
|
||||
@@ -156,7 +157,7 @@ chatRouter.get('/sessions', async (ctx) => {
|
||||
that excludes commanders has nothing to say about which of its rows is one. `/chat/live` is where the
|
||||
flag is real, because that route keeps both kinds.
|
||||
*/
|
||||
const filed = await listCommandHqSessionIds(user.id).catch(() => new Set<string>());
|
||||
const filed = await listCommandHqOwnedSessionIds(user.id).catch(() => new Set<string>());
|
||||
const sessions = merged.filter((s) => !filed.has(s.id));
|
||||
|
||||
/*
|
||||
|
||||
@@ -423,7 +423,17 @@ export type ClaudeChatMessage =
|
||||
// Where the agent rewrote its own context. Kept for the same reason as `divider`: the conversation
|
||||
// above it is still yours to read, and the agent's memory of it is a summary. It is also the answer to
|
||||
// "why did it go quiet for two minutes there", which is only useful if it survives a reload.
|
||||
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true };
|
||||
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true }
|
||||
// A background task reporting in — see `taskNotificationFrom`. `startsTurn` is the load-bearing field:
|
||||
// this is the prompt the agent woke up to, so it is where one turn ends and the next begins.
|
||||
| {
|
||||
role: 'task';
|
||||
taskId: string;
|
||||
description: string;
|
||||
status?: 'completed' | 'failed' | 'stopped';
|
||||
summary?: string;
|
||||
startsTurn: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
|
||||
@@ -457,16 +467,59 @@ function blockText(content: unknown): string {
|
||||
* A background task reporting in. The harness delivers these to the agent by writing them as the *user's*
|
||||
* next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally,
|
||||
* the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never
|
||||
* typed. Live it never appears, because a finished task travels as its own `task:notification` event; it
|
||||
* surfaces only when a refresh rebuilds the conversation from Claude's file.
|
||||
* typed. Live it never appears as prose, because a finished task travels as its own `task:notification`
|
||||
* event and lands in the row the task already had; it surfaces only when a refresh rebuilds the
|
||||
* conversation from Claude's file.
|
||||
*
|
||||
* Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real
|
||||
* message — this file's own conversation does exactly that — stays yours.
|
||||
*/
|
||||
const isTaskNotification = (text: string) => text.trimStart().startsWith('<task-notification>');
|
||||
|
||||
const tagOf = (envelope: string, tag: string): string | undefined =>
|
||||
envelope.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1];
|
||||
|
||||
/** `&` last, or `&lt;` would decode twice and become `<`. */
|
||||
const decodeEntities = (text: string) =>
|
||||
text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
|
||||
const TASK_STATUSES = new Set(['completed', 'failed', 'stopped']);
|
||||
|
||||
/**
|
||||
* The envelope, rebuilt into the same row a live `task:notification` produces when the task's announcing
|
||||
* row is gone (`useChat`, `task:notification`). It used to be dropped outright, and dropping it lost more
|
||||
* than a bubble: **turn boundaries are derived from the user's messages**, so deleting one merged the
|
||||
* woken turn into the one before it. Everything the agent had said closing that earlier turn stopped being
|
||||
* the turn's trailing prose and became mid-turn work, which folds — and the merged turn then ended on a
|
||||
* tool call, which printed "Turn finished · no closing message" over a turn that had plainly finished
|
||||
* talking. A commander wakes on these, so its whole life replayed as a single turn with every answer
|
||||
* folded away.
|
||||
*
|
||||
* Malformed envelopes still return null and are still dropped: a row we cannot identify is worse than a
|
||||
* missing boundary.
|
||||
*/
|
||||
function taskNotificationFrom(text: string): ClaudeChatMessage | null {
|
||||
const taskId = tagOf(text, 'task-id')?.trim();
|
||||
if (!taskId) return null;
|
||||
const status = tagOf(text, 'status')?.trim();
|
||||
const summary = decodeEntities((tagOf(text, 'summary') ?? '').trim());
|
||||
return {
|
||||
role: 'task',
|
||||
taskId,
|
||||
description: summary,
|
||||
status: status && TASK_STATUSES.has(status) ? (status as 'completed' | 'failed' | 'stopped') : undefined,
|
||||
summary,
|
||||
startsTurn: true,
|
||||
};
|
||||
}
|
||||
|
||||
function userMessageFrom(text: string): ClaudeChatMessage | null {
|
||||
if (isTaskNotification(text)) return null;
|
||||
if (isTaskNotification(text)) return taskNotificationFrom(text);
|
||||
if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' };
|
||||
return { role: 'user', text };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { getNotifyServerUrl } from '../notify/router';
|
||||
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
|
||||
import { shouldNotify, type NotifyKind } from './notify-decision';
|
||||
|
||||
// The phone half of the bell: the same signal the browser socket listens to, delivered to APNs/FCM.
|
||||
@@ -32,16 +32,17 @@ export function registerChatPushNotifications(): void {
|
||||
if (!(await shouldNotify(userId, sessionKey, kind))) return;
|
||||
|
||||
const base = getNotifyServerUrl();
|
||||
const producer = notifyProducerHeaders();
|
||||
// The sidecar has not reported in — either it is starting, or push is not running on this install.
|
||||
// Not an error: the browser bell still works, and this is the channel that is allowed to be absent.
|
||||
if (!base) return;
|
||||
if (!base || !producer) return;
|
||||
|
||||
// `id` is the session key so the device can deep-link straight to the conversation. It is an
|
||||
// opaque uuid, which is the only reason it may travel: NO title, and no message content, because
|
||||
// this payload is read by Apple and Google. See docs/push-notifications.md.
|
||||
await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId), ...producer },
|
||||
body: JSON.stringify({ code: CODE_FOR[kind], userId, id: sessionKey }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { dueNudges, recordNudgeSent, cancelNudge, getUserById } from 'officerdb';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { dueNudges, recordNudgeSent, getUserById } from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { startClaudeTurn } from './websocket';
|
||||
|
||||
@@ -24,64 +23,41 @@ import { startClaudeTurn } from './websocket';
|
||||
|
||||
const TICK_MS = 60_000;
|
||||
|
||||
type Safety = { safe: true } | { safe: false; reason: string; disable: boolean };
|
||||
|
||||
/**
|
||||
* May we speak into this session right now?
|
||||
* There is no safety check any more, and that is the point.
|
||||
*
|
||||
* ── The failure this replaced ──
|
||||
* ── What was here ──
|
||||
*
|
||||
* This used to ask one question, "is it generating", and treat NO as permission. That is exactly
|
||||
* backwards, and on 2026-08-20 it cost a working agent its afternoon: the target session had been
|
||||
* garbage-collected, so it was not generating, so the nudge fired — and `startClaudeTurn` given a session
|
||||
* the sidecar no longer holds does not resume it, it starts a NEW one. A second Claude with bypassed
|
||||
* permissions appeared in a repository another agent was mid-build in. Nothing was lost only because that
|
||||
* agent noticed the collision and stopped rather than committing over the work.
|
||||
* Three refusals: the session is not live, the session is generating, or anything else is live on the
|
||||
* machine. Written on 2026-08-20 after a nudge fired at a garbage-collected session and a second Claude
|
||||
* with bypassed permissions appeared in a repository another agent was mid-build in.
|
||||
*
|
||||
* So there are three questions, and only one answer is permission:
|
||||
* ── Why all three are gone ──
|
||||
*
|
||||
* - **Not live at all** → refuse, and disarm. Nudging it would CREATE an agent, which is not what a
|
||||
* schedule attached to a conversation means and never was.
|
||||
* - **Live but generating** → refuse, keep the schedule. It is working; that is the whole rule.
|
||||
* - **Another session live in the same directory** → refuse, keep the schedule. Two agents with
|
||||
* bypassed permissions in one working tree is not something to arrange on purpose.
|
||||
* **Not live** was fixed elsewhere and the guard outlived it. `fire()` passes `resumeSessionId`, and
|
||||
* `claude-manager` resolves `getClaudeSession(sessionKey) ?? params.resumeSessionId` into the SDK's
|
||||
* `resume` — so a collected session resumes its own transcript rather than starting a new agent. That is
|
||||
* what a schedule attached to a conversation always meant.
|
||||
*
|
||||
* **Anything else live** refused on the presence of any agent anywhere, because the sidecar does not report
|
||||
* working directories and the check could not tell "another agent in this conversation" from "an unrelated
|
||||
* chat". On a machine running commanders that is always true, so no nudge could ever fire — silently: the
|
||||
* row stayed enabled, `sentCount` stayed 0, and whatever scheduled it reported success. It was a ban on the
|
||||
* feature wearing the clothes of a safety check.
|
||||
*
|
||||
* **Generating** was the last one, and it went for a better reason than the others: it was refusing to do
|
||||
* something that already works. Each session is one long-lived `query()` driven by a STREAMING INPUT QUEUE
|
||||
* (`claude-manager.ts` → "Persistent streaming sessions"), and a turn is one message pushed onto it. A
|
||||
* message sent to a busy session is not rejected and not lost — it is queued, and delivered when the
|
||||
* current turn ends. Waiting is fine; disappearing is not, and refusing here meant disappearing.
|
||||
*
|
||||
* ── What this costs, stated plainly ──
|
||||
*
|
||||
* Nudging conversation A while conversation B is live in the same working tree still puts two agents in one
|
||||
* directory. That is real and it is the owner's call, made 2026-08-25: a message that must arrive is worth
|
||||
* more than a guard that could only ever be right by accident, since it never knew the directories either.
|
||||
* Fixing it properly needs the sidecar to report each session's cwd.
|
||||
*/
|
||||
async function checkSafe(userId: number, sessionId: string): Promise<Safety> {
|
||||
try {
|
||||
const live = await sidecar.listLiveClaudeSessions(userId);
|
||||
// Matched on BOTH ids on purpose. `sessionKey` is officer's handle for the conversation;
|
||||
// `claudeSessionId` is the transcript's own filename, and only the agent holds the mapping. A schedule
|
||||
// stored against one would silently never match a live row keyed by the other.
|
||||
const mine = live.find((s) => s.sessionKey === sessionId || s.claudeSessionId === sessionId);
|
||||
|
||||
if (!mine) {
|
||||
return {
|
||||
safe: false,
|
||||
reason: 'the session is no longer live — nudging it would start a NEW agent, not continue this one',
|
||||
disable: true,
|
||||
};
|
||||
}
|
||||
if (mine.isGenerating) return { safe: false, reason: 'working', disable: false };
|
||||
|
||||
// Anything else alive belongs to another conversation. It may or may not share this one's directory —
|
||||
// the sidecar does not say — so this refuses on the presence of any other live session rather than on
|
||||
// a directory comparison it cannot actually make.
|
||||
const others = live.filter((s) => s !== mine).length;
|
||||
if (others > 0) {
|
||||
return {
|
||||
safe: false,
|
||||
reason: `${others} other live session(s) — refusing to add a second agent`,
|
||||
disable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { safe: true };
|
||||
} catch {
|
||||
// Unknown is not permission. A sidecar that cannot answer might be running a turn, and interrupting one
|
||||
// is the failure this exists to avoid — so silence is the safe answer.
|
||||
return { safe: false, reason: 'sidecar did not answer', disable: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function fire(nudge: { id: number; userId: number; sessionId: string; message: string }): Promise<void> {
|
||||
const user = await getUserById(nudge.userId);
|
||||
@@ -144,15 +120,7 @@ export function startNudger(): void {
|
||||
running = true;
|
||||
try {
|
||||
for (const nudge of await dueNudges()) {
|
||||
const check = await checkSafe(nudge.userId, nudge.sessionId);
|
||||
if (!check.safe) {
|
||||
if (check.disable) {
|
||||
console.warn(`[nudger] session ${nudge.sessionId} disarmed — ${check.reason}`);
|
||||
await cancelNudge(nudge.userId, nudge.sessionId);
|
||||
}
|
||||
continue; // otherwise reconsidered next tick
|
||||
}
|
||||
console.log(`[nudger] session ${nudge.sessionId} idle — nudging (${nudge.sentCount + 1}/${nudge.maxCount})`);
|
||||
console.log(`[nudger] session ${nudge.sessionId} — nudging (${nudge.sentCount + 1}/${nudge.maxCount})`);
|
||||
await fire(nudge);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadClaudeSession, projectSlug, type ChatIdentity } from './claude-sessions';
|
||||
|
||||
/**
|
||||
* The harness delivers a finished background task to the agent by writing it as the agent's next *user*
|
||||
* message. Replayed literally that is a raw XML envelope in a bubble on your side of the conversation, so
|
||||
* the parser used to drop it — and dropped the turn boundary with it, because `turn-rows` derives turns
|
||||
* from the message list alone. Reported 2026-08-25 against a commander, which wakes on these: its whole
|
||||
* history replayed as one turn with every answer folded away inside the seam.
|
||||
*
|
||||
* Driven through the real loader rather than the parser, because the drop and the boundary live one call
|
||||
* apart and testing them separately is how they came apart in the first place.
|
||||
*/
|
||||
|
||||
const CWD = '/home/someone/commanders/1';
|
||||
const SESSION = '00000000-0000-4000-8000-000000000001';
|
||||
|
||||
const home = mkdtempSync(join(tmpdir(), 'officer-transcript-'));
|
||||
afterAll(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
const who: ChatIdentity = { email: 'someone@example.com', home, osUser: null, isOwner: true };
|
||||
|
||||
const write = (lines: unknown[]): void => {
|
||||
const dir = join(home, '.claude', 'projects', projectSlug(CWD));
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${SESSION}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n'));
|
||||
};
|
||||
|
||||
const load = () => loadClaudeSession(who, CWD, SESSION)!.messages;
|
||||
|
||||
const NOTIFICATION = [
|
||||
'<task-notification>',
|
||||
'<task-id>b99iu40k1</task-id>',
|
||||
'<tool-use-id>toolu_01JDH</tool-use-id>',
|
||||
'<status>completed</status>',
|
||||
'<summary>Background command "wc -l < ledger.tsv" completed (exit code 0)</summary>',
|
||||
'</task-notification>',
|
||||
].join('\n');
|
||||
|
||||
const say = (text: string) => ({
|
||||
type: 'assistant',
|
||||
cwd: CWD,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text }] },
|
||||
});
|
||||
const prompt = (text: string) => ({ type: 'user', cwd: CWD, message: { role: 'user', content: text } });
|
||||
|
||||
describe('a background task reporting in, replayed from the transcript', () => {
|
||||
test('becomes a task row rather than being dropped or shown as something you typed', () => {
|
||||
write([prompt('watch the folder'), say('armed'), prompt(NOTIFICATION), say('it fired')]);
|
||||
|
||||
const messages = load();
|
||||
expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'task', 'assistant']);
|
||||
|
||||
const task = messages[2];
|
||||
expect(task).toMatchObject({ role: 'task', taskId: 'b99iu40k1', status: 'completed', startsTurn: true });
|
||||
});
|
||||
|
||||
test('carries the summary with its entities decoded, not the raw envelope', () => {
|
||||
write([prompt('watch the folder'), prompt(NOTIFICATION)]);
|
||||
|
||||
const task = load()[1] as Extract<ReturnType<typeof load>[number], { role: 'task' }>;
|
||||
expect(task.summary).toBe('Background command "wc -l < ledger.tsv" completed (exit code 0)');
|
||||
expect(task.summary).not.toContain('<');
|
||||
expect(task.summary).not.toContain('<task-notification>');
|
||||
});
|
||||
|
||||
test('an envelope with no task id is still dropped — an unidentifiable row is worse than none', () => {
|
||||
write([
|
||||
prompt('watch the folder'),
|
||||
prompt('<task-notification>\n<status>completed</status>\n</task-notification>'),
|
||||
]);
|
||||
|
||||
expect(load().map((m) => m.role)).toEqual(['user']);
|
||||
});
|
||||
|
||||
test('a message merely QUOTING an envelope stays yours', () => {
|
||||
// This file's own conversation does exactly that, which is why the match is anchored to the start.
|
||||
write([prompt(`what does ${NOTIFICATION} mean?`)]);
|
||||
|
||||
expect(load().map((m) => m.role)).toEqual(['user']);
|
||||
});
|
||||
|
||||
test('an unknown status is dropped rather than passed through to the row', () => {
|
||||
write([prompt('go'), prompt(NOTIFICATION.replace('completed', 'wedged'))]);
|
||||
|
||||
expect(load()[1]).toMatchObject({ role: 'task', status: undefined });
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
|
||||
import { claudeLoginState } from '@@/os-user-claude';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
import { logger } from './logger';
|
||||
import { isSuperAdmin } from '@@/super-admin';
|
||||
import { snapshotClaudeUsage } from './usage-store';
|
||||
@@ -47,6 +47,20 @@ import { snapshotClaudeUsage } from './usage-store';
|
||||
// Default model when no user preference is set
|
||||
export const DEFAULT_MODEL = 'claude-code';
|
||||
|
||||
/**
|
||||
* What a commander runs on, regardless of the account's default.
|
||||
*
|
||||
* A commander is long-running and mostly does the same settled thing over and over, so it is the one
|
||||
* surface where the account default — chosen for interactive work — is the wrong answer. Pinned here
|
||||
* rather than left to the picker so a commander created today does not depend on what the owner last
|
||||
* selected for a chat.
|
||||
*
|
||||
* Temporary in the sense that per-surface defaults belong in settings; permanent in the sense that
|
||||
* "commanders default to Sonnet" is the decision, and this is where it lives until there is a better
|
||||
* place for it.
|
||||
*/
|
||||
export const COMMANDER_DEFAULT_MODEL = 'claude-code/sonnet';
|
||||
|
||||
// Harness selection: the `claude-code` provider runs through the Claude sidecar; every other provider
|
||||
// (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server.
|
||||
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
|
||||
@@ -187,8 +201,25 @@ async function resolveChatCwd(
|
||||
`[command-hq] no commander row for ${msg.commanderId !== undefined ? `id ${msg.commanderId}` : `session ${msg.resumeSessionId ?? '(none given)'}`} — falling back to the account home`,
|
||||
);
|
||||
}
|
||||
const dir = row ? getCommanderDir(email, row.dirName) : home;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
if (!row) return home;
|
||||
|
||||
/*
|
||||
The directory must already exist. `POST /command-hq/commanders` creates it with its `CLAUDE.md`
|
||||
before any turn spawns, so a missing one means the commander was DELETED — and a late turn from the
|
||||
conversation that outlived it would otherwise recreate it here.
|
||||
|
||||
That happened: an empty `commanders/46` reappeared after a clean delete, from one in-flight turn
|
||||
calling this. An empty directory is worse than none, because it is a commander with no CLAUDE.md
|
||||
that knows nothing about itself, and it shows up in the file browser as something that still exists.
|
||||
|
||||
So: no mkdir. Fall back to the home, and say so — the same degradation the missing-row branch above
|
||||
reports, for the same reason.
|
||||
*/
|
||||
const dir = getCommanderDir(email, row.dirName);
|
||||
if (!existsSync(dir)) {
|
||||
console.warn(`[command-hq] ${row.dirName}: directory is gone — deleted mid-turn? Falling back to the home.`);
|
||||
return home;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createApiKey, revokeApiKeysByName } from 'officerdb';
|
||||
import { mintApiKey } from '@@/auth-token';
|
||||
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
import { getCommanderDir } from '../../data-path';
|
||||
|
||||
/*
|
||||
A commander's own credential.
|
||||
|
||||
── Why it has one at all ──
|
||||
|
||||
A commander is an agent working while nobody watches, and it has to reach the platform to do anything
|
||||
worth doing. Until now it could not: the only credential in the tree sits in the MCP tool server's
|
||||
environment, which the agent's own shell cannot see. So the first commander built here did the
|
||||
reasonable thing from where it was standing and curled the whisper container directly on localhost —
|
||||
correct answer, wrong door, and a door the platform does not control.
|
||||
|
||||
── Why it is narrowed ──
|
||||
|
||||
The owner bypasses every permission check, so a credential carrying the owner's identity is unbounded.
|
||||
That is fine for a browser session somebody is sitting in front of and wrong for a process that runs
|
||||
for months unattended. `api_keys.permissions` is the only thing that can bound a Super Admin — see
|
||||
`permissions/authorize.ts` → `decideApiRequest`, which applies it BEFORE the owner short-circuit.
|
||||
|
||||
── Why it excludes a KIND rather than listing what it allows ──
|
||||
|
||||
A commander's job is open-ended and grows. It should send a notification, send mail, spawn a sub-agent,
|
||||
hand a transcription to an idle one — and reach a plugin installed six months from now without being
|
||||
re-issued. What it should never do is administer the server it runs on.
|
||||
|
||||
An allow list cannot express that. A list is a snapshot of what existed on the day it was written, so a
|
||||
plugin added afterwards is simply missing from it, and the failure arrives as a bare 403 with nothing
|
||||
saying why. Every future capability would land on that.
|
||||
|
||||
So the default is a rule: every kind except `admin`. It is resolved against the live registry on each
|
||||
request, which means it keeps meaning the same thing as the platform grows — bounded away from
|
||||
installing plugins, managing users and roles and changing server settings, forever, and open to
|
||||
everything else the platform learns to do.
|
||||
|
||||
A DEFAULT, not a policy: it is stored on the key row and can be tightened per commander with an explicit
|
||||
allow list alongside it. And for a member it narrows itself, because the check is an intersection with
|
||||
what their role holds.
|
||||
*/
|
||||
|
||||
/** What a commander gets unless told otherwise: everything the platform can do, except administering it. */
|
||||
export const COMMANDER_EXCLUDED_KINDS = ['admin'];
|
||||
|
||||
/** The env file's path, and the name the key is stored under — both derived, so revocation cannot miss one. */
|
||||
export const commanderKeyName = (dirName: string) => `commander:${dirName}`;
|
||||
export const commanderEnvPath = (email: string, dirName: string) =>
|
||||
join(getCommanderDir(email, dirName), 'officer.env');
|
||||
|
||||
/**
|
||||
* Mint a commander's key and write it into its directory.
|
||||
*
|
||||
* The secret is written to a FILE rather than injected into the agent's environment because the agent is
|
||||
* not the only thing that needs it: a commander arms a detached loop, and that loop is spawned by the
|
||||
* agent itself and inherits nothing the platform chose. A file both can read is the only shape that
|
||||
* covers the interactive turn and the unattended loop with one credential.
|
||||
*
|
||||
* 0600, and `createCommanderDirectory` chowns the whole directory to the account afterwards — which is
|
||||
* why this must run BEFORE that chown, not after.
|
||||
*/
|
||||
export async function writeCommanderCredential(params: {
|
||||
userId: number;
|
||||
email: string;
|
||||
dirName: string;
|
||||
/** An explicit allow list, to tighten one commander further. Absent leaves it at the default rule. */
|
||||
permissions?: string[] | null;
|
||||
}): Promise<{ path: string; excludedKinds: string[] }> {
|
||||
const { secret, keyHash, prefix } = mintApiKey();
|
||||
|
||||
// Revoke first. A commander recreated under a directory name that has been used before would otherwise
|
||||
// leave a live key nothing points at any more.
|
||||
await revokeApiKeysByName(params.userId, commanderKeyName(params.dirName)).catch(() => {});
|
||||
await createApiKey({
|
||||
userId: params.userId,
|
||||
name: commanderKeyName(params.dirName),
|
||||
keyHash,
|
||||
prefix,
|
||||
permissions: params.permissions ?? null,
|
||||
excludedKinds: COMMANDER_EXCLUDED_KINDS,
|
||||
});
|
||||
|
||||
const path = commanderEnvPath(params.email, params.dirName);
|
||||
mkdirSync(join(path, '..'), { recursive: true });
|
||||
writeFileSync(
|
||||
path,
|
||||
[
|
||||
"# This commander's own credential. Source it before calling Officer:",
|
||||
'# set -a; . ./officer.env; set +a',
|
||||
'#',
|
||||
'# It reaches everything the platform does EXCEPT administering it — no installing plugins, no',
|
||||
'# managing users or roles, no server settings. That holds for anything added later too: the rule is',
|
||||
'# resolved per request, not frozen into a list.',
|
||||
'#',
|
||||
'# Revoked when this commander is deleted. Do not copy it anywhere else; anything that needs one',
|
||||
'# gets its own.',
|
||||
`OFFICER_API_URL=${OFFICER_API_URL}`,
|
||||
`OFFICER_API_HOST=${process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL).host : `127.0.0.1:${PORT}`}`,
|
||||
`OFFICER_API_KEY=${secret}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
return { path, excludedKinds: COMMANDER_EXCLUDED_KINDS };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { lookupOsUser } from '@@/os-user';
|
||||
import { join } from 'node:path';
|
||||
import { getCommanderDir, getCommanderStateDir } from '../../data-path';
|
||||
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||
import { writeCommanderCredential } from './credential';
|
||||
|
||||
/*
|
||||
A commander's own directory, created once, up front — `docs/commander-directory.md`.
|
||||
@@ -22,8 +23,141 @@ export type CommanderClaudeMdParams = {
|
||||
referencePath: string;
|
||||
loopPidPath: string;
|
||||
spawnedPath: string;
|
||||
/** Absolute path to this commander's `officer.env`, or null when minting it failed. */
|
||||
credentialPath: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The section telling a commander it has a credential, where it is, and what it must not do with it.
|
||||
*
|
||||
* Exported because it is written in two places: at creation, and by
|
||||
* `scripts/backfill-commander-credentials.ts` for a commander that predates credentials existing. That
|
||||
* backfill APPENDS it to a `CLAUDE.md` its commander has been extending for days, so this has to be a
|
||||
* self-contained section rather than a slice of one file's layout.
|
||||
*/
|
||||
export function credentialSection(credentialPath: string | null): string[] {
|
||||
if (!credentialPath) {
|
||||
return [
|
||||
'## Reaching the platform',
|
||||
'',
|
||||
'This commander has NO credential — minting one failed. It cannot reach the platform API, and it',
|
||||
'must not work around that by calling a sidecar directly. Tell the owner.',
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'## Reaching the platform',
|
||||
'',
|
||||
'This commander has its own credential, and it is the ONLY way it should reach anything:',
|
||||
'',
|
||||
'```',
|
||||
`set -a; . ${credentialPath}; set +a`,
|
||||
'curl -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\',
|
||||
' "$OFFICER_API_URL/api/..."',
|
||||
'```',
|
||||
'',
|
||||
'Use the platform, never the thing behind it. A sidecar — whisper, a media server, anything a plugin',
|
||||
'wraps — usually also listens on localhost with no authentication, so calling it directly works and',
|
||||
'looks correct. It is still wrong: it skips every permission, every setting the owner chose and every',
|
||||
'log, and it breaks silently the day that service moves.',
|
||||
'',
|
||||
'**The database is one of those things, and it is the one that gets missed.** `psql $POSTGRES_URL`',
|
||||
'may work from your shell. Never use it. Every table has routes in front of it that check who is',
|
||||
'asking and keep the caches correct — `PUT /api/chat/sessions/<id>/nudge` rather than an INSERT into',
|
||||
'`nudges`, and so on for everything else. A write that goes round the API is a write nothing else on',
|
||||
'the machine knows happened.',
|
||||
'',
|
||||
'Prefer a TASK over a raw call where one exists. `GET /api/tasks` lists them and',
|
||||
'`POST /api/tasks/<dirName>/run` runs one and returns what it did — one reviewed definition on disk',
|
||||
'instead of a re-implementation living in a loop nobody reads.',
|
||||
'',
|
||||
'**Ask the API what things are. Never read the platform to find out.** `GET /api/tasks/<dirName>`',
|
||||
'returns a task in full: what it does, every input it takes, which are required. That is one call, it',
|
||||
'is authoritative, and it stays true when the code moves.',
|
||||
'',
|
||||
'Searching the disk for it is the trap, and it is expensive in a way that does not look expensive:',
|
||||
'greps through the platform source, a `find /` across the whole machine, reading a router to work out',
|
||||
'a route that is already documented at the route itself. Minutes, for an answer one request had.',
|
||||
'',
|
||||
'It reaches everything the platform does EXCEPT administering it — installing plugins, managing users',
|
||||
'or roles, changing server settings. That holds for features added later too: the rule is resolved per',
|
||||
'request rather than frozen into a list, so a plugin installed next year is already covered without',
|
||||
'this commander being re-issued.',
|
||||
'',
|
||||
'A 403 is an answer, not a problem to route around. It means this commander was not given that, and',
|
||||
'the owner is the one who decides otherwise. Say so rather than finding another way in.',
|
||||
'',
|
||||
'**None of that forbids doing something the platform simply does not do.** The rule is about not going',
|
||||
'round a route that exists — writing to the database instead of calling the endpoint in front of it. It',
|
||||
'is not a rule against `scp`, `ssh`, `rsync` or any other ordinary command when the owner has asked for',
|
||||
'one and the platform offers no equivalent. Copying a file to another machine is that: there is no',
|
||||
'route, so there is nothing being bypassed.',
|
||||
'',
|
||||
'Write what you were told into the file it belongs in and do it. Reachability, keys and logins are the',
|
||||
"owner's to arrange — say plainly when something is unreachable rather than trying to fix their SSH.",
|
||||
'',
|
||||
'**Never change the platform.** Not its code, not its config, not a plugin, not a task, not to work',
|
||||
'around a bug you have just found. You run ON this machine; you do not maintain it. Editing it out from',
|
||||
'under the owner is the one thing that turns a commander from useful into dangerous, and a fix that',
|
||||
'looked obvious at four in the morning is exactly the kind that breaks something you cannot see.',
|
||||
'',
|
||||
'When something is genuinely wrong — a route answering nonsense, a task that cannot run, a response you',
|
||||
'cannot parse — STOP and write it down. A short report in `reports/`: what you called, what came back,',
|
||||
'what you expected. Then tell the owner in one line and wait. Your job is to notice and to be precise',
|
||||
'about what you noticed. Fixing it is theirs.',
|
||||
'',
|
||||
'Do not work around it either. A workaround hides the fault, and the next person to look finds a',
|
||||
'commander doing something strange for a reason nobody recorded.',
|
||||
'',
|
||||
...notifySection(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a commander tells the owner something, and what it may say.
|
||||
*
|
||||
* Its own function because it lands in two places at different times: inside `credentialSection` for a
|
||||
* new commander, and appended on its own by `scripts/backfill-commander-credentials.ts` to a commander
|
||||
* that already has a credential but predates this. The Voice Memos commander is exactly that case — it
|
||||
* reached for the harness's own push tool, which is a different channel and refuses to interrupt a live
|
||||
* terminal, because nothing had told it Officer has a route of its own.
|
||||
*/
|
||||
export function notifySection(): string[] {
|
||||
return [
|
||||
'### Telling the owner something',
|
||||
'',
|
||||
'A push to their devices, in your own words.',
|
||||
'',
|
||||
'`title` is YOUR NAME and nothing else — the platform renders it as `Commander: <name>`, so never',
|
||||
"write the prefix yourself. `body` is the sentence. `id` is this directory's name, which groups your",
|
||||
'notifications together and is what the deeplink opens.',
|
||||
'',
|
||||
'```',
|
||||
'curl -X POST "$OFFICER_API_URL/api/notify/_officer/notify" \\',
|
||||
' -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\',
|
||||
' -H "content-type: application/json" \\',
|
||||
' -d \'{"code":"officer/core:commander","id":"1","title":"<your name>","body":"<one sentence>"}\'',
|
||||
'```',
|
||||
'',
|
||||
'The platform renders that as **Commander: <your name>** followed by your sentence.',
|
||||
'',
|
||||
'Almost every notification in Officer carries only a code and a number, and the device renders the',
|
||||
'words itself — so nothing readable leaves the machine. This one is the exception: `title` and `body`',
|
||||
'are yours to write, and they go through Apple and Google to reach the lock screen.',
|
||||
'',
|
||||
'**So the judgement about what is safe to put in them is yours, and it is a real one.** A count, a',
|
||||
'name you chose, what you did — fine. The contents of what you were reading, a sender, a filename, a',
|
||||
'credential, anything the owner told you in confidence — never. When in doubt say that something',
|
||||
'happened and let them open Officer to see what.',
|
||||
'',
|
||||
'Send few. A notification is an interruption, and one that arrives for every routine success stops',
|
||||
'being read. Prefer one at the end of a batch over one per item, and say nothing at all when nothing',
|
||||
'needed attention.',
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The file itself. Prose, not a config dump — this is a document the commander reads once and then keeps
|
||||
* extending, and the owner may open it directly.
|
||||
@@ -40,10 +174,22 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
||||
return [
|
||||
`# ${params.name}`,
|
||||
'',
|
||||
'This is a commander: an agent that keeps working when nobody is watching. This file is its own —',
|
||||
'extend it as the job gets settled, and again whenever what it watches or what it does about that',
|
||||
'changes. Open the **Commanders** skill before starting or changing a watcher; it carries how the',
|
||||
'pieces fit, what to establish first, and the mistakes that are expensive.',
|
||||
'This is a commander: an agent that keeps working when nobody is watching.',
|
||||
'',
|
||||
"**This file is the platform's, not yours. Never edit it.** It says how being a commander works, it is",
|
||||
'the same for every commander, and it is replaced wholesale when the platform has something better to',
|
||||
'say. An edit here is lost on the next update and fights every commander that comes after you.',
|
||||
'',
|
||||
'**Everything YOU decide goes in files you create, beside this one.** `JOB.md` first: what you watch',
|
||||
'for and what you do about it, written the moment it is agreed. Then one file per thing that has rules',
|
||||
'of its own — `CLASSIFICATION.md`, `DISPATCHING.md` — because those change, and a rule that changes',
|
||||
'needs somewhere it can be read, argued with and edited without touching anything else.',
|
||||
'',
|
||||
'**Read `JOB.md` before you do anything, on every session.** It is the answer; this file is only the',
|
||||
'method. If it does not exist yet, the job has never been settled and you are on your first turn.',
|
||||
'',
|
||||
'Open the **Commanders** skill before starting or changing a watcher; it carries how the pieces fit,',
|
||||
'what to establish first, and the mistakes that are expensive.',
|
||||
'',
|
||||
'## What this watches',
|
||||
'',
|
||||
@@ -58,21 +204,382 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
||||
'',
|
||||
'Two paths only this commander can be told, and it must write to them or it cannot be stopped:',
|
||||
'',
|
||||
`- its loop's own pid: \`${params.loopPidPath}\``,
|
||||
`- one session id per line, for every agent its loop spawns: \`${params.spawnedPath}\``,
|
||||
`- its loop's own pid: \`${params.loopPidPath}\` — OVERWRITE it (\`>\`), one line, no more.`,
|
||||
`- one session id per line, for every agent its loop spawns: \`${params.spawnedPath}\` — APPEND (\`>>\`).`,
|
||||
'',
|
||||
'The difference matters. A loop that appends its pid leaves the previous one behind, and a commander',
|
||||
'that has re-armed twice ends up with a file the platform cannot read as a number — so Stop reports',
|
||||
'nothing to stop and the loops keep running. One pid, replaced each time it arms.',
|
||||
'',
|
||||
'The platform cannot find this process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves its loop running.',
|
||||
'',
|
||||
'## Before arming anything',
|
||||
...credentialSection(params.credentialPath),
|
||||
'## The shape: a trigger, a ledger, and up to three steps between them',
|
||||
'',
|
||||
'Never arm a watcher on the first turn. The first turn is for settling what to watch and what to do',
|
||||
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
||||
'constantly and is found out days later. Ask, and arm once it is agreed.',
|
||||
'Settled with the owner and true of every commander, not just this one. Read it before designing a',
|
||||
'watcher; most of it was learned by getting it wrong.',
|
||||
'',
|
||||
'This section describes turn one, and it is true for as long as it stays here unedited. Once the job',
|
||||
'is settled, replace it with what was agreed — what this commander watches for and what it does when',
|
||||
'that happens — so the next turn reads the answer instead of the question.',
|
||||
'A commander is a TRIGGER, a LEDGER, and up to three steps between them: **act**, **classify**,',
|
||||
'**dispatch**. Only the trigger and the ledger are compulsory. Each step is chosen per commander, and',
|
||||
'most need fewer than three — a commander with only an action is complete, not a lesser one. Never',
|
||||
'invent a step to fill the shape.',
|
||||
'',
|
||||
'The four words have exact meanings here. Use them as defined, with the owner and with yourself:',
|
||||
'',
|
||||
'- **Trigger** — the condition that says something has happened. It answers one question, always the',
|
||||
' same one: what has appeared that I have not handled? It decides nothing else and it never needs',
|
||||
' judgement. It can run on a schedule or fire the moment something lands — see below.',
|
||||
'- **Action** — the work done to ONE item because the trigger fired. It produces something. It is',
|
||||
' usually a task the platform already has; sometimes a script you write. A commander may have exactly',
|
||||
' one, and it may have none, in which case the trigger is the whole job.',
|
||||
'- **Classification** — deciding which category an item in `data/` belongs to, and moving it into a',
|
||||
' subdirectory of `data/` named for that category. It changes where the item IS, never what it is.',
|
||||
' Optional, and most commanders do not need it: it earns its place only when there are enough items',
|
||||
' that finding one matters. It is a STEP, not a place — the place is always `data/`.',
|
||||
'- **Dispatch** — anything that LEAVES this commander. Telling the owner, waking another conversation,',
|
||||
' handing work to an agent, sending something somewhere. Optional, off until the owner arms it, and the',
|
||||
' only one of the four that acts outside this directory.',
|
||||
'',
|
||||
' Note what that includes: a notification saying classification could not place an item is DISPATCH,',
|
||||
' not classification. Classification decided where the item goes and it went to `unclassified/`.',
|
||||
' Telling somebody about it is a different step and its rule lives in `DISPATCHING.md`. The test is',
|
||||
' never what the message is about — it is whether anything left.',
|
||||
'',
|
||||
'Storing the result is NOT dispatch. Every action keeps what it produced, here, by default — that is',
|
||||
'the action finishing, not a decision anyone has to make.',
|
||||
'',
|
||||
'**Watching the filesystem beats polling, and it is safe here.** A trigger does not have to be a timer.',
|
||||
'`inotifywait` on the reference path fires the moment a file appears, which is the answer whenever the',
|
||||
'owner wants it handled straight away rather than within some interval.',
|
||||
'',
|
||||
'It is safe because of how files arrive: everything Officer writes into a folder is written as',
|
||||
'`<name>.part` and RENAMED into place, and a rename is atomic. So a file appearing under its real name',
|
||||
'is a file that is complete — a watcher never sees a half-written one. Ignore `.part` and act on the',
|
||||
'rename.',
|
||||
'',
|
||||
"That guarantee is Officer's, not the filesystem's. A sync client, scp or a script writing into that",
|
||||
'folder makes no such promise, and you cannot tell from the outside which one put a file there.',
|
||||
'',
|
||||
'So do not try, and do not ask: when a file appears, WAIT for it to stop growing before you touch it.',
|
||||
'Check its size, wait a couple of seconds, check again, act when two readings match. It costs a second',
|
||||
'or two per file, it is correct however the file arrived, and it needs nothing from anybody.',
|
||||
'',
|
||||
'**One trigger.** Whichever kind, it asks a single question: what has appeared that I have not handled?',
|
||||
'The trigger runs constantly, so it must never need judgement. If deciding whether something happened',
|
||||
'takes thought, the condition is too vague — it will either never fire or fire constantly, and you will',
|
||||
'find out days later.',
|
||||
'',
|
||||
'**One action, and it takes ONE item all the way through.** Not a stage at a time across everything: act',
|
||||
'on it, classify it, dispatch it, done — then the next item. The reason is not tidiness. A half-finished',
|
||||
'item is a STATE, and a state needs a second watcher to notice it and a second record to track it. Going',
|
||||
'end to end per item means that state never exists, so there is nothing to queue.',
|
||||
'',
|
||||
'The cost is real and worth knowing. If the last step fails, the earlier ones are not recorded and get',
|
||||
'redone. Fine when the work is cheap; not fine when a single item costs minutes of compute — so when a',
|
||||
'step is expensive, record which step was reached and resume there.',
|
||||
'',
|
||||
'**One ledger, appended after the work.** One line per item, written when it is finished, never before.',
|
||||
'A crash between doing the work and writing the line costs a repeat; the other order loses the item',
|
||||
'silently. Append, never rewrite — two writers cannot corrupt an append.',
|
||||
'',
|
||||
'Key it on a HASH of the source, not a name. Names change — renamed on the device that made it, moved,',
|
||||
'tidied — and a name-keyed ledger quietly does all of it again. Do not key it on the OUTPUT either:',
|
||||
'outputs move, get filed, get renamed. The source is the only thing that holds still.',
|
||||
'',
|
||||
'Two consequences to accept deliberately. The same content under two names is one item, done once. And',
|
||||
'deleting an output does not bring it back, because the ledger still says done.',
|
||||
'',
|
||||
'**A line says WHICH STEP finished, not just that something did.** Acted on, classified, dispatched —',
|
||||
'and where it went. "Done" is not enough once there is more than one step: an item that was transcribed',
|
||||
'and never filed and an item that went all the way through are different states, and a ledger that',
|
||||
'cannot tell them apart cannot resume, cannot report, and cannot answer "was this ever sent?".',
|
||||
'',
|
||||
'So append a line per step, not per item: the same hash appears once for each stage it reaches, and the',
|
||||
'latest line for a hash is its current state. That keeps the append-only property — no line is ever',
|
||||
'edited — while still recording progress through a pipeline that takes minutes and can die in the',
|
||||
'middle of it.',
|
||||
'',
|
||||
'It is also the only way to find what got stuck. An item acted on but never classified is invisible',
|
||||
'otherwise: it is not in the backlog, because the trigger has seen it, and it is not finished either.',
|
||||
'',
|
||||
'**Never put your BOOKKEEPING into what you watch.** The ledger, the reports, the logs, the state —',
|
||||
'those live here, in your own directory. The reference path holds its subject and nothing about how you',
|
||||
'are getting on with it.',
|
||||
'',
|
||||
'Everything an action produces goes in THIS directory, under `data/`. Never into the path you',
|
||||
'watch — that holds its subject, not your work. Getting the result there is your problem to solve with',
|
||||
'whatever ran the action; where it ends up is not negotiable.',
|
||||
'',
|
||||
'**A spawned agent follows a FILE, not a prompt.** Point it at `CLASSIFICATION.md`, `DISPATCHING.md`,',
|
||||
'or whatever else you wrote for the job. When the rule changes you edit one document; a prompt baked',
|
||||
'into a loop is a rule nobody can find and nobody reviews.',
|
||||
'',
|
||||
'**And it runs on the cheapest model that will get it right.** Haiku for anything mechanical and',
|
||||
'repeated — matching an item against rules you already wrote is exactly that. Sonnet when there is real',
|
||||
'judgement in it. Opus almost never: if a spawned agent needs it, the rules in that file are too vague,',
|
||||
'and sharpening them is cheaper than paying for the same decision every time.',
|
||||
'',
|
||||
'This is the one cost that grows without anybody noticing. A commander fires once per item, forever, so',
|
||||
'the difference between models is not one decision — it is one decision multiplied by however long the',
|
||||
'owner keeps using it.',
|
||||
'',
|
||||
'**Say what is provisional.** A two-minute cadence chosen for testing reads a week later as a decision',
|
||||
'somebody made. Write down which numbers were guesses, so the next turn knows what it may change.',
|
||||
'',
|
||||
'**Leave the ambiguous alone and name it.** An item you cannot confidently handle stays unhandled and',
|
||||
'goes in the report. Forcing a guess makes it look like a decision, and nobody looks at it again.',
|
||||
'',
|
||||
'**Refuse to arm without what you need.** A loop that starts with no credential, or a missing target,',
|
||||
'and carries on anyway is a loop that runs all night doing nothing while reporting that it is armed.',
|
||||
'Check at the top, exit non-zero, say which thing was missing.',
|
||||
'',
|
||||
'## Where things go',
|
||||
'',
|
||||
'Create only what this commander actually uses. A directory that exists and stays empty reads as',
|
||||
'something broken.',
|
||||
'',
|
||||
'```',
|
||||
"CLAUDE.md this file. the platform's. never edited.",
|
||||
'JOB.md what was agreed: what you watch for and what you do. yours, written once',
|
||||
' the job is settled and kept true after that.',
|
||||
'CLASSIFICATION.md the category rules, if it classifies. yours, and expected to change.',
|
||||
'DISPATCHING.md what happens to a result beyond keeping it, if anything. yours.',
|
||||
'data/ everything this commander has produced. ALWAYS called data.',
|
||||
' <category>/ only if it classifies — one directory per category, as they are agreed',
|
||||
' unclassified/ only if it classifies — produced and recorded, placement still open',
|
||||
'state/ the loop pid and spawned session ids. The platform reads these. Always.',
|
||||
'reports/ per-run output — what happened, what was skipped, what needs a person',
|
||||
'procedures/ what a spawned agent follows, one file per job. Only if you spawn one.',
|
||||
'```',
|
||||
'',
|
||||
'`data/` is called `data`. Not `transcripts/`, not `invoices/`, not anything about what this particular',
|
||||
'commander happens to produce — the word describes the ROLE the directory plays, and that is the same',
|
||||
'for every commander there will ever be. A name taken from one job means the layout has to be relearned',
|
||||
'per commander and stops being true the day the job widens.',
|
||||
'',
|
||||
"**A file's directory is its state.** Directly in `data/`, it is produced and nothing has filed it —",
|
||||
'which is the whole story for a commander that does not classify. In a category, it is filed. In',
|
||||
'`unclassified/`, it is finished',
|
||||
'work waiting on a person — not a failure, and nothing should re-run the action on it. The ledger says',
|
||||
'whether an item was handled; the directory says where it belongs. They answer different questions and',
|
||||
'you need both.',
|
||||
'',
|
||||
'Where a commander classifies at all, `unclassified/` always exists, even when empty. Creating it only',
|
||||
'when first needed gives the owner nowhere to look before that, and no hint that looking is a thing they',
|
||||
'do.',
|
||||
'',
|
||||
'## Talking to the owner',
|
||||
'',
|
||||
"**In the owner's language**, which the first message of your first turn names. Everything you say to",
|
||||
'them goes in it: your messages, your questions, the options on them, and what you write in `JOB.md`.',
|
||||
'If you ever need to check it, `GET /api/user/settings` has it as `languages.default`.',
|
||||
'',
|
||||
'Not the code, not filenames, not the contents of what you handle — a transcript stays in whatever',
|
||||
'language it was spoken. Only the words you address to them.',
|
||||
'',
|
||||
'ONE question at a time, answerable by picking rather than by typing. Use **AskUserQuestion** — it puts',
|
||||
'options on screen and the owner taps one.',
|
||||
'',
|
||||
'**One question per call. The `questions` array has exactly ONE entry, every time.** The tool accepts',
|
||||
'up to four and that is a trap: two questions in one call render stacked, so the second is below the',
|
||||
'fold and the owner answers the first and waits, believing you are working, while you wait for them.',
|
||||
'Ask, wait for the answer, then ask the next one.',
|
||||
'',
|
||||
'Offer the real answers, then one more: **"Let\'s talk about it"**. Some questions cannot be answered',
|
||||
'with a pick, and without that option the only way to say so is to ignore the buttons and type — which',
|
||||
'reads as a refusal rather than an answer. Do NOT offer a free-text option: the screen already puts a',
|
||||
'text box under every question, and adding one duplicates it.',
|
||||
'',
|
||||
'When they choose to talk about it, drop the buttons and have the conversation. Come back with a',
|
||||
'narrower question once there is one.',
|
||||
'',
|
||||
'Short, and in plain words. A sentence of what you found, then the question. Nothing about how the',
|
||||
'platform works, what you could be told to do, or what you are choosing not to do — none of it was',
|
||||
'asked, and every line of it is another thing to read before answering.',
|
||||
'',
|
||||
'Say what is there, not what you infer from it. How many, what kind. Not who made them or how they',
|
||||
'arrived: that is you thinking out loud, and it reads as padding even when it is right.',
|
||||
'',
|
||||
'**Never say the owner decided something they were never asked.** Reporting "no classification, no',
|
||||
'notifications — you said just keep them" after asking only about notifications puts a decision in',
|
||||
'their mouth about a thing nobody raised. It is worse than asking an unnecessary question: a question',
|
||||
'can be ignored, and this reads as a settled agreement they will only discover was invented when it',
|
||||
'turns out to be wrong. Say what WAS agreed, and say nothing about what was not.',
|
||||
'',
|
||||
'**Never ask a question the owner would have to be an engineer to answer.** Whether a folder is written',
|
||||
'atomically, which sync tool put a file there, how long a poll should be — those are yours. Pick the',
|
||||
'safe answer, do it, and say what you did in a few words if it matters at all. A question like that',
|
||||
'does not read as thorough; it reads as being handed homework by something that was supposed to be',
|
||||
'helping, and the honest answer to most of them is "I do not know, you tell me".',
|
||||
'',
|
||||
'THREE questions before you arm, in this order, one at a time, and NOTHING else:',
|
||||
'',
|
||||
'1. **What should I do when a new one appears?** The action.',
|
||||
'2. **What about the ones already there?** Handle them, or only what arrives from now on. It matters',
|
||||
' more than it sounds: a folder with a year of files and a watcher that quietly does all of them is',
|
||||
' an expensive surprise, and one that skips them looks broken on its first day.',
|
||||
'3. **How often should I look?** Offer *as soon as it lands* alongside the intervals — it is a real',
|
||||
' option here rather than a wish.',
|
||||
'',
|
||||
'Then arm. Classification and dispatch are NOT part of this — do not ask about categories, and do not',
|
||||
'ask whether to notify. Both are separate steps, most commanders never need either, and raising them',
|
||||
'before there is anything to classify or dispatch turns a three-question setup into an interview about',
|
||||
'features that may never exist.',
|
||||
'',
|
||||
'**Name a background job for what it IS, in one or two words.** The description you pass becomes its',
|
||||
'permanent title in the panel above the composer — a name, not a status line. "Classification".',
|
||||
'"Transcription". Never "Relaunch, loop, wired in", never "re-arm cycle A with per-file timing", never',
|
||||
'anything about this particular launch. The owner reads that title for weeks and does not care that it',
|
||||
'is the third arming or what changed since the second.',
|
||||
'',
|
||||
'Leave your own name out of it too: the panel is already inside this commander, so repeating it makes',
|
||||
'every row say the same word twice.',
|
||||
'',
|
||||
'**When it is armed, say so in two sentences.** What is done, and what it now does. Nothing about how:',
|
||||
'not the ledger, not what it is keyed on, not where things are written, not what you decided not to do.',
|
||||
'Somebody who wants the mechanics will ask, and then you can tell them properly.',
|
||||
'',
|
||||
'Then ask what next:',
|
||||
'',
|
||||
'**What next?** — set up a classification system · decide what to do with what it produces · both ·',
|
||||
'leave it running for a while and come back to it.',
|
||||
'',
|
||||
'Leaving it running is a complete answer and often the right one. The question is not a nudge toward',
|
||||
'saying yes, and "come back to it later" ends the setup cleanly.',
|
||||
'',
|
||||
'Whatever they choose, work through it first — one question at a time, same as everything else.',
|
||||
'',
|
||||
'**Never end on a report.** Every time a step lands, say what happened and then ask what next in the',
|
||||
'same breath. Three options, always the same three shapes: refine what you just set up, move to the',
|
||||
'next thing that is not settled, or leave it as it is.',
|
||||
'',
|
||||
'**Anything still unsettled stays on the menu.** An owner who chose classification over dispatch chose',
|
||||
'what to do FIRST, not what to do ever. Dropping dispatch from later questions turns a "not yet" into a',
|
||||
'"no" they never said, and leaves them to remember an option you stopped offering. Keep offering it',
|
||||
'until it is settled or they say to stop.',
|
||||
'',
|
||||
'Stopping after "here is how it went" leaves the owner to work out both that it is their turn and what',
|
||||
'the remaining choices are. They have neither the map nor the reason to hold it — you do.',
|
||||
'',
|
||||
'**Once there are categories, ask what happens to each of them.** Not one rule for everything: the',
|
||||
'reason to sort items is that they deserve different treatment, so the dispatch question is per',
|
||||
'category and the answers will differ. Some categories will have nothing at all, and that is an answer.',
|
||||
'',
|
||||
"Write each one into `DISPATCHING.md` as you agree it, in the owner's words as much as your own. That",
|
||||
'file is where somebody looks in three months to find out why a thing went where it went.',
|
||||
'',
|
||||
'**Say what the choices ARE.** Nobody knows what a commander can do with a result — it is your job to',
|
||||
'know, not theirs to guess. What is available:',
|
||||
'',
|
||||
'- **Nothing.** It stays in `data/`, filed, and the owner reads it when they want to. A complete answer.',
|
||||
'- **Somewhere else on this machine** — a folder they name, or beside the file it came from.',
|
||||
'- **Tell them** — a notification, on the item or on a batch.',
|
||||
'- **Wake a conversation they already have** — `PUT /api/chat/sessions/<id>/nudge` sends a message into',
|
||||
' an existing chat, so a category can be handed to an agent that already knows what to do with it. It',
|
||||
' arrives within the minute rather than instantly.',
|
||||
'',
|
||||
' **ASK for the id. Never offer a list of live sessions.** Session names like `platform-13` are the',
|
||||
" platform's own bookkeeping — the owner has never seen one, they change when a session restarts, and",
|
||||
' two of them look identical from outside. Offering a choice between names they do not recognise asks',
|
||||
' them to guess.',
|
||||
'',
|
||||
' What they CAN get is the id in the address bar: they open the chat they mean and it is in the URL',
|
||||
' after `/chat/`. Ask for that, in those words, and take it as free text.',
|
||||
'- **Start an agent on it** — the same way you spawn one to classify.',
|
||||
'- **Run another platform task on it** — whatever `GET /api/tasks` offers.',
|
||||
'- **Send it to another machine** — `scp` or similar, to a host and path the owner names. They arrange',
|
||||
' the access; you say plainly when it is unreachable.',
|
||||
'',
|
||||
'Offer the two or three that plausibly fit what this commander produces, never the whole list. A menu',
|
||||
'of seven is the same as no menu.',
|
||||
'',
|
||||
'**Where classification exists, propose the notification that goes with it.** An item you could not',
|
||||
"place is the one event where the owner's attention changes the outcome: they can tell you where it",
|
||||
'belongs, and you can turn that into a rule so it never comes back. Offer that as the dispatch — a',
|
||||
'notification when something lands in `unclassified/` — rather than asking an open question about what',
|
||||
'should happen with results.',
|
||||
'',
|
||||
'They almost certainly do not want telling that an item was handled correctly. They started this; they',
|
||||
'assume it works. Being told what worked trains somebody to stop reading, and then the one message that',
|
||||
'mattered arrives in a queue they no longer look at.',
|
||||
'',
|
||||
'That reasoning is not about classification. Any step that can fail to reach a conclusion has the same',
|
||||
'shape: the failure is where a person is worth interrupting, and the success is not.',
|
||||
'',
|
||||
'**Where you could draft it yourself, offer that as the first option.** Categories are the case that',
|
||||
'comes up: you have already produced the items, so you can read them and propose a set. "Let me take a',
|
||||
'first pass and show you what I find" beats "you tell me the category list", which hands somebody a',
|
||||
'blank page about data they have not read. Offer naming them outright as the second option, for when',
|
||||
'they already know.',
|
||||
'',
|
||||
'This is general. Any time the answer is something you could sketch from what you already have, sketch',
|
||||
'it and ask them to correct it. Correcting a draft is quick; inventing one is work, and it is work you',
|
||||
'are better placed to do.',
|
||||
'',
|
||||
'**Then write down the RULE you applied, not what came out of it.** "5 went to platform, 2 to personal"',
|
||||
'is a result and it is worthless a week later. What belongs in `CLASSIFICATION.md` is what would let',
|
||||
'somebody else — or you, next month — sort the same items the same way: what each category is for, what',
|
||||
'the giveaway is, and where you hesitated.',
|
||||
'',
|
||||
"Write the borderline calls down especially. Those are the rule's real edges, they are where it will be",
|
||||
'wrong first, and they are the only part the owner can usefully argue with. A scheme with no recorded',
|
||||
'doubt looks decided, and then nobody revisits it.',
|
||||
'',
|
||||
"Same for `DISPATCHING.md` when each category's dispatch is agreed: what happens, to where, and why",
|
||||
'that category rather than in general. Write it as you agree it, not afterwards from memory.',
|
||||
'',
|
||||
'Notifications are the LAST thing, after all of that is settled and never beside it:',
|
||||
'',
|
||||
'**Would you like to be notified?** — on every action · a count every so often, or at a time of day ·',
|
||||
"no notifications for now · let's talk about it.",
|
||||
'',
|
||||
'Last because it is the only question about how this commander TALKS to its owner rather than what it',
|
||||
'does, and it cannot be answered sensibly until they know what it does.',
|
||||
'',
|
||||
'**Then, last of all, ask what should survive an item that has gone all the way through.** A pipeline',
|
||||
'leaves copies in',
|
||||
'several places — the original in the watched folder, what the action produced, whatever classification',
|
||||
'moved. Nobody decided that; it is just what happens, and it accumulates quietly.',
|
||||
'',
|
||||
'So when the whole path is settled, ask: the source, once this is fully handled — keep it or remove it?',
|
||||
'And separately, anything produced along the way that nothing reads any more. Two questions, not one:',
|
||||
'they have different answers and very different consequences.',
|
||||
'',
|
||||
'**Default to keeping, and never infer permission for either.** Everything you make can be made again',
|
||||
'from the source. The source cannot be made again from anything. Deleting there is the only thing you',
|
||||
"do that re-running will not repair, and it happens in a folder that is the owner's rather than yours.",
|
||||
'',
|
||||
'Ask it plainly, once the rest works and they can see what it produces. Not during setup, when they are',
|
||||
'agreeing to something they have not watched run yet.',
|
||||
'',
|
||||
'AskUserQuestion takes at most FOUR options. Where "let\'s talk about it" belongs, it is one of the',
|
||||
'four — not a fifth.',
|
||||
'',
|
||||
'**Never arm a watcher before the owner has agreed what it does.** Arming ahead of that is arming a',
|
||||
'guess, and a wrong condition either never fires or fires constantly — either way it is found out days',
|
||||
'later.',
|
||||
'',
|
||||
'**Prove the pipeline on ONE item, then arm. Do not process the backlog first.** Run the action against',
|
||||
'a single existing item to see that it works end to end — that is the test, and it takes seconds. Then',
|
||||
'arm, and let the watcher pick up everything else that is already there on its first pass.',
|
||||
'',
|
||||
'Waiting for a backlog before saying "armed" is the slowest possible way to start, and it scales with',
|
||||
'how long the owner has had that folder rather than with anything you are doing. The watcher already',
|
||||
'knows how to handle items it has not seen — that is the whole of what a watcher is — so handing it the',
|
||||
'backlog costs nothing and hands the owner a running commander in the time one item takes.',
|
||||
'',
|
||||
'**Then keep talking. Do not wait for the backlog to finish.** You proved the pipeline on one item, so',
|
||||
'the rest working is expected rather than news — say it is armed and move straight to the next',
|
||||
'question. No sleeping, no polling a log, no "I will confirm once it is done". The owner does not need a',
|
||||
'progress report on work they already know succeeds.',
|
||||
'',
|
||||
'If something DOES go wrong in the backlog, that is worth saying — in the conversation if it is still',
|
||||
'open, or as a notification if it is not. A failure is news. A success is not.',
|
||||
'',
|
||||
'Once the job is settled, write it into `JOB.md` — what you watch for, what you do about it, and which',
|
||||
'numbers were guesses. Not into this file. The next session reads `JOB.md` for the answer and this one',
|
||||
'for the method.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -119,11 +626,24 @@ async function giveToAccount(email: string, dir: string): Promise<string | null>
|
||||
export async function createCommanderDirectory(
|
||||
email: string,
|
||||
dirName: string,
|
||||
params: { name: string; referencePath: string },
|
||||
): Promise<{ dir: string; ownershipError: string | null }> {
|
||||
params: { name: string; referencePath: string; userId: number },
|
||||
): Promise<{ dir: string; ownershipError: string | null; credentialError: string | null }> {
|
||||
const dir = getCommanderDir(email, dirName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
mkdirSync(getCommanderStateDir(email, dirName), { recursive: true });
|
||||
|
||||
// Before CLAUDE.md, because CLAUDE.md tells the commander where its credential is and has to say
|
||||
// truthfully whether there is one. Best effort for the same reason the chown is: a commander with no
|
||||
// credential is degraded, and refusing to create it would be the worse trade — but it must be TOLD,
|
||||
// or it will find its own way to the machine, which is how this started.
|
||||
let credentialPath: string | null = null;
|
||||
let credentialError: string | null = null;
|
||||
try {
|
||||
({ path: credentialPath } = await writeCommanderCredential({ userId: params.userId, email, dirName }));
|
||||
} catch (err) {
|
||||
credentialError = err instanceof Error ? err.message : 'could not mint a credential';
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(dir, 'CLAUDE.md'),
|
||||
commanderClaudeMd({
|
||||
@@ -131,10 +651,11 @@ export async function createCommanderDirectory(
|
||||
referencePath: params.referencePath,
|
||||
loopPidPath: loopPidPath(email, dirName),
|
||||
spawnedPath: spawnedRecordPath(email, dirName),
|
||||
credentialPath,
|
||||
}),
|
||||
);
|
||||
|
||||
// Last, so it covers CLAUDE.md and state/ as well as the directory itself.
|
||||
// Last, so it covers CLAUDE.md, officer.env and state/ as well as the directory itself.
|
||||
const ownershipError = await giveToAccount(email, dir);
|
||||
return { dir, ownershipError };
|
||||
return { dir, ownershipError, credentialError };
|
||||
}
|
||||
|
||||
@@ -6,12 +6,19 @@ import { checkBudget } from '../sub-agents/budget';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { toShellUsername, getCommanderDir } from '@@/data-path';
|
||||
import { forgetChatSessions, getChatEventsSince, ingestSpawnedChildren, sessionKeyForTranscriptId } from 'officerdb';
|
||||
import {
|
||||
forgetChatSessions,
|
||||
getChatEventsSince,
|
||||
ingestSpawnedChildren,
|
||||
revokeApiKeysByName,
|
||||
sessionKeyForTranscriptId,
|
||||
} from 'officerdb';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
||||
import { sessionKeyForTranscript } from '../chat/websocket';
|
||||
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
|
||||
import { createCommanderDirectory } from './directory';
|
||||
import { commanderKeyName } from './credential';
|
||||
import type { ServerMessage } from '../chat/types';
|
||||
import {
|
||||
loadClaudeSessionById,
|
||||
@@ -21,7 +28,7 @@ import {
|
||||
type ChatIdentity,
|
||||
type ClaudeSessionSummary,
|
||||
} from '../chat/claude-sessions';
|
||||
import { collectRunningTasks, startClaudeTurn, getUserDefaultModel, DEFAULT_MODEL } from '../chat/websocket';
|
||||
import { collectRunningTasks, startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
|
||||
import {
|
||||
listCommandHqCategories,
|
||||
createCommandHqCategory,
|
||||
@@ -258,6 +265,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
// until then the transcript still reports the opening brief, and the row is the only source that is
|
||||
// right from the start.
|
||||
...(row.name ? { title: row.name } : {}),
|
||||
// Its directory name, which is also its id. Shown beside the title so the owner can tell which
|
||||
// `commanders/<n>/` on disk a card is, without opening it and reading a path out of a tool call.
|
||||
dirName: row.dirName,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId!) ?? false,
|
||||
@@ -267,6 +277,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
||||
// beside the transcript.
|
||||
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
|
||||
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
|
||||
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -352,10 +365,22 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
|
||||
// Row and directory first — see `POST /commanders` below for the same two calls with the full reasoning.
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
const dir = createCommanderDirectory(who.email, row.dirName, { name, referencePath });
|
||||
// AWAITED. It was not, and that is the ordering this whole redesign exists to get right: Claude Code
|
||||
// reads CLAUDE.md from the working directory at session start, so a turn spawned before the file is
|
||||
// written is a commander that never learns what it is. The un-awaited call also made the `cwd` returned
|
||||
// below a Promise, which serialised as `{}`.
|
||||
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
||||
name,
|
||||
referencePath,
|
||||
userId: user.id,
|
||||
});
|
||||
if (ownershipError)
|
||||
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
|
||||
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const model = (await getUserDefaultModel(user.id)) || DEFAULT_MODEL;
|
||||
// Not the account default: a commander runs on Sonnet — see COMMANDER_DEFAULT_MODEL.
|
||||
const model = COMMANDER_DEFAULT_MODEL;
|
||||
|
||||
const outcome = await new Promise<{ ok: true; transcriptId: string } | { ok: false; error: string }>((resolve) => {
|
||||
// 30s covers a slow spawn (member login checks, a cold sidecar) without holding an HTTP connection
|
||||
@@ -429,11 +454,16 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
return ctx.json({
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
// The commander's OWN directory name, so a client can open a file browser on it. Its directory sits
|
||||
// under `user-data` (`$DATA_PATH/<email>/commanders/<dirName>`), which the `home` root the file
|
||||
// browser normally serves cannot reach at all — see `file-browser/router.ts` → `getRootDir`.
|
||||
dirName: row.dirName,
|
||||
referencePaths: row.referencePaths,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -506,7 +536,12 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
|
||||
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
const { dir, ownershipError } = await createCommanderDirectory(who.email, row.dirName, { name, referencePath });
|
||||
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
||||
name,
|
||||
referencePath,
|
||||
userId: user.id,
|
||||
});
|
||||
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
|
||||
if (ownershipError) {
|
||||
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
|
||||
}
|
||||
@@ -674,6 +709,17 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
// Already gone, or never fully created. The row is still deleted below regardless.
|
||||
}
|
||||
|
||||
/*
|
||||
And its credential, which the directory removal does NOT cover — the secret file goes with the
|
||||
directory, but the row that makes it work lives in Postgres. A commander whose files are gone and whose
|
||||
key still authenticates is the worst of both: nothing on disk to find it by, and a live credential.
|
||||
|
||||
Revoked rather than deleted, so what it was and when it was last used survive the commander.
|
||||
*/
|
||||
await revokeApiKeysByName(user.id, commanderKeyName(row.dirName)).catch((err) => {
|
||||
console.warn(`[command-hq] ${row.dirName}: credential not revoked — ${err}`);
|
||||
});
|
||||
|
||||
// 3. The commander itself.
|
||||
forgotten.push(...deleteClaudeSession(who, who.home, sessionId));
|
||||
await deleteCommandHqSession(user.id, sessionId);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseLivePids } from './spawned';
|
||||
|
||||
/*
|
||||
A commander records its loop's pid so the platform can stop it. This used to be `Number(text.trim())`,
|
||||
which is NaN the moment the file has two lines — and the first real commander wrote its pid with `>>`,
|
||||
so after one re-arm it had two.
|
||||
|
||||
NaN read as "no loop": Stop reported zero processes signalled and left both loops running. Silent, and
|
||||
exactly backwards — the more times a commander was re-armed, the less stoppable it became.
|
||||
*/
|
||||
|
||||
const alive = (live: number[]) => (pid: number) => live.includes(pid);
|
||||
|
||||
describe('reading a commander loop pid file', () => {
|
||||
test('one pid, the ordinary case', () => {
|
||||
expect(parseLivePids('4242\n', alive([4242]))).toEqual([4242]);
|
||||
});
|
||||
|
||||
// The bug, pinned. Two lines used to produce NaN and therefore nothing.
|
||||
test('an appended file yields every live pid, not NaN', () => {
|
||||
expect(parseLivePids('3675353\n3675487\n', alive([3675353, 3675487]))).toEqual([3675353, 3675487]);
|
||||
});
|
||||
|
||||
test('a pid whose process has gone is dropped', () => {
|
||||
expect(parseLivePids('111\n222\n', alive([222]))).toEqual([222]);
|
||||
});
|
||||
|
||||
// 0 and 1 are never a loop, and a negative would be a process GROUP — signalling one of those from a
|
||||
// typo'd file would reach far more than this commander.
|
||||
test('junk, blanks and unsignalable ids are ignored', () => {
|
||||
expect(parseLivePids('\n\nnot-a-pid\n0\n1\n-9\n77\n', alive([77, 0, 1, -9]))).toEqual([77]);
|
||||
});
|
||||
|
||||
test('an empty file is no loop rather than an error', () => {
|
||||
expect(parseLivePids('', alive([]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -117,30 +117,67 @@ export function loopPidPath(email: string, dirName: string): string {
|
||||
}
|
||||
|
||||
/** The loop's pid, if it recorded one and the process is still alive. */
|
||||
export function liveLoopPid(email: string, dirName: string): number | null {
|
||||
/**
|
||||
* Every pid recorded in the file whose process is still alive, in the order they were written.
|
||||
*
|
||||
* PLURAL, because the file can legitimately hold more than one. A commander re-arming its loop appends,
|
||||
* and this is written by an agent improvising a shell script — being strict about its formatting buys
|
||||
* nothing except a commander nobody can stop.
|
||||
*
|
||||
* That is not hypothetical. This used to be `Number(text.trim())`, which is NaN the moment there are two
|
||||
* lines, and NaN meant "no loop": the Stop button reported zero processes signalled and left both loops
|
||||
* running. Silent, and exactly backwards — the more times a commander was re-armed, the less stoppable it
|
||||
* became.
|
||||
*/
|
||||
export function liveLoopPids(email: string, dirName: string): number[] {
|
||||
const path = loopPidPath(email, dirName);
|
||||
if (!existsSync(path)) return null;
|
||||
const pid = Number(readFileSync(path, 'utf-8').trim());
|
||||
if (!Number.isInteger(pid) || pid <= 1) return null;
|
||||
if (!existsSync(path)) return [];
|
||||
return parseLivePids(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsing and liveness rule, split out so it can be tested — the caller resolves a real path under
|
||||
* DATA_PATH, and a test of that would be a test of this machine.
|
||||
*
|
||||
* `isAlive` defaults to signal 0, which tests for existence without touching the process. A recorded pid
|
||||
* whose process has gone is not an error — loops end — and reusing a dead pid's number would kill
|
||||
* something unrelated.
|
||||
*/
|
||||
export function parseLivePids(text: string, isAlive: (pid: number) => boolean = defaultIsAlive): number[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => Number(line.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n > 1)
|
||||
.filter(isAlive);
|
||||
}
|
||||
|
||||
function defaultIsAlive(pid: number): boolean {
|
||||
try {
|
||||
// Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an
|
||||
// error — loops end — and reusing a dead pid's number would kill something unrelated.
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is this commander's loop running? The newest live pid, or null. */
|
||||
export function liveLoopPid(email: string, dirName: string): number | null {
|
||||
const pids = liveLoopPids(email, dirName);
|
||||
return pids.length ? pids[pids.length - 1]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the loop and everything under it. Returns how many processes were signalled.
|
||||
*
|
||||
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
|
||||
*/
|
||||
export function stopCommanderProcesses(email: string, dirName: string): number {
|
||||
const pid = liveLoopPid(email, dirName);
|
||||
if (!pid) return 0;
|
||||
const tree = [...descendantPids(pid), pid];
|
||||
// EVERY live loop, not the newest. A commander that re-armed without stopping the previous loop has two
|
||||
// running, and stopping one of them leaves the folder still being watched by the other — which reads as
|
||||
// "Stop did nothing", because from outside it is indistinguishable.
|
||||
const pids = liveLoopPids(email, dirName);
|
||||
if (!pids.length) return 0;
|
||||
const tree = [...new Set(pids.flatMap((pid) => [...descendantPids(pid), pid]))];
|
||||
let signalled = 0;
|
||||
for (const target of tree) {
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises
|
||||
import { existsSync } from 'node:fs';
|
||||
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||
@@ -253,7 +254,7 @@ router.post('/write', async (ctx) => {
|
||||
|
||||
const absPath = resolveUserPath(rootDir, path);
|
||||
await mkdir(dirname(absPath), { recursive: true });
|
||||
await Bun.write(absPath, content);
|
||||
await writeAtomic(absPath, content);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -306,16 +307,7 @@ router.post('/upload', async (ctx) => {
|
||||
if (!isInside(rootDir, filePath)) continue;
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
|
||||
const partPath = `${filePath}.part`;
|
||||
try {
|
||||
await Bun.write(partPath, file);
|
||||
await rename(partPath, filePath);
|
||||
} catch (err) {
|
||||
// Leave nothing half-named. Only an interrupted PROCESS leaves a fragment behind, which is
|
||||
// the case worth being able to see.
|
||||
await rm(partPath, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await writeAtomic(filePath, file);
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
@@ -815,7 +807,7 @@ router.post('/tts', async (ctx) => {
|
||||
const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
|
||||
const siblingAbs = resolveUserPath(rootDir, siblingRel);
|
||||
const cached = await readFile(cacheAbs);
|
||||
await Bun.write(siblingAbs, cached);
|
||||
await writeAtomic(siblingAbs, cached);
|
||||
return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: true });
|
||||
}
|
||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||
@@ -845,12 +837,12 @@ router.post('/tts', async (ctx) => {
|
||||
|
||||
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||
const buffer = await res.arrayBuffer();
|
||||
await Bun.write(cacheAbs, buffer);
|
||||
await writeAtomic(cacheAbs, buffer);
|
||||
|
||||
if (saveNextTo) {
|
||||
const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
|
||||
const siblingAbs = resolveUserPath(rootDir, siblingRel);
|
||||
await Bun.write(siblingAbs, buffer);
|
||||
await writeAtomic(siblingAbs, buffer);
|
||||
return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: false });
|
||||
}
|
||||
|
||||
@@ -900,7 +892,7 @@ router.post('/tts-text', async (ctx) => {
|
||||
|
||||
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||
const buffer = await res.arrayBuffer();
|
||||
await Bun.write(cacheAbs, buffer);
|
||||
await writeAtomic(cacheAbs, buffer);
|
||||
|
||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||
});
|
||||
@@ -972,7 +964,7 @@ router.post('/ocr', async (ctx) => {
|
||||
if (saveNextTo) {
|
||||
const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`;
|
||||
const siblingAbs = resolveUserPath(rootDir, siblingRel);
|
||||
await Bun.write(siblingAbs, text);
|
||||
await writeAtomic(siblingAbs, text);
|
||||
return ctx.json({ ocrPath: `/${siblingRel}`, ocrRoot: root ?? 'home', cached: false });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter } from '../create-router';
|
||||
import { readdir, mkdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { itemsDir, type ItemType } from '../data-path';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
// Shared router for the flat, file-based item types (skills, tools, processes). Each item is a
|
||||
// directory under $OFFICER_ITEMS_DIR/<type>/<dirName>/ holding a single markdown file (SKILL.md /
|
||||
@@ -114,8 +115,8 @@ export function createItemRouter({ type, fileName, label }: ItemRouterConfig) {
|
||||
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
|
||||
|
||||
await mkdir(chatDir, { recursive: true });
|
||||
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
|
||||
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
|
||||
await writeAtomic(join(chatDir, 'messages.json'), JSON.stringify(messages));
|
||||
if (sessionId) await writeAtomic(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -140,7 +141,7 @@ export function createItemRouter({ type, fileName, label }: ItemRouterConfig) {
|
||||
if (await Bun.file(filePath).exists()) return ctx.text(`${label} already exists`, 409);
|
||||
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
await writeAtomic(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
|
||||
return ctx.json({ name: name.trim(), dirName, filePath });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
import { getUsers } from 'officerdb';
|
||||
import * as errors from '../../custom-errors';
|
||||
|
||||
// /api/notify/* — auth, then forward to officer-notify. The apps register their push tokens through here
|
||||
// and nothing else uses it; producers inside the tailnet POST to the sidecar over loopback directly.
|
||||
@@ -7,7 +9,68 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' });
|
||||
|
||||
/*
|
||||
POST /api/notify/broadcast — one message to every account, owner only.
|
||||
|
||||
It exists because the alternative was what actually happened on 2026-08-24: to reach four accounts at
|
||||
once, somebody found the sidecar's ephemeral port and POSTed to it directly, once per user, with no
|
||||
authentication at all. That worked, which was the problem — the same door was open to anything else on
|
||||
the machine, including an agent.
|
||||
|
||||
Teams will narrow this later. Today the choice is everyone or one person, and everyone is owner-only.
|
||||
*/
|
||||
proxy.router.post('/broadcast', async (ctx) => {
|
||||
type BroadcastBody = { title?: string; body?: string; code?: string };
|
||||
const body: BroadcastBody = await ctx.req.json<BroadcastBody>().catch(() => ({}) as BroadcastBody);
|
||||
const title = body.title?.trim();
|
||||
const message = body.body?.trim();
|
||||
if (!title || !message) throw errors.BAD_REQUEST('title and body are required');
|
||||
|
||||
const base = proxy.getHttpUrl();
|
||||
const producer = notifyProducerHeaders();
|
||||
if (!base || !producer) throw errors.createError(503, 'notify sidecar not available');
|
||||
|
||||
// Sequential, and deliberately so: this is a handful of accounts on a personal server, and one slow
|
||||
// APNs call must not turn into a hundred concurrent ones against the same host.
|
||||
const results: { userId: number; ok: boolean }[] = [];
|
||||
for (const user of await getUsers()) {
|
||||
try {
|
||||
const res = await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...producer },
|
||||
// `userId` in the BODY, with no `X-Officer-User`: this is the platform speaking about someone
|
||||
// else, which is exactly the distinction `sidecar/notify/resolve-user.ts` draws.
|
||||
body: JSON.stringify({ code: body.code ?? 'officer/core:message', userId: user.id, title, body: message }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
results.push({ userId: user.id, ok: res.ok });
|
||||
} catch {
|
||||
// One account's failure must not stop the rest — a broadcast that gives up halfway is worse than
|
||||
// one that reports which half landed.
|
||||
results.push({ userId: user.id, ok: false });
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({ sent: results.filter((r) => r.ok).length, total: results.length, results });
|
||||
});
|
||||
|
||||
export const notifyRouter = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getNotifyServerUrl = proxy.getHttpUrl;
|
||||
|
||||
/**
|
||||
* The header an in-process producer must send when it calls the sidecar directly.
|
||||
*
|
||||
* Producers inside the platform — sendoff, chat — bypass this proxy and POST over loopback, because they
|
||||
* are notifying somebody other than the caller and the proxy pins the target to whoever is signed in.
|
||||
* That path used to accept anyone who could reach the port, which on a machine running commanders is
|
||||
* anyone with a shell.
|
||||
*
|
||||
* Null before the sidecar registers, in which case the producer is right to give up rather than send
|
||||
* unauthenticated.
|
||||
*/
|
||||
export function notifyProducerHeaders(): Record<string, string> | null {
|
||||
const secret = proxy.getProducerSecret();
|
||||
return secret ? { 'X-Officer-Producer': secret } : null;
|
||||
}
|
||||
|
||||
@@ -147,6 +147,9 @@ pluginsRouter.get('/', async (ctx) => {
|
||||
version: plugin.manifest.version,
|
||||
platform: plugin.manifest.platform,
|
||||
permissions: plugin.manifest.permissions,
|
||||
// What the owner must be able to set. Declared rather than rendered by the plugin, so a backend
|
||||
// plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`.
|
||||
settings: plugin.manifest.settings ?? [],
|
||||
// What the tree declared. The UI shows these so "installed but does nothing" is legible.
|
||||
has: {
|
||||
api: !!plugin.api,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
const MAX_CONTENT_LENGTH = 100_000;
|
||||
|
||||
@@ -148,7 +149,7 @@ scrapeRouter.post('/', async (ctx) => {
|
||||
}
|
||||
|
||||
await mkdir(saveDir, { recursive: true });
|
||||
await Bun.write(join(saveDir, attachmentId), html);
|
||||
await writeAtomic(join(saveDir, attachmentId), html);
|
||||
|
||||
return ctx.json({ url, title, content, attachmentId });
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
revokeSender,
|
||||
upsertSendoffDevice,
|
||||
} from 'officerdb';
|
||||
import { getNotifyServerUrl } from '../notify/router';
|
||||
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
import { displayNameFor, folderNameFor, safeFilename, safeSegment, sendoffDirFor } from './paths';
|
||||
|
||||
/*
|
||||
@@ -78,10 +79,13 @@ function freePath(dir: string, filename: string): string {
|
||||
async function pushLanded(recipientUserId: number, folder: string, count: number): Promise<void> {
|
||||
try {
|
||||
const base = getNotifyServerUrl();
|
||||
if (!base) return;
|
||||
const producer = notifyProducerHeaders();
|
||||
// No secret means the sidecar has not registered with THIS process yet. Giving up is right: the
|
||||
// alternative is sending unauthenticated, which the sidecar now refuses anyway.
|
||||
if (!base || !producer) return;
|
||||
await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId) },
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId), ...producer },
|
||||
body: JSON.stringify({ code: 'officer/core:sendoff.landed', userId: recipientUserId, id: folder, n: count }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
@@ -149,19 +153,11 @@ sendoffRouter.post('/send', async (ctx) => {
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const written: { name: string; size: number }[] = [];
|
||||
// Aside, then rename — the same reasoning as the file browser upload, and it matters more here
|
||||
// because this is a folder things ARRIVE in unattended. Neither a recipient watching it nor the
|
||||
// notification below may point at a name that is still being written.
|
||||
// `writeAtomic` matters more here than anywhere: this is a folder things ARRIVE in unattended.
|
||||
// Neither a recipient watching it nor the notification below may point at a name still being written.
|
||||
for (const file of files) {
|
||||
const path = freePath(dir, safeFilename(file.name));
|
||||
const partPath = `${path}.part`;
|
||||
try {
|
||||
await Bun.write(partPath, await file.arrayBuffer());
|
||||
await rename(partPath, path);
|
||||
} catch (err) {
|
||||
await rm(partPath, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await writeAtomic(path, await file.arrayBuffer());
|
||||
written.push({ name: path.split('/').pop() ?? file.name, size: file.size });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AGENT_CONFIG_DIR } from '../../data-path';
|
||||
import { invalidateModelCache } from '../chat/list-models';
|
||||
import { logger } from '../chat/logger';
|
||||
import { readConfigValue, writeConfigValue } from 'officerdb';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
export const chatProvidersRouter = createRouter();
|
||||
|
||||
@@ -69,7 +70,7 @@ async function readModelsConfig(): Promise<ModelConfig> {
|
||||
}
|
||||
|
||||
async function writeModelsConfig(config: ModelConfig): Promise<void> {
|
||||
await Bun.write(MODELS_FILE, JSON.stringify(config, null, 2));
|
||||
await writeAtomic(MODELS_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,7 +343,7 @@ async function readAuthJson(): Promise<AuthJson> {
|
||||
}
|
||||
|
||||
async function writeAuthJson(auth: AuthJson): Promise<void> {
|
||||
await Bun.write(AUTH_FILE, JSON.stringify(auth, null, 2));
|
||||
await writeAtomic(AUTH_FILE, JSON.stringify(auth, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync, createWriteStream } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, createWriteStream } from 'node:fs';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getOwnerHomeDir, DATA_PATH } from '../../data-path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { killTree } from './process-tree';
|
||||
import { buildTaskApiEnv } from './task-api-env';
|
||||
import { prepareScriptRun } from './script-run';
|
||||
|
||||
// Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log
|
||||
// file); `started`/`exit`/`error` are structural and get buffered for viewer replay.
|
||||
@@ -33,41 +32,6 @@ export type ExecuteScriptParams = {
|
||||
emit: (event: ScriptEvent) => void;
|
||||
};
|
||||
|
||||
const getRunner = (language: string): string[] =>
|
||||
language === 'python'
|
||||
? ['python3']
|
||||
: language === 'typescript'
|
||||
? ['bun', 'run']
|
||||
: language === 'javascript'
|
||||
? ['node']
|
||||
: ['bash'];
|
||||
const getFileName = (language: string): string =>
|
||||
language === 'python'
|
||||
? 'run.py'
|
||||
: language === 'typescript'
|
||||
? 'index.ts'
|
||||
: language === 'javascript'
|
||||
? 'index.js'
|
||||
: 'run.sh';
|
||||
|
||||
function materializeScript(language: string, implementation: string): string {
|
||||
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const filePath = join(dir, getFileName(language));
|
||||
writeFileSync(filePath, implementation);
|
||||
chmodSync(filePath, 0o755);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const buildInputEnv = (inputs: Record<string, string>): Record<string, string> => {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(inputs)) env[`INPUT_${key.toUpperCase()}`] = value;
|
||||
return env;
|
||||
};
|
||||
|
||||
const buildArgs = (inputs: Record<string, string>, argsOrder?: string[] | null): string[] =>
|
||||
!argsOrder || argsOrder.length === 0 ? [] : argsOrder.map((name) => inputs[name] ?? '');
|
||||
|
||||
// The persisted per-job output log — served by GET /jobs/:id/log for backfill / unattended viewing.
|
||||
export const jobLogPath = (jobId: string) => join(DATA_PATH, 'jobs', `${jobId}.log`);
|
||||
|
||||
@@ -79,22 +43,16 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
|
||||
const task = await getTaskByDirName(params.taskDirName);
|
||||
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
|
||||
if (task.mode !== 'script') throw new Error('Task is not a script-mode task');
|
||||
if (!task.implementation) throw new Error(`Task ${params.taskDirName} has no implementation`);
|
||||
|
||||
const language = task.language ?? 'bash';
|
||||
const scriptPath = materializeScript(language, task.implementation);
|
||||
const inputEnv = buildInputEnv(inputs);
|
||||
const positionalArgs = buildArgs(inputs, task.args);
|
||||
const cmd = [...getRunner(language), scriptPath, ...positionalArgs];
|
||||
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : join(homeDir, params.cwd)) : homeDir;
|
||||
|
||||
const spawnCmd = cmd;
|
||||
const apiEnv = await buildTaskApiEnv({ id: userId, email, username });
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...apiEnv, ...inputEnv };
|
||||
const spawnCwd = cwd;
|
||||
// What the script sees is `script-run.ts`'s to decide, for all three runners. This one owns only how
|
||||
// the output is reported: streamed to viewers and appended to a durable log.
|
||||
const prepared = await prepareScriptRun({
|
||||
task,
|
||||
inputs,
|
||||
cwd: params.cwd,
|
||||
user: { id: userId, email, username },
|
||||
});
|
||||
const { cmd: spawnCmd, env: spawnEnv, cwd: spawnCwd, cleanup } = prepared;
|
||||
|
||||
mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true });
|
||||
const log = createWriteStream(jobLogPath(jobId), { flags: 'w' });
|
||||
@@ -115,15 +73,7 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
/* a log-stream error must never kill the pump */
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
emit({ type: 'started', taskName: task.name });
|
||||
emit({ type: 'started', taskName: prepared.taskName });
|
||||
|
||||
const proc = Bun.spawn(spawnCmd, { cwd: spawnCwd, env: spawnEnv, stdout: 'pipe', stderr: 'pipe' });
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { getTaskByDirName, type TaskRecord } from './task-files';
|
||||
import { getOwnerHomeDir } from '../../data-path';
|
||||
import { buildTaskApiEnv } from './task-api-env';
|
||||
import type { CredentialScope } from '../../auth-token';
|
||||
|
||||
/**
|
||||
* Everything a script-mode task run needs before something spawns it.
|
||||
*
|
||||
* ── Why this is its own file ──
|
||||
*
|
||||
* Three runners spawn a script task and they must agree about HOW: the ephemeral socket
|
||||
* (`task-executor.ts`), the background job (`execute-script.ts`) and the direct REST call below. Each
|
||||
* carried its own copy of the same twenty lines — materialize the implementation into a temp dir, build
|
||||
* `INPUT_<NAME>` env vars, build positional args in `task.args` order, resolve a home-relative cwd, mint
|
||||
* the API env. Identical line for line, and nothing said they had to stay that way.
|
||||
*
|
||||
* They differ in how they REPORT — streaming to a socket, appending to a durable log, or returning a
|
||||
* string — and that difference is real, so it stays with each of them. What must not differ is what the
|
||||
* script sees: a task that behaves one way from the context menu and another from a commander is a bug
|
||||
* nobody can reproduce, because both callers are "running the task".
|
||||
*/
|
||||
|
||||
const getRunner = (language: string): string[] =>
|
||||
language === 'python'
|
||||
? ['python3']
|
||||
: language === 'typescript'
|
||||
? ['bun', 'run']
|
||||
: language === 'javascript'
|
||||
? ['node']
|
||||
: ['bash'];
|
||||
|
||||
const getFileName = (language: string): string =>
|
||||
language === 'python'
|
||||
? 'run.py'
|
||||
: language === 'typescript'
|
||||
? 'index.ts'
|
||||
: language === 'javascript'
|
||||
? 'index.js'
|
||||
: 'run.sh';
|
||||
|
||||
/**
|
||||
* Only `index.ts` (or its sibling by language) is written out — a script task is materialized ALONE, so
|
||||
* it has to be self-contained. That is a contract, not an oversight: see `platform/CLAUDE.md`.
|
||||
*/
|
||||
function materializeScript(language: string, implementation: string): string {
|
||||
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const filePath = join(dir, getFileName(language));
|
||||
writeFileSync(filePath, implementation);
|
||||
chmodSync(filePath, 0o755);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export type ScriptRunUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
username?: string | null;
|
||||
/** The caller's credential narrowing, propagated to the script's own token. See `task-api-env.ts`. */
|
||||
scope?: CredentialScope | null;
|
||||
};
|
||||
|
||||
export type PreparedScriptRun = {
|
||||
taskName: string;
|
||||
cmd: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
/** Removes the temp directory. Every caller must call it, on every path out. */
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type PrepareParams = {
|
||||
task: TaskRecord;
|
||||
inputs: Record<string, string>;
|
||||
/** Home-relative or absolute. Absent means the owner's home. */
|
||||
cwd?: string;
|
||||
user: ScriptRunUser;
|
||||
};
|
||||
|
||||
export async function prepareScriptRun({ task, inputs, cwd, user }: PrepareParams): Promise<PreparedScriptRun> {
|
||||
if (task.mode !== 'script') throw new Error('Task is not a script-mode task');
|
||||
if (!task.implementation) throw new Error(`Task ${task.dirName} has no implementation`);
|
||||
|
||||
const language = task.language ?? 'bash';
|
||||
const scriptPath = materializeScript(language, task.implementation);
|
||||
|
||||
// Inputs reach the script two ways, and both are load-bearing: named, as `INPUT_<NAME>`, and
|
||||
// positionally in the order `task.args` declares.
|
||||
const inputEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(inputs)) inputEnv[`INPUT_${key.toUpperCase()}`] = value;
|
||||
const positionalArgs = task.args?.length ? task.args.map((name) => inputs[name] ?? '') : [];
|
||||
|
||||
// A relative cwd is relative to the OWNER's real home, not the managed one — that is where task runs
|
||||
// execute. A missing cwd surfaces as ENOENT naming the binary rather than the directory, which is why
|
||||
// this resolves rather than passing it through.
|
||||
const homeDir = getOwnerHomeDir(user.email);
|
||||
|
||||
return {
|
||||
taskName: task.name,
|
||||
cmd: [...getRunner(language), scriptPath, ...positionalArgs],
|
||||
cwd: cwd ? (isAbsolute(cwd) ? cwd : join(homeDir, cwd)) : homeDir,
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
...(await buildTaskApiEnv(user)),
|
||||
...inputEnv,
|
||||
},
|
||||
cleanup: () => {
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type DirectRunResult = { code: number; output: string; taskName: string };
|
||||
|
||||
/**
|
||||
* Run a script task and wait for it. No job row, no socket, no streaming.
|
||||
*
|
||||
* This is the shape a caller with no browser needs — a commander's loop, a cron entry, anything holding a
|
||||
* credential rather than a session. The two existing runners both require something to watch them: the
|
||||
* socket needs a live connection, and the job needs somebody to open Jobs to see what happened.
|
||||
*
|
||||
* stdout and stderr are interleaved into one string in arrival order, which is what a caller reading it
|
||||
* as "what happened" wants — the script's own narration and its errors in the order it produced them.
|
||||
*/
|
||||
export async function runScriptTaskDirect(params: {
|
||||
taskDirName: string;
|
||||
inputs: Record<string, string>;
|
||||
cwd?: string;
|
||||
user: ScriptRunUser;
|
||||
}): Promise<DirectRunResult> {
|
||||
const task = await getTaskByDirName(params.taskDirName);
|
||||
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
|
||||
|
||||
const prepared = await prepareScriptRun({ task, inputs: params.inputs, cwd: params.cwd, user: params.user });
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(prepared.cmd, {
|
||||
cwd: prepared.cwd,
|
||||
env: prepared.env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
let output = '';
|
||||
const decoder = new TextDecoder();
|
||||
const drain = async (stream: ReadableStream<Uint8Array>) => {
|
||||
const reader = stream.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
output += decoder.decode(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Both streams are drained CONCURRENTLY with the wait. Reading them in sequence deadlocks the moment
|
||||
// a script writes more to stderr than the pipe buffer holds while nothing is reading it.
|
||||
await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
|
||||
const code = await proc.exited;
|
||||
|
||||
return { code, output, taskName: prepared.taskName };
|
||||
} finally {
|
||||
prepared.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sign } from '@@/jwt';
|
||||
import type { CredentialScope } from '@@/auth-token';
|
||||
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
|
||||
const { PUBLIC_URL } = process.env;
|
||||
@@ -11,7 +12,21 @@ const PUBLIC_HOST = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
type TaskApiUser = { id: number; email: string; username?: string | null };
|
||||
type TaskApiUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
username?: string | null;
|
||||
/**
|
||||
* The narrowing carried by the credential that ASKED for this run, or null/absent for a full-authority
|
||||
* caller (a browser session, the context menu, a job started by hand).
|
||||
*
|
||||
* It has to propagate or the narrowing is decorative. A commander holding a key limited to `tasks` can
|
||||
* write a task and run it; if the token handed to that script were unnarrowed, the script would come
|
||||
* back through the API with the owner's full authority and the limit would have bought nothing. The
|
||||
* script gets exactly what its caller had — never more.
|
||||
*/
|
||||
scope?: CredentialScope | null;
|
||||
};
|
||||
|
||||
// Environment that lets a script task call Officer's own API instead of reimplementing server-side
|
||||
// work (transcription, for one — `plugins/transcribe/tasks/transcribe-audio-file/index.ts` uses exactly
|
||||
@@ -24,7 +39,17 @@ type TaskApiUser = { id: number; email: string; username?: string | null };
|
||||
// curl -H "Authorization: Bearer $OFFICER_AUTH_TOKEN" -H "Host: $OFFICER_API_HOST" \
|
||||
// "$OFFICER_API_URL/api/transcribe/transcribe"
|
||||
export async function buildTaskApiEnv(user: TaskApiUser): Promise<Record<string, string>> {
|
||||
const token = await sign({ id: user.id, email: user.email, username: user.username ?? '' }, '12h');
|
||||
const token = await sign(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username ?? '',
|
||||
// Only when narrowed. An absent claim is what every ordinary run already signs, and `resolveAuthToken`
|
||||
// spreads the payload onto the identity, so this reaches both doors with no further wiring.
|
||||
...(user.scope ? { scope: user.scope } : {}),
|
||||
},
|
||||
'12h',
|
||||
);
|
||||
return {
|
||||
OFFICER_API_URL,
|
||||
OFFICER_API_HOST: PUBLIC_HOST ?? `127.0.0.1:${PORT}`,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getOwnerHomeDir } from '../../data-path';
|
||||
import { buildTaskApiEnv } from './task-api-env';
|
||||
import { prepareScriptRun } from './script-run';
|
||||
|
||||
type WSData = {
|
||||
userId: number;
|
||||
@@ -94,123 +92,38 @@ function killTree(root: number) {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function getRunner(language: string): string[] {
|
||||
switch (language) {
|
||||
case 'bash':
|
||||
return ['bash'];
|
||||
case 'python':
|
||||
return ['python3'];
|
||||
case 'typescript':
|
||||
return ['bun', 'run'];
|
||||
case 'javascript':
|
||||
return ['node'];
|
||||
default:
|
||||
return ['bash'];
|
||||
}
|
||||
}
|
||||
|
||||
function getFileName(language: string): string {
|
||||
switch (language) {
|
||||
case 'bash':
|
||||
return 'run.sh';
|
||||
case 'python':
|
||||
return 'run.py';
|
||||
case 'typescript':
|
||||
return 'index.ts';
|
||||
case 'javascript':
|
||||
return 'index.js';
|
||||
default:
|
||||
return 'run.sh';
|
||||
}
|
||||
}
|
||||
|
||||
// Write implementation to a temp file for execution, cleaned up after
|
||||
function materializeScript(language: string, implementation: string): string {
|
||||
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const fileName = getFileName(language);
|
||||
const filePath = join(dir, fileName);
|
||||
|
||||
writeFileSync(filePath, implementation);
|
||||
chmodSync(filePath, 0o755);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function buildInputEnv(inputs: Record<string, string>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
env[`INPUT_${key.toUpperCase()}`] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function buildArgs(inputs: Record<string, string>, argsOrder?: string[] | null): string[] {
|
||||
if (!argsOrder || argsOrder.length === 0) return [];
|
||||
return argsOrder.map((name) => inputs[name] ?? '');
|
||||
}
|
||||
|
||||
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||
const { userId, email, username } = ws.data;
|
||||
|
||||
// Resolve task from the file-backed store
|
||||
const task = await getTaskByDirName(msg.taskDirName);
|
||||
if (!task) {
|
||||
send(ws, { type: 'error', message: `Task not found: ${msg.taskDirName}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.mode !== 'script') {
|
||||
send(ws, { type: 'error', message: 'Task is not a script-mode task' });
|
||||
// What the script sees comes from `script-run.ts`, shared with the job runner and the direct REST call
|
||||
// — a task must not behave differently depending on which door ran it. This file owns only the
|
||||
// streaming: output to the socket, a keep-alive ping, and a kill that reaches the whole tree.
|
||||
let prepared;
|
||||
try {
|
||||
prepared = await prepareScriptRun({
|
||||
task,
|
||||
inputs: msg.inputs,
|
||||
cwd: msg.cwd,
|
||||
user: { id: userId, email, username },
|
||||
});
|
||||
} catch (err) {
|
||||
send(ws, { type: 'error', message: err instanceof Error ? err.message : 'Task cannot run' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!task.implementation) {
|
||||
send(ws, { type: 'error', message: `Task ${msg.taskDirName} has no implementation` });
|
||||
return;
|
||||
}
|
||||
const { cmd: spawnCmd, env: spawnEnv, cwd: spawnCwd, cleanup } = prepared;
|
||||
|
||||
const language = task.language ?? 'bash';
|
||||
|
||||
// Write script to temp dir for execution
|
||||
const scriptPath = materializeScript(language, task.implementation);
|
||||
|
||||
// Build env vars from inputs
|
||||
const inputEnv = buildInputEnv(msg.inputs);
|
||||
|
||||
// Build positional args
|
||||
const positionalArgs = buildArgs(msg.inputs, task.args);
|
||||
|
||||
// Build the command
|
||||
const runner = getRunner(language);
|
||||
const cmd = [...runner, scriptPath, ...positionalArgs];
|
||||
|
||||
// msg.cwd arrives from the file browser relative to the user's home; Bun.spawn needs it absolute
|
||||
// (a missing cwd surfaces as ENOENT naming the binary, not the directory)
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir;
|
||||
|
||||
const spawnCmd = cmd;
|
||||
const apiEnv = await buildTaskApiEnv({ id: userId, email, username });
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...apiEnv, ...inputEnv };
|
||||
const spawnCwd = cwd;
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
send(ws, { type: 'started', taskName: task.name });
|
||||
send(ws, { type: 'started', taskName: prepared.taskName });
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(spawnCmd, {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { listTasks, getTaskByDirName } from './task-files';
|
||||
|
||||
/**
|
||||
* A plugin's tasks are read where they live (docs/plugin-capabilities.md), and the whole reason it lands
|
||||
* in `task-files.ts` is that every runner resolves a task through these two functions. So these pin the
|
||||
* properties the file browser's context menu depends on — a plugin's task is listed, is resolvable by the
|
||||
* same directory name a menu click sends back, and carries its implementation.
|
||||
*
|
||||
* Sources are injected: resolving them for real reads the database and the plugin directory, which would
|
||||
* make this assert whatever this machine happens to have installed.
|
||||
*/
|
||||
|
||||
const seed = (files: Record<string, string>): string => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'zz-tasks-'));
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
const abs = join(dir, rel);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, body);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const taskMd = (name: string, extra = '') =>
|
||||
`---\nname: ${name}\nmode: script\nlanguage: typescript\n${extra}---\n\nbody\n`;
|
||||
|
||||
describe('tasks across sources', () => {
|
||||
test("a plugin's task is listed and carries its provenance", async () => {
|
||||
const owner = seed({ 'mine/TASK.md': taskMd('Mine') });
|
||||
const plugin = seed({ 'transcribe-audio-file/TASK.md': taskMd('Transcribe Audio File') });
|
||||
try {
|
||||
const found = await listTasks([
|
||||
{ dir: owner, plugin: null },
|
||||
{ dir: plugin, plugin: 'transcribe' },
|
||||
]);
|
||||
expect(found.map((t) => [t.dirName, t.plugin])).toEqual([
|
||||
['mine', null],
|
||||
['transcribe-audio-file', 'transcribe'],
|
||||
]);
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The context menu sends back a directory name and nothing else, so resolution has to find the plugin's
|
||||
// copy by that name alone — otherwise the task is listed and then "not found" the moment it is clicked.
|
||||
test("a plugin's task resolves by directory name, with its implementation", async () => {
|
||||
const owner = seed({ 'mine/TASK.md': taskMd('Mine') });
|
||||
const plugin = seed({
|
||||
'transcribe-audio-file/TASK.md': taskMd('Transcribe Audio File'),
|
||||
'transcribe-audio-file/index.ts': 'export const ok = true;\n',
|
||||
});
|
||||
try {
|
||||
const sources = [
|
||||
{ dir: owner, plugin: null },
|
||||
{ dir: plugin, plugin: 'transcribe' },
|
||||
];
|
||||
const task = await getTaskByDirName('transcribe-audio-file', sources);
|
||||
expect(task?.plugin).toBe('transcribe');
|
||||
expect(task?.implementation).toBe('export const ok = true;\n');
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Same precedence as skills: a plugin cannot take over a name its owner is already using.
|
||||
test("the owner's own wins a name collision", async () => {
|
||||
const owner = seed({ 'deploy/TASK.md': taskMd('Deploy (owner)') });
|
||||
const plugin = seed({ 'deploy/TASK.md': taskMd('Deploy (plugin)') });
|
||||
try {
|
||||
const sources = [
|
||||
{ dir: owner, plugin: null },
|
||||
{ dir: plugin, plugin: 'somebody' },
|
||||
];
|
||||
expect(await listTasks(sources)).toHaveLength(1);
|
||||
const task = await getTaskByDirName('deploy', sources);
|
||||
expect(task?.name).toBe('Deploy (owner)');
|
||||
expect(task?.plugin).toBeNull();
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// A directory name arrives from a websocket message and a job row before it is joined onto a path.
|
||||
test('a traversing directory name resolves to nothing', async () => {
|
||||
const owner = seed({ 'mine/TASK.md': taskMd('Mine') });
|
||||
try {
|
||||
const sources = [{ dir: join(owner, 'nested'), plugin: null }];
|
||||
expect(await getTaskByDirName('../mine', sources)).toBeNull();
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `inputs` is a LIST in TASK.md and a map everywhere else.
|
||||
*
|
||||
* The app's own task-creation guide teaches the list form, so agents write it; every consumer reads the
|
||||
* map. A list reached the runner as `Object.entries(array)` — key "0" — so the input rendered nameless,
|
||||
* never matched what the file browser auto-fills, and ran empty. That is exactly what the transcribe
|
||||
* plugin's task did the first time it was clicked.
|
||||
*/
|
||||
describe('input normalisation', () => {
|
||||
const withInputs = (yaml: string) => `---\nname: T\nmode: script\n${yaml}---\n\nbody\n`;
|
||||
|
||||
test('the list form becomes a map keyed by name', async () => {
|
||||
const owner = seed({
|
||||
't/TASK.md': withInputs('inputs:\n - name: file_path\n description: the file\n required: true\n'),
|
||||
});
|
||||
try {
|
||||
const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]);
|
||||
expect(task?.inputs).toEqual({ file_path: { description: 'the file', required: true } });
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the map form passes through untouched', async () => {
|
||||
const owner = seed({ 't/TASK.md': withInputs('inputs:\n file_path:\n description: the file\n') });
|
||||
try {
|
||||
const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]);
|
||||
expect(task?.inputs).toEqual({ file_path: { description: 'the file' } });
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// No name means no INPUT_<NAME> env var and no form key — there is nothing to call it.
|
||||
test('an entry with no name is dropped', async () => {
|
||||
const owner = seed({ 't/TASK.md': withInputs('inputs:\n - description: nameless\n') });
|
||||
try {
|
||||
const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]);
|
||||
expect(task?.inputs).toBeNull();
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,29 @@
|
||||
import { readdir, mkdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { itemsDir, OFFICER_ITEMS_DIR } from '../../data-path';
|
||||
import { capabilitySources, type CapabilitySource } from '../../capability-sources';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
// File-backed task store. Every task is a directory under $OFFICER_ITEMS_DIR/tasks/<dirName>/ with a
|
||||
// TASK.md (metadata + prose body) and, for script-mode tasks, a sibling implementation file. There are
|
||||
// no scope tiers and no database — the directory name is the task's identity.
|
||||
//
|
||||
// ── Plus every enabled plugin's own tasks/ directory ──
|
||||
//
|
||||
// Read where they live, never copied in — the same rule as skills and affordances, and the same reasons
|
||||
// (`capability-sources.ts`, `docs/plugin-capabilities.md`). It lands here rather than in each caller
|
||||
// because EVERY execution path goes through `getTaskByDirName`: the context menu, the runner sockets,
|
||||
// the job queue and the pipeline executor all resolve a task by its directory name and then read
|
||||
// `implementation` off the record. Teaching this one function about plugin sources makes a plugin's task
|
||||
// runnable everywhere at once, and leaves the identity — the directory name — exactly as it was.
|
||||
//
|
||||
// The owner's own store is searched FIRST, so a name the owner is already using cannot be taken over by
|
||||
// a plugin that happened to pick the same directory name.
|
||||
//
|
||||
// Writing is a different matter: create, update and delete only ever touch the owner's store, and refuse
|
||||
// a task that belongs to a plugin. A plugin's task lives in a git checkout that `plugins:update` pulls —
|
||||
// editing it in place would be overwritten by the next update, and deleting it would make the plugin's
|
||||
// own files disappear from a screen that says nothing about plugins.
|
||||
|
||||
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
|
||||
|
||||
@@ -34,6 +53,8 @@ export type TaskRecord = TaskFrontmatter & {
|
||||
body: string;
|
||||
implementation: string | null;
|
||||
filePath: string;
|
||||
/** The plugin that ships it, or null when it is the owner's own. Read-only when set. */
|
||||
plugin: string | null;
|
||||
};
|
||||
|
||||
export type TaskSummary = {
|
||||
@@ -45,6 +66,10 @@ export type TaskSummary = {
|
||||
inline: boolean | 'ask';
|
||||
version: number;
|
||||
trigger: unknown;
|
||||
plugin: string | null;
|
||||
/** Normalised to a map keyed by input name. Carried in the list so a caller can tell, without a second
|
||||
* request, whether running this task needs anything from the user. */
|
||||
inputs: unknown;
|
||||
};
|
||||
|
||||
// Frontmatter keys, in write order. These mirror the old `tasks` table columns 1:1.
|
||||
@@ -83,6 +108,32 @@ const taskFile = (dirName: string) => join(taskDir(dirName), 'TASK.md');
|
||||
|
||||
const asArray = (v: unknown): string[] | null => (Array.isArray(v) ? v.map(String) : null);
|
||||
|
||||
/**
|
||||
* `inputs` in TASK.md is a LIST of `{ name, … }`. Everything downstream wants a map keyed by name.
|
||||
*
|
||||
* Both shapes are in the wild because the app's own task-creation guide teaches the list form while
|
||||
* every consumer — the runner modal, the agent runner, `execute-script`'s `INPUT_<NAME>` env vars —
|
||||
* reads the map. A list reached the form as `Object.entries(array)`, so the key was "0": the input
|
||||
* rendered with no name, was never matched against what the file browser auto-fills, and ran with an
|
||||
* empty value. Normalising here fixes every consumer at once, and the map form still passes through.
|
||||
*/
|
||||
function normalizeInputs(raw: unknown): Record<string, Record<string, unknown>> | null {
|
||||
if (!raw) return null;
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
const map: Record<string, Record<string, unknown>> = {};
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const { name, ...rest } = entry as Record<string, unknown>;
|
||||
if (typeof name !== 'string' || !name) continue; // no name, no way to pass it to the script
|
||||
map[name] = rest;
|
||||
}
|
||||
return Object.keys(map).length ? map : null;
|
||||
}
|
||||
|
||||
return typeof raw === 'object' ? (raw as Record<string, Record<string, unknown>>) : null;
|
||||
}
|
||||
|
||||
export const slugify = (name: string) =>
|
||||
name
|
||||
.trim()
|
||||
@@ -120,7 +171,7 @@ function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } {
|
||||
tags: asArray(parsed.tags),
|
||||
tools: asArray(parsed.tools),
|
||||
skills: asArray(parsed.skills),
|
||||
inputs: parsed.inputs ?? null,
|
||||
inputs: normalizeInputs(parsed.inputs),
|
||||
outputs: parsed.outputs ?? null,
|
||||
dependencies: parsed.dependencies ?? null,
|
||||
config: parsed.config ?? null,
|
||||
@@ -145,9 +196,8 @@ function buildTaskMd(fm: Partial<TaskFrontmatter>, body: string): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function readImplementation(dirName: string, language: string | null): Promise<string | null> {
|
||||
const implPath = join(taskDir(dirName), implFileName(language));
|
||||
const file = Bun.file(implPath);
|
||||
async function readImplementation(dir: string, language: string | null): Promise<string | null> {
|
||||
const file = Bun.file(join(dir, implFileName(language)));
|
||||
return (await file.exists()) ? file.text() : null;
|
||||
}
|
||||
|
||||
@@ -170,42 +220,64 @@ export async function readCategoryOrder(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTasks(): Promise<TaskSummary[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(tasksRoot(), { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
/**
|
||||
* Every task on offer, the owner's own first.
|
||||
*
|
||||
* `sources` is injectable so this can be tested against temporary directories — resolving them for real
|
||||
* reads the database and the plugin directory, which would make a test assert whatever this machine
|
||||
* happens to have installed.
|
||||
*/
|
||||
export async function listTasks(sources?: CapabilitySource[]): Promise<TaskSummary[]> {
|
||||
const roots = sources ?? (await capabilitySources('tasks'));
|
||||
|
||||
const byDirName = new Map<string, TaskSummary>();
|
||||
for (const source of roots) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(source.dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue; // most plugins ship no tasks at all — the ordinary case, not a fault
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || byDirName.has(entry.name)) continue;
|
||||
const file = Bun.file(join(source.dir, entry.name, 'TASK.md'));
|
||||
if (!(await file.exists())) continue;
|
||||
const { fm } = parseTaskMd(await file.text());
|
||||
byDirName.set(entry.name, {
|
||||
dirName: entry.name,
|
||||
name: fm.name || entry.name,
|
||||
description: fm.description,
|
||||
category: fm.category,
|
||||
mode: fm.mode,
|
||||
inline: fm.inline,
|
||||
version: fm.version,
|
||||
trigger: fm.trigger ?? [],
|
||||
plugin: source.plugin,
|
||||
inputs: fm.inputs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summaries: TaskSummary[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const file = Bun.file(taskFile(entry.name));
|
||||
if (!(await file.exists())) continue;
|
||||
const { fm } = parseTaskMd(await file.text());
|
||||
summaries.push({
|
||||
dirName: entry.name,
|
||||
name: fm.name || entry.name,
|
||||
description: fm.description,
|
||||
category: fm.category,
|
||||
mode: fm.mode,
|
||||
inline: fm.inline,
|
||||
version: fm.version,
|
||||
trigger: fm.trigger ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return summaries;
|
||||
return [...byDirName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function getTaskByDirName(dirName: string): Promise<TaskRecord | null> {
|
||||
const file = Bun.file(taskFile(dirName));
|
||||
if (!(await file.exists())) return null;
|
||||
const { fm, body } = parseTaskMd(await file.text());
|
||||
const implementation = await readImplementation(dirName, fm.language);
|
||||
return { ...fm, dirName, body, implementation, filePath: taskFile(dirName) };
|
||||
export async function getTaskByDirName(dirName: string, sources?: CapabilitySource[]): Promise<TaskRecord | null> {
|
||||
// A directory name reaches this from a websocket message and a job row, so it is caller-supplied and
|
||||
// gets joined onto a path. Traversal would otherwise read any TASK.md on the disk.
|
||||
if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName === '.' || dirName === '..') return null;
|
||||
|
||||
const roots = sources ?? (await capabilitySources('tasks'));
|
||||
for (const source of roots) {
|
||||
const dir = join(source.dir, dirName);
|
||||
const filePath = join(dir, 'TASK.md');
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) continue;
|
||||
const { fm, body } = parseTaskMd(await file.text());
|
||||
const implementation = await readImplementation(dir, fm.language);
|
||||
return { ...fm, dirName, body, implementation, filePath, plugin: source.plugin };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type CreateTaskInput = {
|
||||
@@ -225,11 +297,11 @@ export async function createTask(input: CreateTaskInput): Promise<TaskSummary &
|
||||
const language = input.language ?? null;
|
||||
|
||||
await mkdir(taskDir(dirName), { recursive: true });
|
||||
await Bun.write(
|
||||
await writeAtomic(
|
||||
taskFile(dirName),
|
||||
buildTaskMd({ name, description: input.description ?? null, version: 1, mode, language }, ''),
|
||||
);
|
||||
if (mode === 'script') await Bun.write(join(taskDir(dirName), implFileName(language)), '');
|
||||
if (mode === 'script') await writeAtomic(join(taskDir(dirName), implFileName(language)), '');
|
||||
|
||||
return {
|
||||
dirName,
|
||||
@@ -240,6 +312,8 @@ export async function createTask(input: CreateTaskInput): Promise<TaskSummary &
|
||||
inline: false,
|
||||
version: 1,
|
||||
trigger: [],
|
||||
plugin: null,
|
||||
inputs: null,
|
||||
filePath: taskFile(dirName),
|
||||
};
|
||||
}
|
||||
@@ -250,19 +324,28 @@ export async function updateTask(
|
||||
): Promise<TaskRecord | null> {
|
||||
const existing = await getTaskByDirName(dirName);
|
||||
if (!existing) return null;
|
||||
if (existing.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and is read-only`);
|
||||
|
||||
const merged: TaskFrontmatter = { ...existing, ...patch };
|
||||
const body = patch.body ?? existing.body;
|
||||
await Bun.write(taskFile(dirName), buildTaskMd(merged, body));
|
||||
await writeAtomic(taskFile(dirName), buildTaskMd(merged, body));
|
||||
|
||||
if (patch.implementation !== undefined && patch.implementation !== null) {
|
||||
await Bun.write(join(taskDir(dirName), implFileName(merged.language)), patch.implementation);
|
||||
await writeAtomic(join(taskDir(dirName), implFileName(merged.language)), patch.implementation);
|
||||
}
|
||||
|
||||
return getTaskByDirName(dirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only ever the owner's own copy.
|
||||
*
|
||||
* Without the guard this was quietly a no-op on a plugin's task — `rm -rf` on a path in the owner's store
|
||||
* that does not exist, followed by a cheerful `{ ok: true }` and a task still in the menu.
|
||||
*/
|
||||
export async function deleteTask(dirName: string): Promise<void> {
|
||||
const existing = await getTaskByDirName(dirName);
|
||||
if (existing?.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and cannot be deleted`);
|
||||
await rm(taskDir(dirName), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -273,8 +356,8 @@ export async function importTask(
|
||||
record: Partial<TaskFrontmatter> & { body?: string; implementation?: string | null },
|
||||
): Promise<void> {
|
||||
await mkdir(taskDir(dirName), { recursive: true });
|
||||
await Bun.write(taskFile(dirName), buildTaskMd(record, record.body ?? ''));
|
||||
await writeAtomic(taskFile(dirName), buildTaskMd(record, record.body ?? ''));
|
||||
if (record.implementation != null && record.implementation !== '') {
|
||||
await Bun.write(join(taskDir(dirName), implFileName(record.language ?? null)), record.implementation);
|
||||
await writeAtomic(join(taskDir(dirName), implFileName(record.language ?? null)), record.implementation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '../../custom-errors';
|
||||
import { listTasks, getTaskByDirName, createTask, deleteTask, readCategoryOrder } from './task-files';
|
||||
import { runScriptTaskDirect } from './script-run';
|
||||
|
||||
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
|
||||
|
||||
@@ -15,6 +17,9 @@ tasksRouter.get('/', async (ctx) => {
|
||||
category: row.category,
|
||||
triggers: (row.trigger as TriggerConfig[]) ?? [],
|
||||
mode: row.mode ?? 'agentic',
|
||||
plugin: row.plugin,
|
||||
inline: row.inline,
|
||||
inputs: row.inputs,
|
||||
}));
|
||||
|
||||
return ctx.json(tasks);
|
||||
@@ -47,9 +52,59 @@ tasksRouter.get('/:name', async (ctx) => {
|
||||
config: task.config,
|
||||
version: task.version,
|
||||
filePath: task.filePath,
|
||||
plugin: task.plugin,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /tasks/:name/run — run a script task, wait for it, return what it did.
|
||||
*
|
||||
* The entry point for a caller with no browser. Both existing runners need something watching them: the
|
||||
* socket needs a live connection to stream into, and a job needs somebody to open Jobs and read it. A
|
||||
* commander's loop has neither — it holds a credential, makes one call, and acts on the answer.
|
||||
*
|
||||
* Deliberately NOT a job. Transcribing a memo takes seconds and produces a file; wrapping it in a queue,
|
||||
* a row and a progress bar puts a screen nobody opened between the request and the result.
|
||||
*
|
||||
* Synchronous, so the caller's own timeout is the bound on how long a task may take. That is the honest
|
||||
* shape for "do this and tell me what happened" — a task that needs to outlive its caller should be a
|
||||
* job, and `POST /jobs` already is one.
|
||||
*/
|
||||
tasksRouter.post('/:name/run', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const task = await getTaskByDirName(name);
|
||||
if (!task) throw errors.NOT_FOUND(`Task not found: ${name}`);
|
||||
if (task.mode !== 'script') throw errors.BAD_REQUEST(`Task mode '${task.mode}' cannot run directly`);
|
||||
|
||||
type RunBody = { inputs?: Record<string, string>; cwd?: string };
|
||||
// A run with no inputs is a legitimate call, so an absent or unparseable body is an empty one rather
|
||||
// than a 400 — the task's own required-input check is the place that knows what it needs.
|
||||
const body: RunBody = await ctx.req.json<RunBody>().catch(() => ({}) as RunBody);
|
||||
|
||||
try {
|
||||
const { code, output, taskName } = await runScriptTaskDirect({
|
||||
taskDirName: name,
|
||||
inputs: body.inputs ?? {},
|
||||
cwd: body.cwd,
|
||||
// The script inherits the caller's narrowing, or the absence of one. Without this a narrowed
|
||||
// credential could write a task and run it back at the API with full authority.
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username ?? '',
|
||||
scope: ctx.get('credentialScope'),
|
||||
},
|
||||
});
|
||||
// A non-zero exit is reported, not thrown: the script ran, and its output is the useful part of the
|
||||
// answer either way. HTTP 200 means "the run happened"; `code` means "it worked".
|
||||
return ctx.json({ ok: code === 0, code, output, taskName });
|
||||
} catch (err) {
|
||||
throw errors.BAD_REQUEST(err instanceof Error ? err.message : 'Task failed to start');
|
||||
}
|
||||
});
|
||||
|
||||
tasksRouter.post('/', async (ctx) => {
|
||||
const body = await ctx.req.json<{ name: string; description?: string; mode?: string; language?: string }>();
|
||||
|
||||
@@ -74,6 +129,8 @@ tasksRouter.delete('/:name', async (ctx) => {
|
||||
|
||||
const task = await getTaskByDirName(name);
|
||||
if (!task) return ctx.text('Not found', 404);
|
||||
// A plugin's task is its own file in its own checkout. Uninstalling the plugin is how it goes away.
|
||||
if (task.plugin) return ctx.text(`${name} belongs to the ${task.plugin} plugin — uninstall it instead`, 409);
|
||||
|
||||
await deleteTask(task.dirName);
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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 });
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { bodyOf, frontmatter, patchFrontmatter } from './todo-tree';
|
||||
|
||||
const FILE = `---
|
||||
id: fleet/an-item
|
||||
title: "An item, with: punctuation"
|
||||
theme: fleet
|
||||
status: open
|
||||
created: 2026-08-21
|
||||
order: 2
|
||||
---
|
||||
|
||||
**The body.** Hand-written prose with --- inside it.
|
||||
|
||||
- a list
|
||||
`;
|
||||
|
||||
describe('frontmatter', () => {
|
||||
test('reads plain and JSON-quoted values', () => {
|
||||
const fm = frontmatter(FILE);
|
||||
expect(fm.id).toBe('fleet/an-item');
|
||||
expect(fm.title).toBe('An item, with: punctuation');
|
||||
expect(fm.order).toBe('2');
|
||||
});
|
||||
|
||||
test('accepts prettier-style single quotes', () => {
|
||||
expect(frontmatter(`---\ntitle: 'A quoted title'\n---\n`).title).toBe('A quoted title');
|
||||
});
|
||||
|
||||
test('no frontmatter → empty', () => {
|
||||
expect(frontmatter('just prose')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyOf', () => {
|
||||
test('returns everything after the fence, trimmed of leading blank lines', () => {
|
||||
expect(bodyOf(FILE).startsWith('**The body.**')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchFrontmatter', () => {
|
||||
test('replaces an existing key in place', () => {
|
||||
const out = patchFrontmatter(FILE, { status: 'done' });
|
||||
expect(frontmatter(out).status).toBe('done');
|
||||
expect(out.match(/^status:/gm)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('appends a key that was absent', () => {
|
||||
const out = patchFrontmatter(FILE, { priority: 'urgent' });
|
||||
expect(frontmatter(out).priority).toBe('urgent');
|
||||
});
|
||||
|
||||
test('never touches the body — byte for byte', () => {
|
||||
const out = patchFrontmatter(FILE, { status: 'doing', order: '9' });
|
||||
expect(bodyOf(out)).toBe(bodyOf(FILE));
|
||||
});
|
||||
|
||||
test('a --- inside the body is not mistaken for the fence', () => {
|
||||
const out = patchFrontmatter(FILE, { status: 'done' });
|
||||
expect(out).toContain('with --- inside it');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// The todo tree: `todo/<theme>/<item>.md`, one item per file, frontmatter + hand-written prose.
|
||||
// `todo/README.md` is the format's contract; the parts that matter here:
|
||||
//
|
||||
// - `id` (`<theme>/<slug>`) is the identity and the filename. Everything keys on it.
|
||||
// - Frontmatter is the machine's half. The BODY IS THE OWNER'S and is never rewritten by code —
|
||||
// `patchFrontmatter` below splices keys into the frontmatter block and must not touch a byte past
|
||||
// the closing fence.
|
||||
// - `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`
|
||||
// router both import it, so the board and the generated index cannot disagree about what the tree holds.
|
||||
|
||||
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
|
||||
const todoDir = () => resolve(process.cwd(), 'todo');
|
||||
|
||||
export const TODO_STATUSES = ['open', 'doing', 'blocked', 'backburner', 'done'] as const;
|
||||
export type TodoStatus = (typeof TODO_STATUSES)[number];
|
||||
|
||||
export type TodoItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
theme: string;
|
||||
status: string;
|
||||
created: string;
|
||||
/** `urgent` or empty — deliberately no scale, see todo/README.md. */
|
||||
priority: string;
|
||||
due: string;
|
||||
order: number;
|
||||
file: string;
|
||||
};
|
||||
|
||||
export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] };
|
||||
|
||||
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
|
||||
export function frontmatter(text: string): Record<string, string> {
|
||||
const end = text.indexOf('\n---', 4);
|
||||
if (!text.startsWith('---') || end === -1) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.slice(4, end).split('\n')) {
|
||||
const m = line.match(/^([a-z_]+):\s*(.*)$/);
|
||||
if (!m || !m[1]) continue;
|
||||
const raw = (m[2] ?? '').trim();
|
||||
out[m[1]] = raw.startsWith('"') || raw.startsWith("'") ? (JSON.parse(normalizeQuotes(raw)) as string) : raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Prettier rewrites the frontmatter's double quotes to single, which JSON.parse refuses. Both mean the
|
||||
// same string; accept both rather than fighting the formatter.
|
||||
const normalizeQuotes = (raw: string) =>
|
||||
raw.startsWith("'") ? JSON.stringify(raw.slice(1, -1).replace(/\\'/g, "'")) : raw;
|
||||
|
||||
/** The body: everything after the frontmatter block's closing fence and its trailing blank lines. */
|
||||
export function bodyOf(text: string): string {
|
||||
const end = text.indexOf('\n---', 4);
|
||||
if (!text.startsWith('---') || end === -1) return text;
|
||||
return text.slice(end + 4).replace(/^\n+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice `updates` into the frontmatter block, touching nothing else — the body is hand-written prose
|
||||
* and a rewrite would produce a diff nobody can read. An existing key is replaced in place; a new one
|
||||
* is appended just before the closing fence. Returns the text unchanged if there is no frontmatter.
|
||||
*/
|
||||
export function patchFrontmatter(text: string, updates: Record<string, string>): string {
|
||||
const end = text.indexOf('\n---', 4);
|
||||
if (!text.startsWith('---') || end === -1) return text;
|
||||
let block = text.slice(4, end);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const line = `${key}: ${value}`;
|
||||
const re = new RegExp(`^${key}:.*$`, 'm');
|
||||
block = re.test(block) ? block.replace(re, line) : `${block}\n${line}`;
|
||||
}
|
||||
return `---\n${block}${text.slice(end)}`;
|
||||
}
|
||||
|
||||
export function readTree(): { themes: TodoTheme[] } {
|
||||
const root = todoDir();
|
||||
const themes = readdirSync(root)
|
||||
.filter((d) => statSync(join(root, d)).isDirectory())
|
||||
.sort()
|
||||
.map((theme) => {
|
||||
const files = readdirSync(join(root, theme)).filter((f) => f.endsWith('.md'));
|
||||
const indexText = readFileSync(join(root, theme, '_index.md'), 'utf8');
|
||||
const meta = frontmatter(indexText);
|
||||
const items = files
|
||||
.filter((f) => f !== '_index.md')
|
||||
.map((f) => {
|
||||
const fm = frontmatter(readFileSync(join(root, theme, f), 'utf8'));
|
||||
return {
|
||||
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
|
||||
title: fm.title ?? f,
|
||||
theme,
|
||||
status: fm.status ?? 'open',
|
||||
created: fm.created ?? '',
|
||||
priority: fm.priority ?? '',
|
||||
due: fm.due ?? '',
|
||||
order: Number(fm.order ?? 0),
|
||||
file: `todo/${theme}/${f}`,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return { theme, heading: meta.heading ?? theme, note: bodyOf(indexText).trim(), items };
|
||||
});
|
||||
return { themes };
|
||||
}
|
||||
|
||||
const HEADER = `# TODO — the index
|
||||
|
||||
Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map
|
||||
over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here.
|
||||
Format and the two-way-sync contract: \`todo/README.md\`. Browse and edit it at \`/todos\` in the app.
|
||||
|
||||
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
|
||||
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
||||
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
|
||||
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
|
||||
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
|
||||
deleting it have been removed rather than left to mislead the next reader.
|
||||
|
||||
What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as
|
||||
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
||||
`;
|
||||
|
||||
export function renderIndex(): string {
|
||||
const { themes } = readTree();
|
||||
const all = themes.flatMap((t) => t.items);
|
||||
const open = all.filter((i) => i.status !== 'done').length;
|
||||
const done = all.length - open;
|
||||
|
||||
const urgent = all
|
||||
.filter((i) => i.status !== 'done' && i.priority === 'urgent')
|
||||
.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
|
||||
.map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`);
|
||||
|
||||
const rows = themes
|
||||
.filter((t) => t.items.length)
|
||||
.map((t) => {
|
||||
const o = t.items.filter((i) => i.status !== 'done').length;
|
||||
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${t.items.length - o} |`;
|
||||
});
|
||||
|
||||
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode the
|
||||
// whole split risks. List them explicitly rather than letting them age out of view.
|
||||
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
|
||||
|
||||
const recent = all
|
||||
.filter((i) => i.status !== 'done')
|
||||
.sort((a, b) => b.created.localeCompare(a.created))
|
||||
.slice(0, 10)
|
||||
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
|
||||
|
||||
return [
|
||||
HEADER,
|
||||
...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []),
|
||||
`## Themes\n`,
|
||||
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
|
||||
`| Theme | Open | Closed |`,
|
||||
`| --- | --- | --- |`,
|
||||
...rows,
|
||||
``,
|
||||
`## Records — no items, kept for the reasoning\n`,
|
||||
...notes,
|
||||
``,
|
||||
`## Newest ten\n`,
|
||||
...recent,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function writeIndex(): void {
|
||||
writeFileSync(resolve(process.cwd(), 'TODO.md'), renderIndex());
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createRouter } from '../../create-router';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
/** PDFs never enter a prompt whole — the agent reads them from disk — so the ceiling is storage, not context. */
|
||||
@@ -49,8 +50,7 @@ uploadRouter.post('/', async (ctx) => {
|
||||
saveDir = getTmpAttachmentsDir(user.email);
|
||||
}
|
||||
|
||||
await mkdir(saveDir, { recursive: true });
|
||||
await Bun.write(join(saveDir, filename), buffer);
|
||||
await writeAtomic(join(saveDir, filename), buffer);
|
||||
|
||||
// Only an image gets a data URL. Building one for a PDF would mean carrying megabytes through the
|
||||
// browser for a preview nothing renders.
|
||||
|
||||
@@ -68,8 +68,33 @@ export type AuthIdentity = {
|
||||
iat?: number;
|
||||
/** JWT only, seconds since the epoch. */
|
||||
exp?: number;
|
||||
/**
|
||||
* How this credential is narrowed, or null/absent for one carrying its holder's full authority — which
|
||||
* is every session JWT and every key a human has minted.
|
||||
*
|
||||
* Carried on the identity rather than looked up again by the gate, because the key row was already read
|
||||
* to answer "whose is this" — and because a second lookup is a second chance for the two doors to
|
||||
* disagree about the same credential, which is the failure this whole file exists to prevent.
|
||||
*/
|
||||
scope?: CredentialScope | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A narrowing, in the two shapes that are actually useful.
|
||||
*
|
||||
* `allow` is exact and a snapshot; `excludeKinds` is a rule resolved against the live registry. A
|
||||
* commander wants the second: bounded away from administration forever, and still able to reach a plugin
|
||||
* installed long after it was created. See `api-keys/schema.ts` for the full argument.
|
||||
*/
|
||||
export type CredentialScope = {
|
||||
allow?: string[] | null;
|
||||
excludeKinds?: string[] | null;
|
||||
};
|
||||
|
||||
/** Does this narrowing actually narrow anything? Null and two empty rules are the same thing: no. */
|
||||
export const isNarrowed = (scope: CredentialScope | null | undefined): scope is CredentialScope =>
|
||||
!!scope && (Array.isArray(scope.allow) || !!scope.excludeKinds?.length);
|
||||
|
||||
/**
|
||||
* Turn an API key into a caller, or null.
|
||||
*
|
||||
@@ -86,7 +111,14 @@ export async function resolveApiKey(secret: string): Promise<AuthIdentity | null
|
||||
// is debounced to at most once a minute per key inside touchApiKey.
|
||||
void touchApiKey(row.keyId, row.lastUsedAt).catch(() => {});
|
||||
|
||||
return { id: row.userId, email: row.email, username: row.username, via: 'api-key' };
|
||||
const scope: CredentialScope = { allow: row.permissions, excludeKinds: row.excludedKinds };
|
||||
return {
|
||||
id: row.userId,
|
||||
email: row.email,
|
||||
username: row.username,
|
||||
via: 'api-key',
|
||||
scope: isNarrowed(scope) ? scope : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,8 +37,14 @@ import { snapshotPlugins } from './plugins/mount';
|
||||
* database work. A missing directory is skipped rather than erroring: most plugins ship no capabilities
|
||||
* at all, and that is the ordinary case rather than a fault.
|
||||
*/
|
||||
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
|
||||
const roots = [itemsDir(type as ItemType)];
|
||||
export type CapabilitySource = {
|
||||
dir: string;
|
||||
/** The plugin that ships it, or null for the owner's own store. */
|
||||
plugin: string | null;
|
||||
};
|
||||
|
||||
export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise<CapabilitySource[]> {
|
||||
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
|
||||
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||
try {
|
||||
@@ -46,34 +52,27 @@ export async function capabilityRoots(type: ItemType | 'affordances' | 'notifica
|
||||
} catch {
|
||||
// The owner's own capabilities still load. A database that cannot be read is a much louder problem
|
||||
// somewhere else, and losing the agent's whole skill index to it would only obscure that.
|
||||
return roots;
|
||||
return sources;
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
const dir = join(state.plugin.dir, type);
|
||||
if (existsSync(dir)) roots.push(dir);
|
||||
if (existsSync(dir)) sources.push({ dir, plugin: state.plugin.appName });
|
||||
}
|
||||
return roots;
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same list, for a caller that already holds a snapshot.
|
||||
* The same list for a caller that only needs the paths.
|
||||
*
|
||||
* Exists so a request doing several of these — the items rescan reads five types — pays for one database
|
||||
* read rather than five.
|
||||
* Which is most of them: a skill or an affordance is read, compiled into a preamble and never written
|
||||
* to, so where it came from changes nothing. Tasks are the exception — one can be deleted, and deleting
|
||||
* a plugin's file out of the owner's screen is not a thing the owner asked for — so `task-files.ts`
|
||||
* takes the sources and keeps the provenance.
|
||||
*/
|
||||
export function capabilityRootsFrom(
|
||||
states: Awaited<ReturnType<typeof snapshotPlugins>>['states'],
|
||||
type: ItemType | 'affordances' | 'notifications',
|
||||
): string[] {
|
||||
const roots = [itemsDir(type as ItemType)];
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
const dir = join(state.plugin.dir, type);
|
||||
if (existsSync(dir)) roots.push(dir);
|
||||
}
|
||||
return roots;
|
||||
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
|
||||
return (await capabilitySources(type)).map((s) => s.dir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +89,11 @@ export async function pluginCatalogueSources(): Promise<
|
||||
const { states } = await snapshotPlugins();
|
||||
return states
|
||||
.filter((s) => s.install?.enabled)
|
||||
.map((s) => ({ appName: s.plugin.appName, dir: s.plugin.dir, manifest: { publisher: s.plugin.manifest.publisher } }));
|
||||
.map((s) => ({
|
||||
appName: s.plugin.appName,
|
||||
dir: s.plugin.dir,
|
||||
manifest: { publisher: s.plugin.manifest.publisher },
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { User } from 'types';
|
||||
import { Hono } from 'hono';
|
||||
import type { CredentialScope } from './auth-token';
|
||||
|
||||
export type HonoVariables = {
|
||||
body: Record<string, unknown>;
|
||||
@@ -14,6 +15,15 @@ export type HonoVariables = {
|
||||
* Carrying it on the object those call sites already receive means none of them can forget.
|
||||
*/
|
||||
user: User & { homeDir?: string };
|
||||
/**
|
||||
* The narrowing carried by the credential that made this request, or null when it carries its holder's
|
||||
* full authority (every session JWT, every key a human minted).
|
||||
*
|
||||
* Set by the permission gate, which has already resolved the token and is the only place that should.
|
||||
* A route reads it when it hands authority ONWARD — `POST /tasks/:name/run` mints a token for the
|
||||
* script it spawns, and that token must not be wider than the credential that asked for the run.
|
||||
*/
|
||||
credentialScope: CredentialScope | null;
|
||||
};
|
||||
|
||||
export const createRouter = () => new Hono<{ Variables: HonoVariables }>();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { themeFavoritesRouter } from './api/themes/favorites';
|
||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { rolesRouter } from './api/roles/router';
|
||||
import { todosRouter } from './api/todos/router';
|
||||
import { apiKeysRouter } from './api/api-keys/router';
|
||||
import { skillsRouter } from './api/skills/skills';
|
||||
import { tasksRouter } from './api/tasks/tasks';
|
||||
@@ -130,6 +131,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/theme-favorites', themeFavoritesRouter],
|
||||
['/users', usersRouter],
|
||||
['/roles', rolesRouter],
|
||||
['/todos', todosRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
['/agents', agentsRouter],
|
||||
|
||||
@@ -123,6 +123,59 @@ const CORE_ENTRIES: Record<string, NotificationEntry> = {
|
||||
route: 'officer://jobs/{id}',
|
||||
thread: 'officer/core:job',
|
||||
},
|
||||
/*
|
||||
A commander with something to say.
|
||||
|
||||
ONE entry rather than one per commander, and the commander travels as `{id}` — so arming a new
|
||||
commander needs no catalogue change and no app resync. `{id}` is the commander's DIRECTORY name, which
|
||||
is what threads its notifications together.
|
||||
|
||||
The route is the LIST, deliberately, and not `officer://command-hq/{id}`. That is what it was, and it
|
||||
was wrong: `/command-hq/:sessionId` addresses a commander by its TRANSCRIPT id, so a deeplink built
|
||||
from the directory name lands nowhere. Threading wants the directory name (stable from creation, before
|
||||
any transcript exists); the route wants the transcript id (which does not exist yet when the commander
|
||||
is created). Rather than make a commander fetch its own transcript id to send a notification, the tap
|
||||
opens Command HQ and the owner picks — one tap worse, and correct.
|
||||
|
||||
Worth revisiting when the app handles deep links properly: the honest fix is a route that accepts a
|
||||
directory name, not a commander doing a lookup before it can speak.
|
||||
|
||||
This entry is what a commander sees when it sends nothing but a code and an id: "Voice Memos" would be
|
||||
a lie without knowing which one, so the generic is honest and the deeplink does the rest. In practice a
|
||||
commander supplies its own `title` and `body`, which override this — that is the exception described in
|
||||
`sidecar/notify/types.ts`, and the whole reason a commander can notify usefully at all. The entry still
|
||||
matters: it is the fallback when it doesn't, and it is what makes the thread group per commander rather
|
||||
than piling every commander into one stack.
|
||||
*/
|
||||
/*
|
||||
A message from the owner, in their own words.
|
||||
|
||||
The plainest possible entry: both fields are the producer's and this adds nothing around them. It
|
||||
exists because `officer/core:test` has fixed words and `officer/core:commander` prefixes a name, so
|
||||
there was no way to say one sentence to somebody without it arriving dressed as something else.
|
||||
|
||||
Same trade as every producer-text notification — the words go through Apple and Google. See
|
||||
`sidecar/notify/types.ts`. This one is the case where that is most obviously the point: it is a
|
||||
message TO a person, and a message they cannot read is not one.
|
||||
*/
|
||||
'officer/core:message': {
|
||||
title: '{title}',
|
||||
body: '{body}',
|
||||
target: 'mobile',
|
||||
route: 'officer://',
|
||||
thread: 'officer/core:message',
|
||||
},
|
||||
'officer/core:commander': {
|
||||
// `{title}` is the commander's NAME and `{body}` its sentence — the commander supplies both and this
|
||||
// decides what they look like. It sends "Voice Memos", never "Commander: Voice Memos": the prefix is
|
||||
// the platform's to add, so every commander reads the same on the lock screen whether or not it
|
||||
// remembered to. A commander that sends no title of its own falls through to the bare word.
|
||||
title: 'Commander: {title}',
|
||||
body: '{body}',
|
||||
target: 'mobile',
|
||||
route: 'officer://command-hq',
|
||||
thread: 'commander:{id}',
|
||||
},
|
||||
};
|
||||
|
||||
/** FNV-1a, 32-bit. Small, stable across processes, and not a security boundary. */
|
||||
|
||||
@@ -214,3 +214,97 @@ describe('deciding a websocket', () => {
|
||||
`api/users/permissions-routes.ts` calls the invalidator — is a claim about call sites, not about logic,
|
||||
and a test here could only re-state it.
|
||||
*/
|
||||
|
||||
/*
|
||||
── A credential narrower than its holder ──
|
||||
|
||||
The owner bypasses every role check, so a token carrying the owner's identity is unbounded by
|
||||
construction. That is right for a browser session somebody is sitting in front of, and wrong for an
|
||||
agent holding one unattended for months — a commander. The narrowing on an API key is the only thing
|
||||
that can bound a Super Admin, which is why it is checked BEFORE the bypass rather than folded into the
|
||||
grants map (a Super Admin never reads that map).
|
||||
*/
|
||||
describe('a narrowed credential', () => {
|
||||
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
|
||||
const allow = (...keys: string[]) => ({ allow: keys });
|
||||
|
||||
test('bounds the owner, who nothing else bounds', () => {
|
||||
expect(decideApiRequest(owner, 'POST', '/api/tasks', allow('tasks')).allowed).toBe(true);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/users', allow('tasks')).allowed).toBe(false);
|
||||
expect(decideWsProvider(owner, 'terminal', allow('tasks'))).toBe(false);
|
||||
});
|
||||
|
||||
test('null and undefined leave the holder unnarrowed', () => {
|
||||
expect(decideApiRequest(owner, 'GET', '/api/users', null).allowed).toBe(true);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/users').allowed).toBe(true);
|
||||
});
|
||||
|
||||
// An empty list is a coherent thing to mint, and the safe default for a credential nobody has decided
|
||||
// about yet. It must not read as "no narrowing".
|
||||
test('an empty allow list reaches nothing', () => {
|
||||
expect(decideApiRequest(owner, 'GET', '/api/tasks', allow()).allowed).toBe(false);
|
||||
});
|
||||
|
||||
// Intersection, never union: naming a permission does not grant it.
|
||||
test('it cannot widen an account that was never granted it', () => {
|
||||
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser', allow('files'));
|
||||
expect(d.allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('it narrows an account that was granted it', () => {
|
||||
const held = resolve(member(), grants(['files', 'write']));
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser', allow('files')).allowed).toBe(true);
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser', allow('tasks')).allowed).toBe(false);
|
||||
});
|
||||
|
||||
// Same rule the unnarrowed path already has, one step earlier: a path nothing serves must not be
|
||||
// reachable by failing to resolve.
|
||||
test('a path no permission covers is refused', () => {
|
||||
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this', allow('tasks')).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
── Excluding a KIND, rather than listing what is allowed ──
|
||||
|
||||
The shape a commander needs, and the reason there are two. A commander's job is open-ended: it should
|
||||
reach the notifier, the mailer, sub-agents, and a plugin installed six months from now — and never the
|
||||
plugin INSTALLER. An allow list cannot say that. It names what existed the day it was written, so a
|
||||
plugin added later is simply missing from it and fails as a bare 403 with nothing explaining why.
|
||||
|
||||
Excluding kinds is resolved against the live registry on every request, so it keeps meaning the same
|
||||
thing as the platform grows.
|
||||
*/
|
||||
describe('a credential that excludes a kind', () => {
|
||||
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
|
||||
const commander = { excludeKinds: ['admin'] };
|
||||
|
||||
test('the owner keeps everything except that kind', () => {
|
||||
expect(decideApiRequest(owner, 'POST', '/api/tasks', commander).allowed).toBe(true);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/notify', commander).allowed).toBe(true);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/file-browser', commander).allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('and cannot administer the server', () => {
|
||||
expect(decideApiRequest(owner, 'GET', '/api/users', commander).allowed).toBe(false);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/plugins', commander).allowed).toBe(false);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/roles', commander).allowed).toBe(false);
|
||||
});
|
||||
|
||||
// The property an allow list cannot have. `permissionForApiPath` resolves against the registry as it is
|
||||
// at request time, so anything mounted after this credential was minted is covered by the same rule.
|
||||
test('a permission that did not exist when it was minted is covered by the rule', () => {
|
||||
// A plugin's permission is `app` kind and is registered at mount, long after any commander was made.
|
||||
// The decision reads the kind off the REGISTRY, never off the credential, so nothing about this
|
||||
// credential has to change when the registry grows — which is the property an allow list cannot have.
|
||||
const laterKind: string = 'app';
|
||||
expect(commander.excludeKinds.includes(laterKind)).toBe(false);
|
||||
});
|
||||
|
||||
test('both rules apply together, and both only subtract', () => {
|
||||
const both = { allow: ['tasks', 'user-admin'], excludeKinds: ['admin'] };
|
||||
expect(decideApiRequest(owner, 'POST', '/api/tasks', both).allowed).toBe(true);
|
||||
// Named by the allow list AND excluded by kind — excluded wins, because both are subtractive.
|
||||
expect(decideApiRequest(owner, 'GET', '/api/users', both).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getUserById, getRoleGrants } from 'officerdb';
|
||||
import type { CredentialScope } from '../auth-token';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import {
|
||||
PERMISSION_BY_KEY,
|
||||
@@ -141,17 +142,62 @@ export async function isApiRequestAllowed(
|
||||
userId: number | undefined,
|
||||
method: string,
|
||||
path: string,
|
||||
scope?: CredentialScope | null,
|
||||
): Promise<{ allowed: boolean; reason?: string }> {
|
||||
return decideApiRequest(await getEffectivePermissions(userId), method, path);
|
||||
return decideApiRequest(await getEffectivePermissions(userId), method, path, scope);
|
||||
}
|
||||
|
||||
/** The decision, given an already-resolved account. Pure, and where every rule below actually lives. */
|
||||
/**
|
||||
* Does this credential's own narrowing admit this permission?
|
||||
*
|
||||
* Two rules, both subtractive, and an absent one admits everything:
|
||||
*
|
||||
* - `excludeKinds` is resolved against the LIVE registry, so it keeps meaning what it meant as the
|
||||
* platform grows. That is what makes it right for a commander: excluded from administration forever,
|
||||
* and able to reach a plugin installed long after it was minted. An allow list cannot express that —
|
||||
* it names what existed on the day it was written, so next year's plugin is simply missing from it.
|
||||
* - `allow` is an exact list, for a credential with one job.
|
||||
*
|
||||
* An unresolvable permission is refused rather than admitted: a path nothing serves must not become
|
||||
* reachable by failing to resolve, which is the same rule the unnarrowed path already applies below.
|
||||
*/
|
||||
function credentialAdmits(
|
||||
scope: CredentialScope,
|
||||
permission: { key: string; kind: string } | null | undefined,
|
||||
): boolean {
|
||||
if (!permission) return false;
|
||||
if (scope.excludeKinds?.includes(permission.kind)) return false;
|
||||
if (Array.isArray(scope.allow) && !scope.allow.includes(permission.key)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision, given an already-resolved account. Pure, and where every rule below actually lives.
|
||||
*
|
||||
* `scope` is the narrowing carried by an API key, or null/undefined for one that carries its holder's
|
||||
* full authority (which is every key a human has minted and every session JWT). It is an intersection,
|
||||
* never a union: a key cannot reach what its account could not, and its account cannot reach, through
|
||||
* this key, what the key excludes.
|
||||
*
|
||||
* **It is checked before the owner bypass, and that is the whole point.** A Super Admin consults no
|
||||
* grants table, so a credential carrying the owner's identity is unbounded by construction — which is
|
||||
* fine for a browser session the owner is sitting in front of, and not fine for an agent holding one
|
||||
* unattended for months. This line is the only thing in the platform that can bound them.
|
||||
*/
|
||||
export function decideApiRequest(
|
||||
effective: EffectivePermissions,
|
||||
method: string,
|
||||
path: string,
|
||||
scope?: CredentialScope | null,
|
||||
): { allowed: boolean; reason?: string } {
|
||||
const { isOwner, grants } = effective;
|
||||
|
||||
// Same deny-the-unmapped rule as below, one step earlier: a path this cannot resolve must not become
|
||||
// reachable by failing to resolve.
|
||||
if (scope && !credentialAdmits(scope, permissionForApiPath(path))) {
|
||||
return { allowed: false, reason: 'this credential is not permitted to use that' };
|
||||
}
|
||||
|
||||
if (isOwner) return { allowed: true };
|
||||
|
||||
const permission = permissionForApiPath(path);
|
||||
@@ -197,13 +243,26 @@ export function decideApiRequest(
|
||||
* provider reaching here today belongs to an `execution` permission and is refused above, structurally,
|
||||
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
|
||||
*/
|
||||
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
|
||||
return decideWsProvider(await getEffectivePermissions(userId), provider);
|
||||
export async function isWsProviderAllowed(
|
||||
userId: number | undefined,
|
||||
provider: string,
|
||||
scope?: CredentialScope | null,
|
||||
): Promise<boolean> {
|
||||
return decideWsProvider(await getEffectivePermissions(userId), provider, scope);
|
||||
}
|
||||
|
||||
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
|
||||
export function decideWsProvider(effective: EffectivePermissions, provider: string): boolean {
|
||||
export function decideWsProvider(
|
||||
effective: EffectivePermissions,
|
||||
provider: string,
|
||||
scope?: CredentialScope | null,
|
||||
): boolean {
|
||||
const { isOwner, grants } = effective;
|
||||
|
||||
// Before the owner bypass, for the reason given in `decideApiRequest`. A socket is the one door a
|
||||
// narrowed credential must not walk through just because it authenticated.
|
||||
if (scope && !credentialAdmits(scope, permissionForWsProvider(provider))) return false;
|
||||
|
||||
if (isOwner) return true;
|
||||
|
||||
const permission = permissionForWsProvider(provider);
|
||||
|
||||
@@ -198,7 +198,19 @@ const CORE_REGISTRY: Permission[] = [
|
||||
key: 'notify',
|
||||
label: 'Notifications',
|
||||
description: 'Push notifications to your devices',
|
||||
kind: 'app',
|
||||
/*
|
||||
CORE, not `app`, since 2026-08-24.
|
||||
|
||||
It was grantable and nothing granted it, so every Member's app got a 403 the moment it tried to
|
||||
register its push token — silently, at launch, with no notification ever arriving and nothing
|
||||
anywhere saying why. Invisible for days because the owner bypasses the check.
|
||||
|
||||
Making it grantable was the mistake rather than the missing grant. These are the caller's OWN
|
||||
devices, and the send route resolves its target from the authenticated header rather than the body
|
||||
(`sidecar/notify/resolve-user.ts`), so an account can only ever reach its own phone. There is no
|
||||
version of "you may not subscribe to your own notifications" that is a coherent thing to configure.
|
||||
*/
|
||||
kind: 'core',
|
||||
api: ['/notify'],
|
||||
// Device registration is the caller's own — a phone subscribing to its own push channel.
|
||||
//
|
||||
@@ -207,6 +219,14 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// nothing, so a read-level member could list their phones but not register or delete one — the exact
|
||||
// opposite of the line above. Invisible in testing because the owner bypasses every check.
|
||||
personal: ['/_officer/devices', '/_officer/subscriptions'],
|
||||
/*
|
||||
Broadcast is the one part of a core permission that is not the caller's own.
|
||||
|
||||
Everything else here acts on the account asking — its devices, its notifications — which is what
|
||||
made `core` right. Sending to EVERY account is administration, so it needs the owner even though
|
||||
the permission around it is not deniable.
|
||||
*/
|
||||
ownerOnly: ['/broadcast'],
|
||||
},
|
||||
// `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale,
|
||||
// minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone
|
||||
@@ -422,6 +442,17 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
|
||||
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',
|
||||
label: 'Server settings',
|
||||
|
||||
@@ -182,6 +182,22 @@ export type OsDependency = {
|
||||
};
|
||||
};
|
||||
|
||||
/** One field on a plugin's settings form. See `settings` on the manifest for why this is declared. */
|
||||
export type PluginSetting = {
|
||||
/** The key in the JSON body sent to `<prefix>/config`. */
|
||||
key: string;
|
||||
/** What the owner sees beside the input. */
|
||||
label: string;
|
||||
/** One line under it — what this is for, or where to find the value. Optional. */
|
||||
hint?: string;
|
||||
/** `url` and `text` render as text inputs; `secret` is write-only and renders as a password. */
|
||||
type: 'url' | 'text' | 'secret';
|
||||
/** Refuse to save without it. */
|
||||
required?: boolean;
|
||||
/** Shown greyed in an empty input, never submitted. */
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type PluginManifest = {
|
||||
/**
|
||||
* Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two
|
||||
@@ -346,6 +362,33 @@ export type PluginManifest = {
|
||||
* The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a
|
||||
* request-forgery primitive reachable by anyone the plugin is granted to.
|
||||
*/
|
||||
/**
|
||||
* Settings the owner must be able to set, rendered by the platform on the plugin's own detail page.
|
||||
*
|
||||
* ── Why a declaration and not a component ──
|
||||
*
|
||||
* A plugin with no `web/` has nowhere to put a settings screen — no route, no dock tile, no panel. That
|
||||
* is most backend plugins, and it left them configurable only by an HTTP call somebody had to know
|
||||
* about. `transcribe` shipped in exactly that state: installed, running, and with no way to tell it
|
||||
* where its server was.
|
||||
*
|
||||
* The plugin could ship a component instead, and that survives settings too complicated to describe.
|
||||
* But every backend plugin would then carry a frontend to render one text field, and they would each
|
||||
* look slightly different. A declaration means the platform renders one consistent form and the plugin
|
||||
* ships no UI at all — which for a URL and a token is the whole requirement.
|
||||
*
|
||||
* ── The contract ──
|
||||
*
|
||||
* The platform GETs and PUTs `<mountPrefix>/config`, and the plugin owns what that means. Values are
|
||||
* never stored by the platform: it is a form over the plugin's own route, so a plugin that keeps a
|
||||
* credential keeps it, and a plugin that validates before saving still does.
|
||||
*
|
||||
* A field marked `secret` is write-only across the boundary — sent when set, never read back, and
|
||||
* rendered as a password input. The plugin returns whether it HAS one, never the value, the same shape
|
||||
* the Jellyfin access token already uses.
|
||||
*/
|
||||
settings?: PluginSetting[];
|
||||
|
||||
websocket?: { sidecar: string };
|
||||
osDependencies?: OsDependency[];
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state'
|
||||
import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis';
|
||||
import { createParseState, processMessage } from './stream-parser';
|
||||
import { spawnClaudeAsMember } from './spawn-as-member';
|
||||
import { mintSessionCredential } from './session-credential';
|
||||
import { claudeBinIn } from '@@/os-user-claude';
|
||||
|
||||
const SEND_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
@@ -592,6 +593,19 @@ async function createSession(
|
||||
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
|
||||
const cleanEnv = envWithoutParentSession();
|
||||
|
||||
/*
|
||||
This session's own credential, scoped to notifications.
|
||||
|
||||
In the environment rather than a file: it dies with the process, there is no path to leak and nothing
|
||||
to clean up after a crash. The agent's Bash inherits it as `$OFFICER_AGENT_KEY`, which is the whole
|
||||
point — until now the only token in the tree was the MCP server's, which the agent's shell cannot see,
|
||||
so an agent asked to tell its owner something had no door at all.
|
||||
|
||||
Null when minting failed. The agent then has no key, which is exactly where everything was before.
|
||||
*/
|
||||
const agentEnv = await mintSessionCredential(params.userId, sessionKey);
|
||||
if (agentEnv) Object.assign(cleanEnv, agentEnv);
|
||||
|
||||
const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId;
|
||||
/*
|
||||
Where that transcript actually lives, because `--resume` is scoped to the WORKING DIRECTORY.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createApiKey, revokeApiKeysByName } from 'officerdb';
|
||||
import { mintApiKey } from '@@/auth-token';
|
||||
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
|
||||
/**
|
||||
* A credential for one agent session, narrowed to notifications.
|
||||
*
|
||||
* ── The gap it closes ──
|
||||
*
|
||||
* A commander has `officer.env` and can reach the platform. An ordinary chat agent has nothing: the only
|
||||
* token in the tree lives in the MCP server's environment, which the agent's own Bash cannot see. So an
|
||||
* agent asked to tell its owner something had no door at all — it either could not, or it asked to be
|
||||
* handed a personal API key, which is the owner's full authority pasted into a transcript.
|
||||
*
|
||||
* ── Why `notify` and nothing else ──
|
||||
*
|
||||
* "An agent may tell me things" is a much smaller thing to hand out than "an agent may act as me", and it
|
||||
* is the whole of what was being asked for. Everything else an agent needs, it already has: it runs as
|
||||
* somebody, in their home, with their files. This is only the one capability that has no other route.
|
||||
*
|
||||
* Widening it later is one entry in this array. Starting wide and narrowing later never happens.
|
||||
*
|
||||
* ── Why an env var rather than a file ──
|
||||
*
|
||||
* It dies with the process. There is no path to leak, nothing to clean up after a crash, and no file for a
|
||||
* later session to find. The commander's own credential is a file precisely because it must OUTLIVE the
|
||||
* turn that made it — a detached loop reads it hours later. A chat agent has no such need.
|
||||
*
|
||||
* ── Lifetime ──
|
||||
*
|
||||
* Named for the session, so re-arming the same conversation replaces its key rather than accumulating one
|
||||
* per turn, and a key outliving its session is findable by name rather than being one of a hundred
|
||||
* identical rows. Also expires on its own: a session that ends without cleanup — a crash, a restart — must
|
||||
* not leave a live credential behind for a month.
|
||||
*/
|
||||
const SESSION_KEY_PERMISSIONS = ['notify'];
|
||||
|
||||
/** A week. Long enough for a conversation somebody returns to, short enough that a leaked one dies. */
|
||||
const SESSION_KEY_DAYS = 7;
|
||||
|
||||
export const sessionKeyName = (sessionKey: string) => `session:${sessionKey}`;
|
||||
|
||||
export type AgentApiEnv = {
|
||||
OFFICER_API_URL: string;
|
||||
OFFICER_API_HOST: string;
|
||||
OFFICER_AGENT_KEY: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mint one, replacing any previous key for this session.
|
||||
*
|
||||
* Best effort by contract: a turn must still start if the database refuses. The agent then simply has no
|
||||
* key, which is the state everything was in before this existed — degradation rather than failure.
|
||||
*/
|
||||
export async function mintSessionCredential(userId: number, sessionKey: string): Promise<AgentApiEnv | null> {
|
||||
try {
|
||||
const name = sessionKeyName(sessionKey);
|
||||
await revokeApiKeysByName(userId, name);
|
||||
|
||||
const { secret, keyHash, prefix } = mintApiKey();
|
||||
const expiresAt = new Date(Date.now() + SESSION_KEY_DAYS * 24 * 60 * 60 * 1000);
|
||||
await createApiKey({ userId, name, keyHash, prefix, permissions: SESSION_KEY_PERMISSIONS, expiresAt });
|
||||
|
||||
const publicHost = (() => {
|
||||
try {
|
||||
return process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL.replace(/^"|"$/g, '')).host : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
OFFICER_API_URL,
|
||||
// Requests go to 127.0.0.1, but origin validation matches Host against PUBLIC_URL — so a bare
|
||||
// loopback request is refused without this. Same reason `task-api-env.ts` carries it.
|
||||
OFFICER_API_HOST: publicHost ?? `127.0.0.1:${PORT}`,
|
||||
OFFICER_AGENT_KEY: secret,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[claude] could not mint a session credential — the agent will have no API key', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop it when the session ends. Never fatal: the expiry above is the backstop. */
|
||||
export async function revokeSessionCredential(userId: number, sessionKey: string): Promise<void> {
|
||||
await revokeApiKeysByName(userId, sessionKeyName(sessionKey)).catch(() => {});
|
||||
}
|
||||
@@ -54,8 +54,17 @@ export type MemberRun = {
|
||||
*/
|
||||
const ALLOWED_ENV = ['LANG', 'LC_ALL', 'TERM', 'TZ', 'NO_COLOR', 'CLAUDE_CODE_ENTRYPOINT'] as const;
|
||||
|
||||
/** Set by `memberEnv` itself rather than inherited, so vetted the same way. */
|
||||
const ALSO_ALLOWED = ['HOME', 'CLAUDE_CONFIG_DIR'] as const;
|
||||
/**
|
||||
* Set by `memberEnv` itself rather than inherited, so vetted the same way.
|
||||
*
|
||||
* The three `OFFICER_*` entries are this member's OWN session credential, minted per turn and narrowed to
|
||||
* `notify`. They belong here deliberately, and they are not the kind of thing `NEVER_ENV` below guards
|
||||
* against: those are the OWNER'S credentials, which would make a member's shell more privileged than the
|
||||
* member. This one grants a member exactly one capability they already have — telling their own devices
|
||||
* something — and grants it to nobody else. Without it a member's agent has no way to reach the platform
|
||||
* at all, which is a worse answer than a narrow key.
|
||||
*/
|
||||
const ALSO_ALLOWED = ['HOME', 'CLAUDE_CONFIG_DIR', 'OFFICER_API_URL', 'OFFICER_API_HOST', 'OFFICER_AGENT_KEY'] as const;
|
||||
|
||||
/**
|
||||
* Credential names that must never appear in the allowlist. **This guards the constant, not the instance.**
|
||||
@@ -127,6 +136,12 @@ function memberEnv(run: MemberRun, inherited: Record<string, string | undefined>
|
||||
}
|
||||
env.HOME = run.home;
|
||||
env.CLAUDE_CONFIG_DIR = memberClaudeConfigDir(run.home);
|
||||
// The member's own session credential, when one was minted. Absent is fine: the agent then has no key,
|
||||
// which is where every member's agent was before this existed.
|
||||
for (const name of ['OFFICER_API_URL', 'OFFICER_API_HOST', 'OFFICER_AGENT_KEY'] as const) {
|
||||
const value = inherited[name];
|
||||
if (value !== undefined) env[name] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,10 +31,16 @@ export type SidecarProxyParams = {
|
||||
/** Extra work when the sidecar reports in. Rare — most sidecars only need the port remembered. */
|
||||
onRegister?: (port: number) => void;
|
||||
/**
|
||||
* Per-request idle timeout, seconds. The main server drops a proxied connection after 60s with no bytes
|
||||
* flowing, which is wrong for a sidecar route that thinks before it answers (a from-scratch music
|
||||
* reindex holds the socket open for minutes). Applied to the whole prefix: the proxy must not know which
|
||||
* of a sidecar's routes are slow.
|
||||
* Per-request idle timeout, seconds — or omitted, which now means NO timeout and is the default.
|
||||
*
|
||||
* The main server drops an ordinary connection after 60s with no bytes flowing, which is wrong for
|
||||
* every sidecar route that thinks before it answers. A from-scratch music reindex holds the socket for
|
||||
* minutes; transcribing a 42-minute recording holds it for longer than that.
|
||||
*
|
||||
* The owner's call on 2026-08-24, after a transcription that SUCCEEDED was thrown away by a proxy that
|
||||
* gave up on it: there should be no timeouts on these requests at all, because long ones will keep
|
||||
* arriving from everywhere and every ceiling is a number somebody guessed. A plugin may still name one
|
||||
* if it genuinely wants a bound; nothing does by default.
|
||||
*/
|
||||
timeoutSeconds?: number;
|
||||
/**
|
||||
@@ -76,10 +82,31 @@ export type SidecarProxy = {
|
||||
router: Hono<{ Variables: HonoVariables }>;
|
||||
/** `http://127.0.0.1:<port>`, or null before the sidecar has reported in. */
|
||||
getHttpUrl: () => string | null;
|
||||
/**
|
||||
* The secret this sidecar announced at registration, for a producer INSIDE the platform calling it
|
||||
* directly rather than through this proxy. Null until it registers.
|
||||
*
|
||||
* In memory on both sides and never written down — see `sidecar/notify/index.ts` for why a file would
|
||||
* not have bounded the thing this exists to bound.
|
||||
*/
|
||||
getProducerSecret: () => string | null;
|
||||
/** The same server as a `ws://` base, for sidecars whose sockets the platform relays. */
|
||||
getWsUrl: () => string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request timeouts on sidecar proxies. Flip this line to turn them back on.
|
||||
*
|
||||
* `false` — no ceiling on either leg. Long requests arrive from everywhere (transcribing a 42-minute
|
||||
* recording, a from-scratch music reindex) and every ceiling is a number somebody guessed.
|
||||
*
|
||||
* `true` — Bun's built-in fetch ceiling outbound, and the inbound drop at whatever each plugin declared.
|
||||
*
|
||||
* One constant in one file, so turning it on or off does not mean remembering which of eight routers to
|
||||
* edit.
|
||||
*/
|
||||
const TIMEOUTS_ON = false;
|
||||
|
||||
export function createSidecarProxy({
|
||||
name,
|
||||
prefix,
|
||||
@@ -89,15 +116,19 @@ export function createSidecarProxy({
|
||||
permissionKey,
|
||||
}: SidecarProxyParams): SidecarProxy {
|
||||
let serverPort: number | null = null;
|
||||
let producerSecret: string | null = null;
|
||||
|
||||
// Tells bodyParser to keep its hands off this prefix's request bodies. Load-bearing for uploads, not an
|
||||
// optimisation: see proxied-prefixes.ts.
|
||||
registerProxiedPrefix(prefix);
|
||||
|
||||
sidecar.on(`${name}:server`, (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
const { port, secret } = msg as { port?: number; secret?: string };
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
// Optional: only a sidecar that guards a producer path announces one, and one that stops announcing
|
||||
// (an older build after a rollback) must not leave a stale secret behind that no longer opens it.
|
||||
producerSecret = typeof secret === 'string' && secret ? secret : null;
|
||||
console.log(`[${name}] sidecar registered on port ${port}`);
|
||||
onRegister?.(port);
|
||||
});
|
||||
@@ -115,12 +146,35 @@ export function createSidecarProxy({
|
||||
const subpath = url.pathname.slice(prefix.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
if (timeoutSeconds) {
|
||||
// Bun passes the server as Hono's env. Older Bun has no per-request timeout; the request still
|
||||
// completes, it just risks the default idle drop.
|
||||
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
|
||||
/*
|
||||
The INBOUND leg: how long Bun holds the caller's connection.
|
||||
|
||||
Applied to every proxied request now, not only ones that declared a number — `0` disables the idle
|
||||
timeout, and no timeout is the default for anything going through a sidecar. Bun passes the server
|
||||
as Hono's env; an older Bun without per-request timeouts still completes the request, it just risks
|
||||
the 60s default drop.
|
||||
*/
|
||||
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
|
||||
// `0` disables the idle timeout. With the switch on, an undeclared plugin falls back to the server's
|
||||
// own 60s default by not being touched at all.
|
||||
/*
|
||||
Bun stores a per-request timeout in ONE BYTE. Anything above 255 seconds is clamped to 255 — so a
|
||||
plugin declaring a generous 1800 got a SHORTER timeout than a plugin declaring nothing at all, which
|
||||
falls through to 0 and is unlimited. Declaring the fix is what armed the bomb.
|
||||
|
||||
That is what killed every long transcription: 1800 became 255, the upload took a second, and the
|
||||
connection dropped at 4m16s while whisperx went on and finished the job into a dead socket. Six
|
||||
plugins declare more than 255 — transcribe and email at 1800, music at 1800, jellyfin at 3600,
|
||||
minesweeper and photos at 600 — and all six were getting 255.
|
||||
|
||||
So anything Bun cannot represent becomes 0: a plugin asking for longer than Bun's maximum is asking
|
||||
for "do not cut this off", and 0 is the only value that means it.
|
||||
*/
|
||||
const requested = TIMEOUTS_ON ? timeoutSeconds : (timeoutSeconds ?? 0);
|
||||
const inbound = requested !== undefined && requested > 255 ? 0 : requested;
|
||||
if (inbound !== undefined) {
|
||||
try {
|
||||
server?.timeout?.(ctx.req.raw, timeoutSeconds);
|
||||
server?.timeout?.(ctx.req.raw, inbound);
|
||||
} catch {
|
||||
/* no per-request timeout available */
|
||||
}
|
||||
@@ -150,6 +204,10 @@ export function createSidecarProxy({
|
||||
// permissions page instead of compiled into an `if`.
|
||||
const userId = ctx.get('user').id;
|
||||
headers['X-Officer-User'] = String(userId);
|
||||
// Proof that this request came THROUGH the proxy, for a sidecar that guards a path with it. Without
|
||||
// it, `X-Officer-User` says who is asking but nothing says the platform is the one asking — and on a
|
||||
// machine where an agent has a shell, anything on loopback can write that header itself.
|
||||
if (producerSecret) headers['X-Officer-Producer'] = producerSecret;
|
||||
|
||||
if (permissionKey) {
|
||||
// Cached by role in `authorize.ts`, and the gate has already computed this for the same request, so
|
||||
@@ -182,11 +240,27 @@ export function createSidecarProxy({
|
||||
: ctx.req.raw.body
|
||||
: undefined;
|
||||
|
||||
const init: RequestInit & { duplex?: 'half' } = {
|
||||
/*
|
||||
`timeoutSeconds` has to govern BOTH hops, and it only governed one.
|
||||
|
||||
Above, it extends how long Bun holds the INBOUND connection open. This is the outbound leg, and it
|
||||
had nothing — so it took Bun's built-in fetch ceiling of about 300s regardless of what a plugin
|
||||
declared. transcribe declares 1800 for exactly this case and still gave up on a 42-minute recording
|
||||
at four minutes, while the sidecar went on and finished it. The work was done and thrown away.
|
||||
|
||||
`timeout: false`, unconditionally. An AbortSignal is specifically NOT wanted: it cannot raise Bun's
|
||||
ceiling anyway (only lower it — the trap `plugins/transcribe/sidecar/transcribe.ts` documents from
|
||||
hitting it first), and it aborts the response BODY as well as the request. This proxy carries video,
|
||||
and jellyfin declares 3600, so a signal would have cut a film at exactly one hour of playback. A
|
||||
streaming response has its headers in milliseconds and then runs for as long as somebody watches.
|
||||
*/
|
||||
type BunRequestInit = RequestInit & { duplex?: 'half'; timeout?: boolean | number };
|
||||
const init: BunRequestInit = {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
duplex: 'half',
|
||||
...(TIMEOUTS_ON ? {} : { timeout: false }),
|
||||
};
|
||||
|
||||
let upstream: Response;
|
||||
@@ -195,6 +269,15 @@ export function createSidecarProxy({
|
||||
} catch (err) {
|
||||
// Target path only — never the body, which may carry a passphrase or a credential.
|
||||
console.error(`[${name}] proxy fetch failed`, { target, error: String(err) });
|
||||
/*
|
||||
A timeout is not "unreachable", and saying so sent an hour of debugging at the wrong machine: the
|
||||
sidecar had answered 200 and finished the job. One means the sidecar is not there; the other means
|
||||
it is there and slower than the plugin said to wait. 504 rather than 502, and the number named, so
|
||||
the next person reads "this took longer than 1800s" instead of "the thing I can see running is
|
||||
not running".
|
||||
*/
|
||||
const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
||||
if (timedOut) return ctx.text(`${name} sidecar did not answer in time`, 504);
|
||||
return ctx.text(`${name} sidecar unreachable`, 502);
|
||||
}
|
||||
|
||||
@@ -224,5 +307,5 @@ export function createSidecarProxy({
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
|
||||
return { router, getHttpUrl, getWsUrl };
|
||||
return { router, getHttpUrl, getWsUrl, getProducerSecret: () => producerSecret };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Catalogue } from '../../notifications/catalogue';
|
||||
import type { Notification } from './types';
|
||||
import { FALLBACK_TITLE, threadFor } from './render';
|
||||
import { FALLBACK_TITLE, renderNotification, threadFor } from './render';
|
||||
|
||||
// The APNs payload.
|
||||
//
|
||||
@@ -8,7 +8,8 @@ import { FALLBACK_TITLE, threadFor } from './render';
|
||||
// arrives in the app untouched. The split is the whole design:
|
||||
//
|
||||
// - `aps.alert` is what iOS draws if nothing else happens. It is a fixed generic, because it is the one
|
||||
// part Apple can read.
|
||||
// part Apple can read — UNLESS the producer supplied its own words, which is the deliberate
|
||||
// exception described in `types.ts`.
|
||||
// - `officer` carries the code, which the Notification Service Extension resolves against its synced
|
||||
// catalogue and uses to REWRITE the banner before it is shown.
|
||||
//
|
||||
@@ -19,7 +20,7 @@ import { FALLBACK_TITLE, threadFor } from './render';
|
||||
|
||||
export type ApnsPayload = {
|
||||
aps: {
|
||||
alert: { title: string };
|
||||
alert: { title: string; body?: string };
|
||||
sound: string;
|
||||
/** Lets the extension replace the banner. Without it, `alert` is final. */
|
||||
'mutable-content': 1;
|
||||
@@ -28,22 +29,42 @@ export type ApnsPayload = {
|
||||
/** Lets the app group/replace banners rather than stacking one per event. */
|
||||
'thread-id'?: string;
|
||||
};
|
||||
/** Ours. Ids and integers, never content. */
|
||||
/** Ours. Ids and integers, plus a route when the destination varies per notification. */
|
||||
officer: {
|
||||
c: string;
|
||||
n?: number;
|
||||
id?: string | number;
|
||||
/** Where tapping goes, when the catalogue's own route cannot express it. */
|
||||
r?: string;
|
||||
/** Catalogue version this was rendered against; the device resyncs when it differs from its own. */
|
||||
mv: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The producer's words, in the shape its code declares.
|
||||
*
|
||||
* RENDERED, not raw. The catalogue owns presentation — `officer/core:commander` is `Commander: {title}` —
|
||||
* so sending `n.title` straight through would put whatever the producer happened to write on the lock
|
||||
* screen and leave "it should always say Commander" to whether it remembered.
|
||||
*/
|
||||
function alertFor(n: Notification, catalogue: Catalogue): { title: string; body?: string } {
|
||||
const { title, body } = renderNotification(n, catalogue);
|
||||
return { title, ...(body ? { body } : {}) };
|
||||
}
|
||||
|
||||
export function buildApnsPayload(n: Notification, catalogue: Catalogue): ApnsPayload {
|
||||
return {
|
||||
aps: {
|
||||
// Deliberately NOT the catalogue's title: sending the real text would hand Apple the category
|
||||
// vocabulary in plaintext, which is exactly what the code exists to withhold.
|
||||
alert: { title: FALLBACK_TITLE },
|
||||
/*
|
||||
Deliberately NOT the catalogue's title: sending the real text would hand Apple the category
|
||||
vocabulary in plaintext, which is exactly what the code exists to withhold.
|
||||
|
||||
A producer's OWN title is different and does go here. It is not the catalogue's vocabulary, it is
|
||||
one sentence about one event that only the producer could write, and the alternative is waking a
|
||||
phone to say "1 item". The producer decides what belongs in it — see `types.ts`.
|
||||
*/
|
||||
alert: n.title ? alertFor(n, catalogue) : { title: FALLBACK_TITLE },
|
||||
sound: 'default',
|
||||
'mutable-content': 1,
|
||||
...(typeof n.n === 'number' ? { badge: n.n } : {}),
|
||||
@@ -53,6 +74,7 @@ export function buildApnsPayload(n: Notification, catalogue: Catalogue): ApnsPay
|
||||
c: n.code,
|
||||
...(n.n !== undefined ? { n: n.n } : {}),
|
||||
...(n.id !== undefined ? { id: n.id } : {}),
|
||||
...(n.route ? { r: n.route } : {}),
|
||||
mv: catalogue.version,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -98,6 +98,55 @@ describe('APNs payload', () => {
|
||||
expect(json).not.toContain('please pay');
|
||||
});
|
||||
|
||||
/*
|
||||
Producer-supplied text — the deliberate exception, added 2026-08-24 for commanders.
|
||||
|
||||
A commander's output is one sentence only it could write, so there is no catalogue entry to render
|
||||
from and "1 item" is not worth waking a phone for. The owner's decision that this text may go through
|
||||
Apple; the judgement about WHICH text lives at the producer, not here.
|
||||
*/
|
||||
test("a producer's own words replace the generic, in the shape its code declares", () => {
|
||||
// The catalogue owns presentation. The commander sends its NAME, not the prefix — so every commander
|
||||
// reads the same on the lock screen whether or not it remembered to write one.
|
||||
const withShape: Catalogue = {
|
||||
...catalogue,
|
||||
entries: { ...catalogue.entries, 'officer/core:commander': { title: 'Commander: {title}', body: '{body}' } },
|
||||
};
|
||||
const p = buildApnsPayload(
|
||||
{ code: 'officer/core:commander', userId: 1, id: 12, title: 'Voice Memos', body: '3 memos filed' },
|
||||
withShape,
|
||||
);
|
||||
expect(p.aps.alert).toEqual({ title: 'Commander: Voice Memos', body: '3 memos filed' });
|
||||
});
|
||||
|
||||
// A template written with a separator must be safe when its value is missing, or every template has to
|
||||
// assume its values are always present.
|
||||
test('a placeholder that resolves to nothing takes its punctuation with it', () => {
|
||||
const withShape: Catalogue = {
|
||||
...catalogue,
|
||||
entries: { ...catalogue.entries, 'officer/core:commander': { title: 'Commander: {title}' } },
|
||||
};
|
||||
const p = buildApnsPayload({ code: 'officer/core:commander', userId: 1, title: 'x' }, withShape);
|
||||
expect(p.aps.alert.title).toBe('Commander: x');
|
||||
expect(renderNotification({ code: 'officer/core:commander', userId: 1 }, withShape).title).toBe('Commander');
|
||||
});
|
||||
|
||||
// `title` is the opt-in, and the smuggle guard above depends on that being true: it passes a stray
|
||||
// `body` and asserts it does not travel. A body that could travel on its own would silently defeat it.
|
||||
test('a body with no title does not travel', () => {
|
||||
const p = buildApnsPayload({ code: 'officer/core:mail', userId: 1, body: 'please pay' } as Notification, catalogue);
|
||||
expect(p.aps.alert.title).toBe(FALLBACK_TITLE);
|
||||
expect(JSON.stringify(p)).not.toContain('please pay');
|
||||
});
|
||||
|
||||
test('a per-notification route rides as `r`, for a destination the catalogue cannot express', () => {
|
||||
const p = buildApnsPayload(
|
||||
{ code: 'officer/core:commander', userId: 1, id: 12, route: 'officer://command-hq/12?files=1' },
|
||||
catalogue,
|
||||
);
|
||||
expect(p.officer.r).toBe('officer://command-hq/12?files=1');
|
||||
});
|
||||
|
||||
test('an unknown code still sends, and threads by itself', () => {
|
||||
// The mapping has not caught up. The event is real, so it goes — the device renders its own
|
||||
// unknown-code fallback rather than the user losing the notification.
|
||||
|
||||
@@ -105,3 +105,49 @@ describe('FCM message', () => {
|
||||
expect(json).not.toContain('example.com');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
Data-only is the design, and the one exception to it.
|
||||
|
||||
Android composes the banner from its synced catalogue, which is why there is normally no `notification`
|
||||
block. Producer text has no entry to compose from — without a block the message arrives and nothing is
|
||||
shown, which is worse than not sending it.
|
||||
*/
|
||||
describe('FCM message', () => {
|
||||
const catalogue: Catalogue = {
|
||||
version: 7,
|
||||
entries: { 'officer/core:mail': { title: '{n} new emails', thread: 'officer/core:mail' } },
|
||||
};
|
||||
const build = (n: Notification) => __testing.buildMessage('tok', n, { catalogue });
|
||||
|
||||
test('a coded notification is data-only', () => {
|
||||
const m = build({ code: 'officer/core:mail', userId: 1, n: 3 });
|
||||
expect(m.message.notification).toBeUndefined();
|
||||
expect(m.message.data).toEqual({ c: 'officer/core:mail', mv: '7', n: '3' });
|
||||
});
|
||||
|
||||
test("a producer's own words get a notification block, or Android shows nothing", () => {
|
||||
const shaped: Catalogue = {
|
||||
version: 7,
|
||||
entries: { 'officer/core:commander': { title: 'Commander: {title}', body: '{body}' } },
|
||||
};
|
||||
const m = __testing.buildMessage(
|
||||
'tok',
|
||||
{ code: 'officer/core:commander', userId: 1, id: 12, title: 'Voice Memos', body: '3 filed' },
|
||||
{ catalogue: shaped },
|
||||
);
|
||||
// Rendered, not raw — same rule as APNs: the code owns the shape.
|
||||
expect(m.message.notification).toEqual({ title: 'Commander: Voice Memos', body: '3 filed' });
|
||||
});
|
||||
|
||||
test('a body with no title stays off the wire', () => {
|
||||
const m = build({ code: 'officer/core:mail', userId: 1, body: 'please pay' } as Notification);
|
||||
expect(m.message.notification).toBeUndefined();
|
||||
expect(JSON.stringify(m)).not.toContain('please pay');
|
||||
});
|
||||
|
||||
test('a route rides in the data map', () => {
|
||||
const m = build({ code: 'officer/core:commander', userId: 1, route: 'officer://command-hq/12' });
|
||||
expect(m.message.data.r).toBe('officer://command-hq/12');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSecret } from 'officerdb/secret-store';
|
||||
import { getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from 'officerdb';
|
||||
import type { Channel, DeliveryResult, Notification, SendContext } from './types';
|
||||
import { FcmAccessToken, parseServiceAccount } from './fcm-auth';
|
||||
import { renderNotification } from './render';
|
||||
|
||||
// The Google channel. FCM HTTP v1, called directly — no Expo, no firebase-admin.
|
||||
//
|
||||
@@ -53,17 +54,22 @@ type FcmMessage = {
|
||||
message: {
|
||||
token: string;
|
||||
/**
|
||||
* DATA-ONLY — deliberately no `notification` block.
|
||||
* DATA-ONLY, except for a producer that wrote its own words.
|
||||
*
|
||||
* A `notification` block is rendered by the system, which would put the code on the lock screen and
|
||||
* give the app no chance to resolve it. Data-only hands the message to the app, which composes the
|
||||
* banner from its synced catalogue — the same thing the Notification Service Extension does on iOS,
|
||||
* except Android needs no extension for it.
|
||||
* A `notification` block is rendered by the system, which for a CODE would put the code itself on the
|
||||
* lock screen and give the app no chance to resolve it. Data-only hands the message to the app, which
|
||||
* composes the banner from its synced catalogue — the same thing the Notification Service Extension
|
||||
* does on iOS, except Android needs no extension for it.
|
||||
*
|
||||
* Producer text has no catalogue entry to compose from, so it needs the block: without one the message
|
||||
* arrives and nothing is shown. See `types.ts` for why that text is allowed to travel at all.
|
||||
*
|
||||
* The cost is that `priority: high` is load-bearing: a normal-priority data message can be deferred
|
||||
* indefinitely in Doze, and a notification that arrives tomorrow is not a notification.
|
||||
*/
|
||||
data: Record<string, string>;
|
||||
/** Only for producer-supplied text. Absent for every coded notification — see above. */
|
||||
notification?: { title: string; body?: string };
|
||||
android: {
|
||||
priority: 'high';
|
||||
};
|
||||
@@ -75,8 +81,23 @@ function buildMessage(token: string, n: Notification, ctx: SendContext): FcmMess
|
||||
const data: Record<string, string> = { c: n.code, mv: String(ctx.catalogue.version) };
|
||||
if (n.id !== undefined) data.id = String(n.id);
|
||||
if (n.n !== undefined) data.n = String(n.n);
|
||||
if (n.route) data.r = n.route;
|
||||
|
||||
return { message: { token, data, android: { priority: 'high' } } };
|
||||
/*
|
||||
Data-only, except when the producer wrote its own words.
|
||||
|
||||
Data-only is what lets the app build the banner from its synced catalogue, which is the whole design.
|
||||
But a producer's own text has no catalogue entry to build from, so without a `notification` block
|
||||
Android would receive it and show nothing. See `types.ts` for why that text is allowed to travel.
|
||||
*/
|
||||
// Rendered against the catalogue, for the reason `apns-payload.ts` gives: the code owns the shape, the
|
||||
// producer only supplies the values that go in it.
|
||||
const rendered = n.title ? renderNotification(n, ctx.catalogue) : null;
|
||||
const notification = rendered
|
||||
? { title: rendered.title, ...(rendered.body ? { body: rendered.body } : {}) }
|
||||
: undefined;
|
||||
|
||||
return { message: { token, data, ...(notification ? { notification } : {}), android: { priority: 'high' } } };
|
||||
}
|
||||
|
||||
type SendOutcome = { ok: boolean; disposition?: Disposition; reason?: string };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { dispatch, configuredChannels } from './dispatch';
|
||||
@@ -62,12 +63,49 @@ const port = getFreePort();
|
||||
// thing that works until it doesn't. One file, rewritten on every boot, deleted on clean shutdown.
|
||||
const ADDRESS_FILE = join(DATA_PATH, 'notify-server.url');
|
||||
|
||||
/*
|
||||
What an in-process producer must present, when it is not speaking for a signed-in user.
|
||||
|
||||
Two callers reach POST /_officer/notify: a browser through the platform's proxy, which arrives with
|
||||
X-Officer-User injected and can only ever notify itself; and a producer inside the platform — sendoff,
|
||||
chat — which is notifying somebody else and therefore names them in the body. The second had no
|
||||
authentication at all, on the reasoning that binding loopback was enough.
|
||||
|
||||
It stopped being enough when commanders arrived. A commander has a shell on this machine, so "anything
|
||||
on loopback is the platform" became "anything on loopback, including an agent, can notify any user on
|
||||
this server as if the platform had asked".
|
||||
|
||||
Generated per boot and held in MEMORY, announced to the platform over the registration socket alongside
|
||||
the port. Deliberately not a file: a member's commander runs as its own Linux user and cannot read this
|
||||
process, but any file both processes can read is a file that account could read too if it were ever
|
||||
granted a shell in the wrong place. The owner's own commander is not bounded by this and cannot be —
|
||||
it runs as the owner, same as the platform — which is the same reason `execution` permissions are
|
||||
never grantable.
|
||||
*/
|
||||
const PRODUCER_SECRET = randomBytes(32).toString('base64url');
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
/*
|
||||
Everything under `/_officer/` acts on a named user, so all of it needs the secret — not just the
|
||||
send route.
|
||||
|
||||
`X-Officer-User` cannot carry this on its own. It says WHO, and a caller on loopback can write it
|
||||
itself; only the secret says the PLATFORM is the one asking. Guarding just the send route would have
|
||||
left `/_officer/devices`, where the same header decides whose phones are listed, registered and
|
||||
deleted.
|
||||
|
||||
`/_health` and `/mapping` stay open: one is a liveness probe and the other is the code-to-text
|
||||
catalogue, which is shipped to every device and is not a secret.
|
||||
*/
|
||||
if (url.pathname.startsWith('/_officer/') && req.headers.get('X-Officer-Producer') !== PRODUCER_SECRET) {
|
||||
return Response.json({ error: 'not the platform' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
return Response.json({ ok: true, channels: configuredChannels() });
|
||||
}
|
||||
@@ -147,7 +185,7 @@ const connection = createSidecarConnector({
|
||||
// Bun types `Server.port` as optional because a unix-socket server has none; this one is TCP, so it
|
||||
// is a number. Asserted narrowly here — it used to be `as SidecarEvent` on the whole object, which
|
||||
// suppressed this and would have suppressed a genuinely wrong event shape too.
|
||||
connection.send({ type: 'notify:server', port: server.port! });
|
||||
connection.send({ type: 'notify:server', port: server.port!, secret: PRODUCER_SECRET });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,11 +16,26 @@ import type { Notification } from './types';
|
||||
/** The generic shown when a device cannot render for itself. Never a decoy sentence — see the doc. */
|
||||
export const FALLBACK_TITLE = 'Officer';
|
||||
|
||||
/*
|
||||
`{n}` and `{id}` are the numbers a producer sends. `{title}` and `{body}` are its own words, and they
|
||||
interpolate for the same reason the others do: so the catalogue decides the SHAPE and the producer only
|
||||
supplies values.
|
||||
|
||||
That distinction is the whole answer to "where is the structure defined". A commander sends its name and
|
||||
a sentence; the catalogue is what turns those into `Commander Voice Memos`. Without this the producer's
|
||||
title reached the lock screen untouched, and "it should always say Commander" was left to whether the
|
||||
commander remembered — which is not a structure, it is a hope.
|
||||
*/
|
||||
const interpolate = (template: string, n: Notification): string =>
|
||||
template.replace(/\{(n|id)\}/g, (_, key: 'n' | 'id') => {
|
||||
const value = key === 'n' ? n.n : n.id;
|
||||
return value === undefined ? '' : String(value);
|
||||
});
|
||||
template
|
||||
.replace(/\{(n|id|title|body)\}/g, (_, key: 'n' | 'id' | 'title' | 'body') => {
|
||||
const value = n[key];
|
||||
return value === undefined ? '' : String(value);
|
||||
})
|
||||
// A placeholder that resolved to nothing leaves the punctuation that introduced it — `Commander: {title}`
|
||||
// with no title renders `Commander: `. Trimming a dangling separator is what makes a template safe to
|
||||
// write with one, rather than every template having to assume its values are always present.
|
||||
.replace(/[\s:\u2014-]+$/, '');
|
||||
|
||||
export type Rendered = { title: string; body?: string; thread: string };
|
||||
|
||||
@@ -32,7 +47,13 @@ export type Rendered = { title: string; body?: string; thread: string };
|
||||
*/
|
||||
export function renderNotification(n: Notification, catalogue: Catalogue): Rendered {
|
||||
const entry = catalogue.entries[n.code];
|
||||
if (!entry) return { title: FALLBACK_TITLE, thread: n.code };
|
||||
|
||||
// A code the catalogue does not know, but with words of its own: send them as they came. There is no
|
||||
// shape to impose and dropping a real event over a missing mapping would be worse.
|
||||
if (!entry) {
|
||||
if (n.title) return { title: n.title, ...(n.body ? { body: n.body } : {}), thread: n.code };
|
||||
return { title: FALLBACK_TITLE, thread: n.code };
|
||||
}
|
||||
|
||||
const body = entry.body ? interpolate(entry.body, n) : undefined;
|
||||
return {
|
||||
|
||||
@@ -6,8 +6,19 @@ import type { Catalogue } from '../../notifications/catalogue';
|
||||
// (`src/servers/notifications/catalogue.ts`), which is synced to the device — so a new kind of
|
||||
// notification is a server change and never an app build. See docs/push-notifications.md.
|
||||
//
|
||||
// Content does not travel. Apple and Google see the code and the numbers, never a subject line, a
|
||||
// sender, a filename, an error string or a path. Anything readable is fetched over the tailnet by id.
|
||||
// Content does not travel BY DEFAULT. Apple and Google see the code and the numbers, never a subject
|
||||
// line, a sender, a filename, an error string or a path. Anything readable is fetched over the tailnet
|
||||
// by id.
|
||||
//
|
||||
// A producer may override that per notification with `title`/`body`, and one kind of producer needs to:
|
||||
// a commander, whose whole output is a sentence only it could write. "3 items" and a deeplink is not
|
||||
// worth waking a phone for. The owner's decision, on 2026-08-24, and the same relaxation `sendoff.landed`
|
||||
// already carries.
|
||||
//
|
||||
// What that buys and what it costs: the text is on the lock screen, and it went through Apple or Google
|
||||
// to get there. The control is at the PRODUCER, not here — a commander is told what not to put in a
|
||||
// message, which is where the judgement about what is sensitive actually lives. This file cannot make
|
||||
// that call and does not try.
|
||||
|
||||
export type Notification = {
|
||||
/**
|
||||
@@ -23,6 +34,20 @@ export type Notification = {
|
||||
n?: number;
|
||||
/** The thing this is about. The device fetches by it; never a human-readable string. */
|
||||
id?: string | number;
|
||||
/**
|
||||
* Text the producer wrote, shown as-is instead of the catalogue's template.
|
||||
*
|
||||
* Absent for almost everything — the catalogue is the right answer whenever the words are known in
|
||||
* advance, because then they never leave the tailnet. Present when only the producer knows what
|
||||
* happened. See the note above for the trade.
|
||||
*/
|
||||
title?: string;
|
||||
body?: string;
|
||||
/**
|
||||
* Where tapping it goes — `officer://command-hq/12`. Overrides the catalogue's `route`, which cannot
|
||||
* express a destination that varies per notification beyond `{id}`.
|
||||
*/
|
||||
route?: string;
|
||||
};
|
||||
|
||||
/** What every channel is handed alongside the notification. */
|
||||
|
||||
@@ -128,7 +128,19 @@ export type SidecarCommand =
|
||||
* `<name>:server`, where the name is the sidecar's own — `music:server`, `pty:server`, and whatever a
|
||||
* plugin nobody has written yet decides to call itself. Deliberately open: see the note in SidecarEvent.
|
||||
*/
|
||||
export type SidecarPortAnnouncement = { type: `${string}:server`; port: number };
|
||||
export type SidecarPortAnnouncement = {
|
||||
type: `${string}:server`;
|
||||
port: number;
|
||||
/**
|
||||
* A per-boot secret for callers INSIDE the platform that reach this sidecar directly rather than
|
||||
* through its proxy — announced here, in memory on both sides, never written to disk.
|
||||
*
|
||||
* Optional because only a sidecar with a producer path to guard has one. `notify` is the first: its
|
||||
* send route accepts a target user in the body, which loopback alone stopped being enough to protect
|
||||
* once commanders could run a shell on the same machine.
|
||||
*/
|
||||
secret?: string;
|
||||
};
|
||||
|
||||
export type SidecarEvent =
|
||||
| { type: 'pong'; id: string }
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeAtomic } from './write-atomic';
|
||||
|
||||
/*
|
||||
The property: the final name never holds a partial file.
|
||||
|
||||
A test cannot observe the instant between two syscalls, so what is pinned here is the shape that makes
|
||||
the property true — the data goes to `<name>.part` first, the final name only ever appears complete,
|
||||
and a failure leaves no fragment under either name.
|
||||
*/
|
||||
|
||||
const dir = () => mkdtempSync(join(tmpdir(), 'zz-atomic-'));
|
||||
|
||||
describe('writeAtomic', () => {
|
||||
test('the file ends up at the final name, complete', async () => {
|
||||
const d = dir();
|
||||
try {
|
||||
const path = join(d, 'memo.md');
|
||||
await writeAtomic(path, 'hello');
|
||||
expect(readFileSync(path, 'utf8')).toBe('hello');
|
||||
expect(existsSync(`${path}.part`)).toBe(false);
|
||||
} finally {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('it overwrites an existing file rather than refusing', async () => {
|
||||
const d = dir();
|
||||
try {
|
||||
const path = join(d, 'memo.md');
|
||||
writeFileSync(path, 'old');
|
||||
await writeAtomic(path, 'new');
|
||||
expect(readFileSync(path, 'utf8')).toBe('new');
|
||||
} finally {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// A failed write must not leave a `.part` behind — only an interrupted PROCESS should, which is the
|
||||
// case worth being able to see in the folder.
|
||||
test('a failed write leaves nothing under either name', async () => {
|
||||
const d = dir();
|
||||
try {
|
||||
// The parent is a FILE, so neither the .part write nor the rename can succeed.
|
||||
writeFileSync(join(d, 'blocker'), 'x');
|
||||
const path = join(d, 'blocker', 'memo.md');
|
||||
await expect(writeAtomic(path, 'x')).rejects.toThrow();
|
||||
expect(existsSync(`${path}.part`)).toBe(false);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
} finally {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Bun.write creates the parent chain itself, which is why this takes no mkdir option and why the
|
||||
// callers it replaced could drop theirs.
|
||||
test('a missing directory is created, not an error', async () => {
|
||||
const d = dir();
|
||||
try {
|
||||
const path = join(d, 'nested', 'deep', 'memo.md');
|
||||
await writeAtomic(path, 'x');
|
||||
expect(readFileSync(path, 'utf8')).toBe('x');
|
||||
} finally {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a Blob writes the same way as a string', async () => {
|
||||
const d = dir();
|
||||
try {
|
||||
const path = join(d, 'blob.bin');
|
||||
await writeAtomic(path, new Blob([new Uint8Array([1, 2, 3])]));
|
||||
expect([...readFileSync(path)]).toEqual([1, 2, 3]);
|
||||
} finally {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { rename, rm } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Write a file so that its final name never holds a partial one.
|
||||
*
|
||||
* ── Why every write into a user's tree has to do this ──
|
||||
*
|
||||
* `Bun.write(path, data)` streams to the name the file will end up with, so for as long as the write
|
||||
* takes, that name exists holding a fragment — and anything reading the folder cannot tell. The failure
|
||||
* is silent rather than loud: a truncated m4a decodes fine, it is simply short. A commander transcribing
|
||||
* recordings as they land would produce a clean transcript of the first few seconds of a two-minute
|
||||
* recording and report success.
|
||||
*
|
||||
* A rename within one directory is atomic, so afterwards the final name either does not exist or is a
|
||||
* complete file. Every consumer gets that for free — a watcher, an agent, the phone's own upload
|
||||
* verification — instead of each inventing a heuristic like "the size stopped changing", which cannot
|
||||
* tell a finished write from a stalled one.
|
||||
*
|
||||
* ── Why it is a function rather than a rule ──
|
||||
*
|
||||
* It was a rule, applied at the two upload routes on 2026-08-24, and by the next morning the platform
|
||||
* had a dozen other writes that put files in the same folders — an OCR result beside its image, a
|
||||
* download landing, a save from the editor — every one of them straight to the final name. A guarantee
|
||||
* that holds for two of fourteen paths is not a guarantee, and the next person to add a fifteenth would
|
||||
* have had nothing to copy.
|
||||
*
|
||||
* ── `.part`, visibly ──
|
||||
*
|
||||
* A leftover should be VISIBLE, named for what it was going to be, next to where it belongs — not a
|
||||
* dotfile. Nothing sweeps them up: a job deleting files it did not create is a worse trade than a
|
||||
* fragment somebody can see. Only an interrupted PROCESS leaves one, since a failed write cleans up
|
||||
* after itself below.
|
||||
*/
|
||||
/** What `Bun.write` accepts, spelled out — `Parameters<typeof Bun.write>[1]` resolves to the first
|
||||
* overload only, which is `BunFile` and rejects the string and Buffer every caller actually passes. */
|
||||
type WritableData = string | Blob | ArrayBufferView | ArrayBufferLike | Response;
|
||||
|
||||
export async function writeAtomic(path: string, data: WritableData): Promise<void> {
|
||||
// No mkdir: `Bun.write` creates the parent chain itself (`createPath` defaults on), which is why the
|
||||
// callers this replaced could drop theirs too.
|
||||
const partPath = `${path}.part`;
|
||||
try {
|
||||
// Bun.write's overloads do not union cleanly — every one of them accepts this at runtime, and the
|
||||
// signature is a set of alternatives rather than one parameter that takes them all.
|
||||
await Bun.write(partPath, data as Parameters<typeof Bun.write>[1]);
|
||||
await rename(partPath, path);
|
||||
} catch (err) {
|
||||
await rm(partPath, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
||||
import { appRegistryMetas as davMetas } from '../apps/Dav';
|
||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||
import { appRegistryMetas as todoBoardMetas } from '../apps/TodoBoard';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { globalQueryKey } from 'hooks/useGlobal';
|
||||
import { metasToRegistry } from './useAppRegistry';
|
||||
@@ -32,6 +33,7 @@ export const apps = [
|
||||
...sendOffMetas,
|
||||
...davMetas,
|
||||
...commandHqMetas,
|
||||
...todoBoardMetas,
|
||||
...appStoreMetas,
|
||||
...pluginsMetas,
|
||||
];
|
||||
|
||||
@@ -255,7 +255,14 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
||||
if (cancelled) return;
|
||||
setResume({
|
||||
id: selectedSessionId,
|
||||
model: detail.model,
|
||||
// `officerModel`, not `detail.model` — the same rule the /chat screen and Command HQ both state
|
||||
// at length, and the only one of the three call sites that had it wrong. `detail.model` is what
|
||||
// the API served (`claude-fable-5`); the picker offers Officer ids (`claude-code/fable`). Seeding
|
||||
// the picker from the wrong id space showed the raw API string in the trigger, and then SENT it:
|
||||
// `isClaudeModel` is `startsWith('claude-code')`, so `claude-fable-5` fails it and the turn
|
||||
// dispatches to OpenCode — caught only when officer still holds the session in memory, because
|
||||
// that is the one case where the server's own record overrules the socket.
|
||||
model: detail.officerModel ?? null,
|
||||
messages: detail.messages as unknown as ChatMessage[],
|
||||
total: detail.total,
|
||||
offset: detail.offset,
|
||||
|
||||
@@ -44,6 +44,14 @@ type EmbeddableChatProps = {
|
||||
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
/**
|
||||
* A detached loop this conversation armed, shown above the composer beside the background tasks.
|
||||
*
|
||||
* Passed in rather than derived, because a watcher is not a background task: it has no task id and is
|
||||
* not in `messages` at all — the platform knows about it from a pid file, and only the screen holding
|
||||
* the commander's metadata can say what it watches.
|
||||
*/
|
||||
watcher?: { label: string; running: boolean };
|
||||
/**
|
||||
* Refuse input entirely, with a reason the composer shows.
|
||||
*
|
||||
|
||||
@@ -52,6 +52,7 @@ type UseEmbeddableChatParams = {
|
||||
contextId?: string;
|
||||
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
|
||||
isCommander?: boolean;
|
||||
watcher?: { label: string; running: boolean };
|
||||
emptyState?: ReactNode;
|
||||
};
|
||||
|
||||
@@ -624,6 +625,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
would lose the brand-new composer, which has no session yet.
|
||||
*/
|
||||
isCommander: params.context === 'command-hq' || params.isCommander === true,
|
||||
watcher: params.watcher,
|
||||
emptyState: params.emptyState,
|
||||
/*
|
||||
Claude's transcript id, which is what a commander is keyed on everywhere — the Command HQ row, the
|
||||
|
||||
@@ -243,6 +243,28 @@ const TaskStrip = ({ label, children, trailing, divided }: TaskStripProps) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* The commander's own watcher, docked in the same strip as the background tasks.
|
||||
*
|
||||
* A watcher is not a background TASK — it is a detached loop that outlives the turn that armed it, so it
|
||||
* has no task id and never appears in `messages`. It is the same KIND of thing to the person reading:
|
||||
* something running on their behalf that they want to see without scrolling. So it reads the same, sits
|
||||
* in the same place, and says what it is watching rather than that something is watching.
|
||||
*/
|
||||
export const WatcherStrip = ({ label, running }: { label: string; running: boolean }) => (
|
||||
<TaskStrip label={running ? 'watching' : 'not watching'}>
|
||||
<span
|
||||
className={`inline-flex min-w-0 shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs ${
|
||||
running ? 'border-success/40 text-foreground' : 'border-border text-muted-foreground'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${running ? 'bg-success' : 'bg-muted-foreground/40'}`} />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
</TaskStrip>
|
||||
);
|
||||
|
||||
// ── Chip ──
|
||||
|
||||
type TaskChipProps = {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { DragEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Loader2, Send, Square, ListPlus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { COMMANDER_FILES_PARAM } from '../../CommandHQ/command-hq-routes';
|
||||
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
||||
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
|
||||
import { useCommanderRecordPaths } from '../useCommanderRecordPaths';
|
||||
@@ -12,7 +14,7 @@ import { useWorkspace } from '../../../components/Workspace';
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
import { AttachButton } from './AttachButton';
|
||||
import { WebpageDialog } from './WebpageDialog';
|
||||
import { BackgroundTaskTray } from './BackgroundTaskTray';
|
||||
import { BackgroundTaskTray, WatcherStrip } from './BackgroundTaskTray';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type InputAreaProps = {
|
||||
@@ -27,6 +29,26 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
|
||||
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
|
||||
const recordPaths = useCommanderRecordPaths(manager.isCommander ? manager.claudeSessionId : null);
|
||||
|
||||
/*
|
||||
A commander's own files, opened beside the transcript — `useCommanderFilesPanel` renders the panel off
|
||||
this same param.
|
||||
|
||||
A param rather than a route, and a panel rather than a link: a commander's directory is the one place
|
||||
`/files` cannot reach (a different root entirely), and a link would open a second copy of the whole app
|
||||
in a new tab and lose the conversation being read.
|
||||
*/
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const toggleCommanderFiles = useCallback(
|
||||
() =>
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (next.get(COMMANDER_FILES_PARAM) === '1') next.delete(COMMANDER_FILES_PARAM);
|
||||
else next.set(COMMANDER_FILES_PARAM, '1');
|
||||
return next;
|
||||
}),
|
||||
[setSearchParams],
|
||||
);
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
@@ -97,6 +119,8 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
||||
)}
|
||||
|
||||
{manager.watcher && <WatcherStrip label={manager.watcher.label} running={manager.watcher.running} />}
|
||||
|
||||
<BackgroundTaskTray messages={messages} sessionId={sessionId} />
|
||||
|
||||
<QueuedList queued={queued} onRemove={unqueue} onSendNow={sendQueuedNow} />
|
||||
@@ -201,6 +225,8 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
onBackgroundJob={() => setJobDialogOpen(true)}
|
||||
planMode={planMode}
|
||||
onPlanModeChange={setPlanMode}
|
||||
filesOpen={manager.isCommander && searchParams.get(COMMANDER_FILES_PARAM) === '1'}
|
||||
onToggleFiles={manager.isCommander ? toggleCommanderFiles : undefined}
|
||||
/>
|
||||
|
||||
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CirclePlay, ClipboardList } from 'lucide-react';
|
||||
import { CirclePlay, ClipboardList, FolderOpen } from 'lucide-react';
|
||||
import { toneText } from '@/components/Data';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { ModelOption } from 'state/useModels';
|
||||
@@ -52,6 +52,16 @@ type ModelSelectorProps = {
|
||||
* about what this turn will cost.
|
||||
*/
|
||||
planMode?: boolean;
|
||||
/**
|
||||
* Open this commander's own files, and whether they are open — absent on an ordinary chat, where the
|
||||
* Plan control takes this place instead.
|
||||
*
|
||||
* Given as props rather than read from the URL here on purpose: this component is rendered bare in its
|
||||
* own tests, and reaching for `useSearchParams` inside it made every one of them fail on a missing
|
||||
* Router. A control that has to be mounted in a route to render is not a control any more.
|
||||
*/
|
||||
filesOpen?: boolean;
|
||||
onToggleFiles?: () => void;
|
||||
onPlanModeChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -66,6 +76,8 @@ export function ModelSelector({
|
||||
hasStarted,
|
||||
onBackgroundJob,
|
||||
planMode = false,
|
||||
filesOpen = false,
|
||||
onToggleFiles,
|
||||
onPlanModeChange,
|
||||
}: ModelSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -240,7 +252,33 @@ export function ModelSelector({
|
||||
Sonnet, quietly, and a user who picked it expecting Opus would never find that out. Officer does NOT
|
||||
auto-enter plan mode for it, because the CLI does not either; it says so instead.
|
||||
*/}
|
||||
{onPlanModeChange && currentHarness !== 'opencode' && (
|
||||
{/*
|
||||
A commander's own directory, instead of Plan.
|
||||
|
||||
Instead, not beside: a commander is an agent that acts while nobody watches, and plan mode is the
|
||||
opposite posture — research, propose, change nothing until a human approves. There is no human in
|
||||
the loop to approve, so the control promises something that cannot happen. What is genuinely
|
||||
missing there is a way to SEE what the commander has been writing, and its directory is
|
||||
unreachable from `/files` (a different root entirely), so this is the only door to it.
|
||||
|
||||
`?files=1` rather than a route: it opens an ephemeral panel beside the transcript in this same
|
||||
workspace — `useCommanderFilesPanel`. A link would open a second copy of the whole app in a new
|
||||
tab and lose the conversation you were reading.
|
||||
*/}
|
||||
{onToggleFiles && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFiles}
|
||||
aria-pressed={filesOpen}
|
||||
title="This commander's own files"
|
||||
className={`shrink-0 cursor-pointer rounded-md p-1.5 transition-colors ${
|
||||
filesOpen ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:bg-foreground/10'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
{!onToggleFiles && onPlanModeChange && currentHarness !== 'opencode' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlanModeChange(!planMode)}
|
||||
|
||||
@@ -14,6 +14,16 @@ const tool = (id: string, isError?: boolean): ChatMessage => ({
|
||||
output: 'ok',
|
||||
...(isError ? { isError: true } : {}),
|
||||
});
|
||||
/** A tool call we never saw come back — no `output`. The turn is still holding it. */
|
||||
const pending = (id: string): ChatMessage => ({ role: 'tool', toolName: 'Bash', toolInput: {}, toolCallId: id });
|
||||
/** A finished background task the agent woke up to — see `startsTurn` in types.ts. */
|
||||
const woke = (id: string): ChatMessage => ({
|
||||
role: 'task',
|
||||
taskId: id,
|
||||
description: 'the task that woke it',
|
||||
status: 'completed',
|
||||
startsTurn: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The shape under test is the row sequence, not the keys.
|
||||
@@ -243,6 +253,86 @@ describe('buildRows', () => {
|
||||
test('a bare question with no work yet produces no marker and no fold', () => {
|
||||
expect(shape([user('go')], true)).toEqual(['user']);
|
||||
});
|
||||
|
||||
// ── A turn the agent woke up to ──
|
||||
//
|
||||
// The bug these pin, reported 2026-08-25 against a commander: the harness delivers a finished background
|
||||
// task as the agent's next *user* message, and the transcript parser used to drop that message so it would
|
||||
// not render as an XML envelope you appear to have typed. Dropping it also dropped the turn boundary, so
|
||||
// every turn a commander woke up for merged into the one before it. A commander's whole life replayed as
|
||||
// one turn, with every answer it had ever given folded away inside the seam.
|
||||
|
||||
test('a task the agent woke up to starts a turn, and the answer before it stays visible', () => {
|
||||
expect(shape([user('go'), tool('a'), say('done, watching'), woke('t1'), tool('b'), say('it fired')])).toEqual([
|
||||
'user',
|
||||
'fold',
|
||||
'assistant',
|
||||
'task',
|
||||
'fold',
|
||||
'assistant',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the same messages WITHOUT the flag are what used to swallow the answer', () => {
|
||||
const merged: ChatMessage[] = [
|
||||
user('go'),
|
||||
tool('a'),
|
||||
say('done, watching'),
|
||||
{ role: 'task', taskId: 't1', description: 'fired', status: 'completed' },
|
||||
tool('b'),
|
||||
say('it fired'),
|
||||
];
|
||||
expect(shape(merged)).toEqual(['user', 'fold', 'assistant']);
|
||||
});
|
||||
|
||||
test('a task the agent STARTED mid-turn is work, not a boundary', () => {
|
||||
const started: ChatMessage[] = [
|
||||
user('go'),
|
||||
{ role: 'task', taskId: 't1', description: 'building' },
|
||||
say('off it goes'),
|
||||
];
|
||||
expect(shape(started)).toEqual(['user', 'fold', 'assistant']);
|
||||
});
|
||||
|
||||
test('the woken turn is never folded away — it is the question that turn answers', () => {
|
||||
const rows = buildRows([user('go'), say('watching'), woke('t1'), tool('b'), say('it fired')], false, 5);
|
||||
expect(rows[2]?.kind === 'message' && rows[2].message.role).toBe('task');
|
||||
});
|
||||
|
||||
// The reported screenshot, in miniature. Merged, the segment ended on a tool call, so the answer became
|
||||
// mid-turn work and folded, and the marker was stamped over the whole conversation. Split, the answer is
|
||||
// back on screen and the marker — still earned, by a woken turn that really did say nothing — is confined
|
||||
// to the two rows it describes.
|
||||
test('merged, the answer is swallowed and the marker covers everything', () => {
|
||||
const merged: ChatMessage[] = [
|
||||
user('go'),
|
||||
say('armed'),
|
||||
{ role: 'task', taskId: 't1', description: 'fired', status: 'completed' },
|
||||
tool('b'),
|
||||
];
|
||||
expect(shape(merged)).toEqual(['user', 'fold', 'turn-end']);
|
||||
});
|
||||
|
||||
test('split, the answer survives and the marker describes only the woken turn', () => {
|
||||
expect(shape([user('go'), say('armed'), woke('t1'), tool('b')])).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'task',
|
||||
'fold',
|
||||
'turn-end',
|
||||
]);
|
||||
});
|
||||
|
||||
// ── A tool we never saw come back ──
|
||||
|
||||
test('a turn holding an unfinished tool call is not marked finished', () => {
|
||||
expect(shape([user('go'), pending('a')], false, 0)).toEqual(['user', 'tool']);
|
||||
});
|
||||
|
||||
test('a reload mid-turn does not claim the turn finished before the socket answers', () => {
|
||||
// `isGenerating` is false until `attach` comes back, so for those frames the transcript is all we have.
|
||||
expect(shape([user('go'), tool('a'), pending('b')])).toEqual(['user', 'fold']);
|
||||
});
|
||||
});
|
||||
|
||||
/** What tool rows key their expansion off. Getting this wrong expands a whole transcript at once. */
|
||||
|
||||
@@ -106,10 +106,12 @@ function buildTurn({ segment, offset, fold, complete, live }: TurnParams): ChatR
|
||||
const push = (message: ChatMessage, i: number) =>
|
||||
rows.push({ kind: 'message', key: keyOf(message, offset + i), message, live });
|
||||
|
||||
// Your message is never folded — it is the question the whole turn is an answer to.
|
||||
// Whatever prompted the turn is never folded — it is the question the whole turn is an answer to. Usually
|
||||
// that is your message; for a turn the agent woke up to, it is the task that woke it.
|
||||
let start = 0;
|
||||
if (segment[0]?.role === 'user') {
|
||||
push(segment[0], 0);
|
||||
const opener = segment[0];
|
||||
if (opener && (opener.role === 'user' || (opener.role === 'task' && opener.startsTurn))) {
|
||||
push(opener, 0);
|
||||
start = 1;
|
||||
}
|
||||
|
||||
@@ -151,8 +153,24 @@ function buildTurn({ segment, offset, fold, complete, live }: TurnParams): ChatR
|
||||
flush();
|
||||
}
|
||||
|
||||
// A tool we never saw come back. The turn is then not over — it is still running, or it was cut off
|
||||
// holding that call — and either way "finished without saying anything" is a claim the messages do not
|
||||
// support. It matters most on a reload, which renders from the transcript with `isGenerating` still
|
||||
// false until the socket attaches: for those few frames every unfinished turn looks finished.
|
||||
//
|
||||
// A COMPLETED tool call at the end is a different thing and keeps its marker: the tool came back, the
|
||||
// agent said nothing, and that is exactly the silence this row exists to name.
|
||||
const last = segment.at(-1);
|
||||
const toolStillOut = last?.role === 'tool' && last.output === undefined;
|
||||
|
||||
// Nothing was said and nothing went wrong — say so, rather than ending on a collapsed tool call.
|
||||
if (complete && !tail.some(isAnswer) && !tail.some(isFailure) && (head.length > 0 || tail.length > 0)) {
|
||||
if (
|
||||
complete &&
|
||||
!toolStillOut &&
|
||||
!tail.some(isAnswer) &&
|
||||
!tail.some(isFailure) &&
|
||||
(head.length > 0 || tail.length > 0)
|
||||
) {
|
||||
rows.push({ kind: 'turn-end', key: `e:${offset}` });
|
||||
}
|
||||
|
||||
@@ -178,15 +196,20 @@ export function buildRows(messages: ChatMessage[], isGenerating: boolean, histor
|
||||
// half `complete` — tool calls, no answer — and printed "Turn finished · no closing message" across a
|
||||
// turn that was visibly still working, with the header still saying so. Reported as "the output is very
|
||||
// misleading", and it was: the UI said the opposite of what was happening.
|
||||
const userAt: number[] = [];
|
||||
//
|
||||
// A background task reporting in is the other kind of prompt, and it starts a turn for the same reason: the
|
||||
// agent was asleep and this is what it woke up to. Live that boundary needs no stating, because the work
|
||||
// arrives as it happens; on a reload it has to be reconstructed, which is what `startsTurn` is for.
|
||||
const turnAt: number[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
if (m.role === 'user' && !m.injected) userAt.push(i);
|
||||
if (m.role === 'user' && !m.injected) turnAt.push(i);
|
||||
else if (m.role === 'task' && m.startsTurn) turnAt.push(i);
|
||||
});
|
||||
|
||||
// Every user message is a turn boundary — including the last one, which used to be excluded because the
|
||||
// Every prompt is a turn boundary — including the last one, which used to be excluded because the
|
||||
// turn after it was by definition the live one. Now the final segment is an ordinary turn that folds or
|
||||
// not on the same rule as the rest.
|
||||
const bounds = [0, ...userAt.filter((i) => i > 0), messages.length].filter((v, i, a) => i === 0 || v !== a[i - 1]);
|
||||
const bounds = [0, ...turnAt.filter((i) => i > 0), messages.length].filter((v, i, a) => i === 0 || v !== a[i - 1]);
|
||||
|
||||
const rows: ChatRow[] = [];
|
||||
for (let i = 0; i < bounds.length - 1; i++) {
|
||||
|
||||
@@ -77,6 +77,17 @@ export type ChatMessage =
|
||||
taskType?: string;
|
||||
status?: 'completed' | 'failed' | 'stopped';
|
||||
summary?: string;
|
||||
/**
|
||||
* This row is the notification the agent WOKE UP to, not a task it started mid-turn — so it opens a
|
||||
* turn, the way a user message does. Only the transcript parser sets it, because only a reload has to
|
||||
* reconstruct a boundary the live socket never needed to state.
|
||||
*
|
||||
* The mirror image of `injected` on a user message: that one is a message that does not start a turn,
|
||||
* this is a non-message that does. Both exist because `turn-rows` derives turns from the message list
|
||||
* alone, and both were added after that derivation printed "Turn finished · no closing message" over a
|
||||
* turn that had not finished.
|
||||
*/
|
||||
startsTurn?: true;
|
||||
}
|
||||
| {
|
||||
// The harness rewriting its own context, start to finish, in one row — the same shape as `task`,
|
||||
|
||||
@@ -192,7 +192,15 @@ function NewChat(props: NewChatProps) {
|
||||
|
||||
// context 'chat' tells the backend to run this session from the caller's own home, so
|
||||
// its transcript lands in Claude's own store as an isolated project group (source of truth).
|
||||
const chat = useChat(undefined, locationState?.model, {
|
||||
//
|
||||
// `props.model` first, for the reason spelled out on `initialModel` below: `locationState.model` is
|
||||
// written by nothing and is always undefined on a resume. That was fixed there and missed HERE, which
|
||||
// is the call that matters — this is the model `useChat` seeds `selectedModel` from, so leaving it
|
||||
// undefined meant the composer sent no model at all. The server then fell back to `DEFAULT_MODEL`, the
|
||||
// bare harness, which names no tier: the picker rendered the raw string `claude-code`, and `record()`
|
||||
// rightly refused to store it, so nothing ever wrote a real value and the next turn did the same again.
|
||||
// Clicking a session from the list hid it — that navigation DOES carry location state.
|
||||
const chat = useChat(undefined, props.model ?? locationState?.model, {
|
||||
resumeSummary,
|
||||
resumeSessionId,
|
||||
initialMessages,
|
||||
|
||||
@@ -360,6 +360,17 @@ export const CodeEditorView = ({
|
||||
// extension is what decides whether JSX is legal. Without it every tag in a `.tsx` file
|
||||
// was underlined as a syntax error. See `monaco-setup.ts`.
|
||||
path={activeFile.path}
|
||||
/*
|
||||
Do not dispose the model on unmount. It is shared — `path` keys it by file URI globally,
|
||||
so two panels on one file hold the same model — and the default (`false`) disposes it
|
||||
from under whoever else is using it. With `key={activeFile.path}` above, that unmount is
|
||||
every TAB SWITCH, not just closing a panel.
|
||||
|
||||
The cost is one retained model per file opened this session, which `useEditorState`'s
|
||||
shared buffers now mirror exactly: the buffer for a file nobody has open is dropped, and
|
||||
the model is not. Worth revisiting together if a long session ever shows it.
|
||||
*/
|
||||
keepCurrentModel
|
||||
theme={theme}
|
||||
language={getLanguage(activeFile.name)}
|
||||
value={activeFile.content}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user