Phase 6.5: first-run setup wizard and a tour that follows what was set up #12

Merged
alex merged 163 commits from refactor/phase-6-5-setup into main 2026-10-29 18:47:09 +01:00
Owner

Phase 6.5 of the DDD/CQRS refactor: a first-run setup wizard and a reworked guided tour. Based on main.

What changes

The steps come from the kinds of plugin. Each contribution kind in the plugin host can declare a setup step: runtimes, trackers and channels do today. Setup.plan puts those between the app's own steps (welcome, first project, done), and each step lists the plugins that offer that kind. The app has a screen for each step it knows. An unknown step, such as a future kind's, renders a generic screen: the plugins of that kind with their switch, permissions and Configure form. So a new kind of plugin gets a step without any wizard code.

/setup has its own layout and no app shortcuts or tour. Its steps:

  • Welcome: language and theme.
  • AI runtimes: each runtime with what was detected on this machine (installed and version, or why not and how to install it). The runtime tasks run on is chosen here, plus the planning and review models.
  • Trackers: turn tracker plugins on, and optionally add and test a first connection.
  • Notifications and chat: channel plugins with Configure, status and Test.
  • First project: a folder and workflow, and optionally a tracker source.
  • Done: a summary, then "Take the tour" or "Go to the board".

Every step can be skipped, and Settings › General can run the setup again.

Who sees it. The new setup settings section records the version of the setup the user finished. A fresh install is redirected to /setup by a 302 at the first render, so nothing flickers. A later release that adds a step shows only the new step. Migration 0013 marks installs that already have a project or a finished tour as through version 1, so an upgrade never shows the wizard.

The runtime is a setting. Sessions run on agents.runtime, falling back to Claude Code when that plugin is off. Adding it surfaced a bug, now fixed: the server's settings cleanup and the General form both rebuilt the agents section field by field, so they would have reset the runtime on every save.

The tour is data with conditions. A step that needs something to exist (a project, a tracker, a channel) is skipped when it doesn't, and the progress counts only the steps shown. The tour no longer starts by itself: the setup's last step and Settings start it. Every step was checked against today's screens.

The plugin card is a shared molecule. PluginOptionRow is now used both in Settings and in the wizard.

Checks

  • 810 tests (745 before), lint, typecheck, Storybook and the Nuxt build all pass.
  • Production build in headless Chrome:
    • A fresh install is sent to /setup. The walk-through covered switching the language, Claude Code detected, a Redmine connection tested against a fake Redmine, channels turned off, and a project added. The tour that followed left out the notifications step, because the channels were off.
    • With every plugin off and every step skipped, the skips were recorded and the tour showed the 10 steps that apply.
    • An install marked as through setup v1 is never redirected.
  • On an upgrade (an existing data dir with a project, with migration 0013 not yet run): / answers 200 instead of a redirect, and the setup is recorded as through version 1.

Known and left

  • The path where an upgrade adds a step (only that step is shown) is covered by unit tests only. Every step today is version 1, so there was nothing to walk end to end.
  • Skips made during a run are forgotten if the page is reloaded mid-run. The current step is kept.
  • The Done step shows "Go to the board" both in its body and as the Finish button.
  • Installing plugins from the Forgejo plugin repository isn't here: plugins are only turned on and off. The wizard's plugin lists are written so installed plugins will appear in them unchanged.
  • The packaged desktop app wasn't rebuilt and nothing ran on Windows. There is still no Forgejo runner.

🤖 Generated with Claude Code

Phase 6.5 of the DDD/CQRS refactor: a first-run setup wizard and a reworked guided tour. Based on `main`. ## What changes **The steps come from the kinds of plugin.** Each contribution kind in the plugin host can declare a setup step: runtimes, trackers and channels do today. `Setup.plan` puts those between the app's own steps (welcome, first project, done), and each step lists the plugins that offer that kind. The app has a screen for each step it knows. An unknown step, such as a future kind's, renders a generic screen: the plugins of that kind with their switch, permissions and Configure form. So a new kind of plugin gets a step without any wizard code. **`/setup`** has its own layout and no app shortcuts or tour. Its steps: - **Welcome:** language and theme. - **AI runtimes:** each runtime with what was detected on this machine (installed and version, or why not and how to install it). The runtime tasks run on is chosen here, plus the planning and review models. - **Trackers:** turn tracker plugins on, and optionally add and test a first connection. - **Notifications and chat:** channel plugins with Configure, status and Test. - **First project:** a folder and workflow, and optionally a tracker source. - **Done:** a summary, then "Take the tour" or "Go to the board". Every step can be skipped, and Settings › General can run the setup again. **Who sees it.** The new `setup` settings section records the version of the setup the user finished. A fresh install is redirected to `/setup` by a 302 at the first render, so nothing flickers. A later release that adds a step shows only the new step. Migration 0013 marks installs that already have a project or a finished tour as through version 1, so an upgrade never shows the wizard. **The runtime is a setting.** Sessions run on `agents.runtime`, falling back to Claude Code when that plugin is off. Adding it surfaced a bug, now fixed: the server's settings cleanup and the General form both rebuilt the agents section field by field, so they would have reset the runtime on every save. **The tour is data with conditions.** A step that needs something to exist (a project, a tracker, a channel) is skipped when it doesn't, and the progress counts only the steps shown. The tour no longer starts by itself: the setup's last step and Settings start it. Every step was checked against today's screens. **The plugin card is a shared molecule.** `PluginOptionRow` is now used both in Settings and in the wizard. ## Checks - 810 tests (745 before), lint, typecheck, Storybook and the Nuxt build all pass. - Production build in headless Chrome: - A fresh install is sent to `/setup`. The walk-through covered switching the language, Claude Code detected, a Redmine connection tested against a fake Redmine, channels turned off, and a project added. The tour that followed left out the notifications step, because the channels were off. - With every plugin off and every step skipped, the skips were recorded and the tour showed the 10 steps that apply. - An install marked as through setup v1 is never redirected. - On an upgrade (an existing data dir with a project, with migration 0013 not yet run): `/` answers 200 instead of a redirect, and the setup is recorded as through version 1. ## Known and left - The path where an upgrade adds a step (only that step is shown) is covered by unit tests only. Every step today is version 1, so there was nothing to walk end to end. - Skips made during a run are forgotten if the page is reloaded mid-run. The current step is kept. - The Done step shows "Go to the board" both in its body and as the Finish button. - Installing plugins from the Forgejo plugin repository isn't here: plugins are only turned on and off. The wizard's plugin lists are written so installed plugins will appear in them unchanged. - The packaged desktop app wasn't rebuilt and nothing ran on Windows. There is still no Forgejo runner. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Phase 6.5 starts with what the wizard stands on. Each contribution kind may
declare its setup step (runtimes, trackers, channels), and Setup.plan puts
them between the core's welcome, project and done steps with the plugins
that offer each kind, so a new kind of plugin brings its own step.
Setup.runtimes asks every runtime what it finds on this machine, with a
timeout. The settings gain a setup section (completedAt, version, skipped)
and agents.runtime, which sessions now run on (Claude Code when that
plugin is off). Migration 0013 marks installs with a project or a finished
tour as through setup version 1, so only a fresh install opens the wizard.

tidyPatch and the General form rebuilt agents field by field and would
have dropped the new runtime on every save; both now carry the section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first-run setup lists the plugins each step offers with the same card
Settings › Plugins shows: name, state, what it adds, and every permission it
asks for, read before it is turned on. The card moves out of PluginList
into a props-only molecule (switch and Configure as events, a slot for what
a card shows besides), so both places render one card and the setup's steps
can have stories. Permission labels move to app/utils/pluginPermissions.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
app/composables/api/setup calls Setup.plan and Setup.runtimes; useSetupPlan
and useRuntimeOptions read them through useRpcQuery, re-read when a plugin
is turned on or off. Detecting runtimes runs programs and may take seconds,
so useRpcQuery gains `server: false` and that query is asked from the
browser only, never while the page renders.

The flow is pure functions in app/utils/setupFlow.ts (tested): the setup is
due when the plan's version is above the user's; a run shows every step on
a fresh install, only the newer ones (and the end) after an upgrade, all of
them when Settings asks; finishing records the plan's version and the steps
skipped, keeping an earlier skip unless this run went through that step.
useSetupWizard holds the step on screen in `?step=` so a reload keeps the
place. The steps read what they show through a provided SetupContext (as
the settings sections read useSettingsForm), so each one has stories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fresh install is sent to /setup (middleware/setup.global.ts). It asks the
server once per app load, during the server render when there is one, so
the redirect is a 302 and nothing flashes first; it never decides from the
defaults the shared settings hold before the server answers. The same rule
sends an upgraded install to the steps a release added, and only those.

The page is thin: SetupTemplate (a step rail with done/skipped marks, the
step's title, Back / Skip / Next, Finish on the last step, Skip the rest)
around one organism per step the app knows: welcome (language, applied at
once as in Settings, and the theme picker), runtimes (each runtime plugin
with what was found and how to install it, the runtime tasks run on, the
planning and review models), trackers (tracker plugins, a first connection
with its check), channels (channel plugins with their state and Test),
project (a first project with its workflow, linking a tracker project),
done (what was set up; the tour or the board). A step with an id the app
does not know, which a future kind of plugin brings, gets SetupPluginsStep:
its plugins with permissions, switch and Configure. The forms that read
their own data mount on first use, so the steps stay storybook-able.

The setup has its own layout (no header, tour or keyboard help) and turns
the app-wide shortcuts off through `definePageMeta({ shortcuts: false })`.
Settings › General can run it again with every step (`/setup?all=1`).
Every string is in the catalogues, English and Spanish, including the
step keys the server's plan names and the runtime problem keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TOUR_STEPS becomes data in app/utils/tour/ with an optional `when(context)`
per step; the context counts what exists (projects, tasks, connections,
and the plugins on by kind). The overlay shows only the steps whose
condition holds, and its progress counts only those: the board, filters
and New task need a project, Trackers a tracker plugin on, Agents a
runtime, and a new Notifications step a channel plugin. A step that stops
showing mid-tour moves on to the next one shown instead of stalling.

The tour no longer starts on first launch; the setup's last step and the
Settings button start it, and ending it still records tour.completedAt.
TourOverlay is now a gate that mounts TourSpotlight only while the tour
runs, so what the conditions read loads only then; the card is a molecule
(TourCard) and the placement and spotlight logic live in
app/utils/tour/cardPosition.ts and useTourSpotlight. Every step points at
a section today's settings page has (tested), and the texts that spoke of
the Integrations section speak of plugins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: the first-run setup, the tour as data, headless runs past the wizard
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
e84e12fe4a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alex added 157 commits 2026-10-29 18:46:56 +01:00
The Machines context: what a machine is, and the port that knows them
Some checks failed
CI / check (push) Has been cancelled
1516baad96
The contract Phase 7 builds on: a Machine (uuid from the data dir, host name,
platform, role, local or not, its own capacity), MachineUnavailable for a
project whose machine this server cannot reach, and the Machines port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Machines when there is one: the local machine, its id kept in the data dir
Some checks failed
CI / check (push) Has been cancelled
bb543cad65
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local machine now registers itself in a `machines` table at boot
(migration 0014): inserted once under the uuid kept in the data dir's
machine-id, its host name, platform and last-seen time refreshed on every
boot after. A migration cannot know that id, so the same registration step
puts the projects that name no machine yet (all of them, on an existing
install) on this one. MachinesSql implements the Machines port over the
table and replaces MachinesLocal; the port keeps local/list/get as they
were for track B and gains setMaxConcurrent.

`projects.machine_id` is declared in the drizzle schema (it would be
dropped on read otherwise), the Project Schema and views carry it, and
CreateProject puts a new project on the local machine.

Capacity is per machine: a session belongs to its task's project's
machine, a machine's limit is its own `max_concurrent` or the setting
(capacityOf / freeSlots, pure rules in the core), and both the workflow's
session node and the scheduler's sweep weigh a task against its own
machine. They still count through the runner's activeCount, now taking an
optional machine, so the characterisation tests' fakes keep driving them
and, with one machine, nothing changes. The runner indicator counts
against all the machines together.

The Machines RPC group lists the machines and sets a machine's limit
(SetMachineCapacity, recording MachineCapacityChanged, which screens hear
as machine.updated and runner.changed, and which wakes the scheduler).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useMachines reads Machines.list (refreshed on machine.updated) and
useMachineCommands sets a machine's limit, both loading the RPC client on
first use like the other contexts.

MachineBadge (atom) is a machine's OS icon and host name. The hub shows it
on a project's card only once there is more than one machine: today every
install has exactly one, and a card repeating the same host name on every
project is noise, while from Phase 10 on it answers a real question (which
machine is this repository on).

Settings › General keeps its one number as the default every machine
follows, and lists the machines below it, each with a field for a limit of
its own (MachineCapacityField, a molecule); emptying it, or "Use the
default", follows the number again. It shows even with one machine, since
that is where the limit lives and the row says plainly that it follows
the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Attachments in a content-addressed blob store
Some checks failed
CI / check (push) Has been cancelled
d49e2b293c
An attachment's bytes now live in `blobs/<aa>/<sha256>` under the data
dir, named by their sha256 in `task_attachments.blob_id` (migration
0015), so the same file attached twice is kept once and, from Phase 10,
bytes can move between machines by id. The BlobStore port (put, read,
stream, has, remove, list) is a small context of its own in the core,
since the Workspace and the nodes will need it as much as the Board;
BlobStoreLocal implements it with synchronous file I/O, writing to a
temporary name and renaming into place.

Every writer of an attachment goes through saveAttachment: the upload
route, a comment with files, the agent's attach tool, and a plugin's
ctx.board.attach / comment (Discord). It also writes the file under
attachments/<task>/ as before, until Phase 8, because that is the path
the agent is told to open the human's files at. Readers prefer the blob:
GET /api/attachments/:id streams it (falling back to the old file for
one that could not be adopted) and a plugin's readAttachment reads it.

Deleting an attachment removes its blob only when no other row has the
same bytes. A deleted task's (or project's) attachments go by cascade, so
removing a task's files also sweeps the blobs nothing refers to any more,
sparing any written in the last minute (its row may be landing). At boot,
attachments from before the store get their file stored as a blob, once;
one whose file is gone is left as it is and logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Everything that touches a machine now goes through a port that is that
machine's, so a node can implement the same interfaces in Phase 10:

- HostOS (packages/core/src/machines): the machine the user sits at. Open a
  folder in the editor or terminal (the launcher choice passed in), open a URL,
  reveal a folder, the folder and app pickers, the launcher presets, notify.
- MachineWorkspaces.for(machineId) -> MachineWorkspace, MachineUnavailable for
  a machine this server cannot reach. It replaces the two ports both named
  Workspace (execution's and projects') and folds in what the folder routes did
  by hand: git state, inspection, the project's .kanbot/settings.json (still
  presets only), appending lessons to CLAUDE.md, skills, MCP config, preparing
  a worktree. Everything is plain data in and out; prepare() reports what it
  did and the hub writes the timeline, so no machine writes the database.
- machineOf(project) is the one seam that says which machine a project is on
  (the local one until projects carry machine_id); workspaceOf builds on it.
- task_workspaces (task, machine, mode, branch; 0016, a file of its own) is
  written where a session prepares its workspace, dual-written with
  tasks.workdir. A workspace's path is derived on its machine
  (worktreeDirFor with that platform's path module) by workdirOf, which the
  session start, the review, FinishTask, the worktree removal and opening a
  task now read; a worktree an older Kanbot put elsewhere keeps reading workdir.

The LocalMachine adapter (server/kanbot/machines/local) implements both over a
Platform service (os, path module, home folder via os.homedir(), env, file
checks) and a ProcessRunner (execFile, never a shell string), each injectable
so tests run the Windows code on macOS and never open real apps. The platform
modules (darwin, win32, linux) hold every command line: osascript, PowerShell
Windows Forms dialogs and a toast, zenity/kdialog, open/explorer/xdg-open, the
launcher presets. The user's own setup command runs through the platform's
shell: sh -c, or cmd.exe /d /s /c "..." on Windows (what Node's shell: true
and npm scripts use; no execution-policy trouble with npm.ps1 shims).

server/utils/launch.ts (suppressed at 268 lines), server/agent/git.ts and the
plugins' desktopNotification are gone into the platform modules; plugins'
host.notify goes through HostOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session's commands are judged by the shells of the machine they run on.
On a Windows machine the POSIX rules still apply (agents there run Git Bash)
and the Windows ones are added: del /s, rmdir /s, rd /s, Remove-Item -Recurse,
format, diskpart, reg/sc/schtasks, runas, piping iwr/irm/curl.exe into iex,
-EncodedCommand, winget/choco/scoop, icacls /t, taskkill, PSSessions,
Send-MailMessage and Start-Process, $env: secrets, writes into C:\Windows and
absolute drive paths. They match at a command's start only (after a separator,
cmd /c or -Command), so `npm start` is not `start`. Strict mode knows the
read-only Windows commands and holds iwr/irm/curl.exe requests that send data.
The gate passes the workspace's platform; POSIX is the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app reaches a project's folder and the user's machine over two RPC groups
(packages/contracts/src/machines): Host.* (launchers, pickFolder, pickApp,
openProject, openTask) and Workspaces.* (gitStates, inspect, fileSettings,
saveFileSettings, lessons, decideLesson, skills), read and written on the
project's machine. The settings file's launchers are a closed Schema
(kind 'preset', a LauncherPresetId), so the contract itself refuses a path or
a command, and the handler drops whatever else the form sent.

useLaunchers, useProjects, useSkills and the memory commands call them
through app/composables/api/machines; no component touches Effect. The REST
routes they replace are deleted, with their LEGACY_API_ROUTES entries:
launchers, skills, fs/pick-folder, fs/pick-app, memory/[id], projects/git,
projects/inspect, projects/[id]/file-settings, projects/[id]/memory and
tasks/[id]/open. POST /api/projects/[id]/open stays for the Electron tray, as
a thin route over the same openProject. Projects.create/update can now say
MachineUnavailable; the shared folder types are the Machines Schemas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Projects now carry machine_id; machineOf reads it (null is this machine), so
CreateProject checks the folder on and records the machine the same seam
names. Migrations stay one file each: 0014 machines, 0015 attachment blobs,
0016 task_workspaces. The kernel keeps A's BlobStore and MachinesLive, with
the machine stack (HostOS, MachineWorkspaces, TaskWorkspaces) under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- platforms: each module's command lines on any machine, the Windows one
  against a made-up Windows (path.win32, %LOCALAPPDATA%, files that exist):
  cmd.exe /d /s /c for the setup command, the PowerShell pickers and toast
  with their quoting, VS Code from its folder or from code.cmd on PATH, wt -d,
  PowerShell, cmd, zenity/kdialog, rundll32/explorer/xdg-open.
- paths: worktreeDirFor and workspacePath on both path modules, `~` from
  os.homedir().
- hostOS: the local machine opening folders, refusing by catalogue key,
  pickers (cancelled, unsupported), availability, notifications, all through
  a recording runner.
- workspace: a real repository in a temporary folder (inspect, git state, a
  worktree with local files and its setup command run with CI=1, the work on
  it, removing it), the same prepare on a made-up Windows, and the settings
  file taking presets only.
- taskWorkspaces: the dual-write as sessions prepare a workspace, derived
  paths, a moved task forgetting it, a legacy worktree read from workdir.
- rpc: Host.* and Workspaces.* end to end over a recording runner that runs
  git for real; a command or a path sent by hand for the settings file is
  refused by the contract.
- classifyCommand: the Windows spellings and the commands that still run.

The test support gains a fake HostOS for hand-built plugin hosts and a fake
machine (platform, recording runner, one machine). expandHome over $HOME goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: machines, the machine ports, Windows, attachments as blobs
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
eedf2f8c0b
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Plan stage's report, the owner's answers to its questions and the
plan an agent proposes (with where it stands) were four columns on tasks
(triage, triage_answers, plan, plan_status). They are one row per task in
task_plans now (Kanbot migration 0017, filled from those columns), written
through the Execution context's TaskPlans port (SqlTaskPlans): propose_plan
through ToolHost.recordPlan, report_plan through recordPlanReport, and an
answer through answerPlanQuestion, which becomes an Effect on the port.

The task's readers keep their fields: the repository reads the row with
the task (server/database/taskRows.ts composes it, in the views' field
order), so the board, the prompts, Discord and the Electron routes see
the same task. drizzle no longer declares the four columns; Phase 8's
drop migration removes them from the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tasks table stops being written for what moved out of it in earlier
phases, and its readers get those fields from their own tables, under the
names the views always had (server/database/taskRows.ts):

- sessionId: the external id of the task's latest task session it may
  still resume (agent_sessions.resumable, new: going back to the queue or
  to another project forgets the sessions, which was session_id = NULL).
- costUsd, numTurns, agentDurationMs, input/outputTokens: the sums over
  every session the task ran, which is what the task's running totals were;
  a session's row now adds up its results like the task did.
- branch, workdir: the task's task_workspaces row, the folder derived on its
  machine, or its new `path` when the folder is not where the rule puts it
  (it used to be kept only in tasks.workdir).
- sourceConnectionId/Ref/Url: task_external_refs.
- reviewVerdict: the latest round in task_reviews (indexed for it).

error, startedAt and finishedAt stay on tasks: they are the task's own
lifecycle (why it is blocked now, when work began, when it was done),
written by the workflow and the board as much as by sessions.

Kanbot migration 0018 moves what only the columns knew: a session row per
task carrying the totals and resume id its rows do not account for
(sessions before Phase 5), a workspace row for every task with a workdir,
and refs for every imported task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The last REST folders (time, views, notifications, reports, standups) did
their validation and their logic in the route. Their rules move into the
core as commands and queries with ports, the way Settings and Machines are
built, so the routes can go and RPC handlers only decode and dispatch:

- time: the manual timer (one at a time: starting it elsewhere stops the
  other), hand-typed stretches, corrections, removal, and booking a day
  (tidyBookings: one row per existing task, zeros only when locked). Each
  change records an event; DateKey is a real calendar day.
- savedViews: save, change, remove a board view, with events.
- notifications: the feed query (feedLimit keeps today's 40 / 1..100),
  mark read and clear with events, and the forced test notification.
- reports: the summary and the timesheet rows (BadRange), and writing a
  standup (StandupWritten).
- execution: the refusals the timeline and upload paths need (comment,
  attachment, review not found; not a comment; empty comment; no files).

Nothing uses them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each REST folder becomes RpcGroups whose handlers only decode and run the
core's command or query (Time, Timesheet, Views, Notifications, Reports,
Standups, Runner, Reviews, Timeline), and the app's composables call them
through app/composables/api with no Effect in a component. The routes that
nothing calls any more are deleted: time/**, views/**, notifications
read/clear/test, reports/summary, standups (whose POST was a legacy route
reaching into server/agent), reviews/[id], comments/[id] and
attachments/[id] DELETE.

What screens hear stays the messages they listened for, now projected from
the new events (time.changed, views.changed, notification.read,
standup.created), so they replay after a reconnect. The legacy writers the
commands go through stop announcing for them (time's writers take
`announce`, the agent's stretches still signal; markRead/clear and saveDay
no longer publish). Reviews and the timeline have no events yet and keep
their signals.

useRpcQuery takes a getter key, so the timesheet's day and the report's
range load again when they change, as useFetch did. API module exports are
named so that check-atomic's composable-call scan cannot mistake them for a
formatter (`fmt.day(`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What stays REST is uploads and downloads (a task's attachments and a
comment's files, an attachment's bytes, the timesheet CSV), the RPC mount,
and the Electron shell's small surface: GET settings, status, tasks,
tasks/[id], projects, notifications (?limit), events (SSE), and POST
tasks/[id]/answer and projects/[id]/open. The shell keeps REST rather than
carrying an RPC client; each route says who it is for.

Each now decodes its params and body with a Schema (server/kanbot/platform/
rest.ts, through h3's validated readers) and runs a query or command of
server/kanbot or packages on the app's runtime: the upload, comment-with-
files and download logic moved to server/kanbot/attachments, the CSV to
server/kanbot/reports/timesheetCsv, and the shell's reads to small queries
(projectsWithStats, publicSettingsNow, runnerStatusNow, TaskReads.list,
notificationsFeed). Their answers are unchanged (test/rest/electronRoutes
compares them with what the legacy functions returned).

zod is gone from server/ (projectSchema.ts was unused). It stays in the root
package.json: plugins/claude-code imports it for the Claude Agent SDK, which
declares it a peer dependency, and plugins have no package.json of their own.

LEGACY_API_ROUTES ends empty, and the route boundary now also forbids the
repository and the legacy utils (server/utils), better-sqlite3 and zod.
test/support/routes.ts mounts routes on h3 with Nitro's auto-imports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Say where the Electron shell's REST surface is, and drop a stale note
Some checks failed
CI / check (push) Has been cancelled
2516fa34ae
The shell keeps REST on purpose (no RPC client in it); its api() helper now
lists the routes it relies on and where their answers are pinned. The Tasks
contract no longer says reading tasks is REST: that is TaskQueries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plan, plan_status, triage, triage_answers, session_id, cost_usd,
num_turns, agent_duration_ms, input_tokens, output_tokens, branch,
workdir, source_connection_id, source_ref, source_url and review_verdict
leave the tasks table; nothing reads or writes them any more, and 0017
and 0018 moved what they held.

source_connection_id is a foreign key, which DROP COLUMN refuses, so the
table is rebuilt the way SQLite prescribes (new table, rows copied with
their ids, old one dropped, new one renamed), keeping the id counter so a
deleted task's id is never reused. Dropping a table that others reference
deletes their rows while foreign keys are on, and the pragma cannot change
inside the migrator's transaction: Migrator.migrate turns keys off around
the run when something is pending (never for the runtime's no-op re-run),
and 0019 refuses to run with them on and fails if foreign_key_check finds
more broken rows after than before.

Tests: the three migrations on a database stopped at 0016 read every
task the same, field for field; the rebuild refuses with keys on; a task
with everything beside it reads in the views' order and decodes as the
board's view; going back to the queue or to another project forgets
sessions and workspace but keeps the totals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	server/kanbot/platform/runtime/layers.ts
CLAUDE.md: the routes left, the tasks table's own fields, migrations with keys off
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
b4eaaa565a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 8c replaces drizzle and better-sqlite3 with node:sqlite. The first
half is getting every query off drizzle's query builder, so the driver
underneath can change in one place.

- server/database/sql.ts: all/get/run with positional parameters over the
  shared connection, and inTransaction, which opens a transaction outside
  one and a savepoint inside one (a legacy write called from a command's
  transaction), as better-sqlite3's .transaction() did. It is the only
  file that knows the driver.
- server/database/table.ts and tables/: one descriptor per table, with the
  fields in drizzle's order and how each is stored (JSON, 0/1 booleans).
  Rows decode to the same objects drizzle produced, so the task JSON and
  the Electron routes stay byte for byte; statements.ts holds the
  select/insert/update/delete-returning shapes the stores repeat.
- taskRows.ts keeps the task's parts as SQL subqueries (TASK_COLUMNS) and
  composes the task as before.
- The infra tests replay drizzle's migrations from the same snapshot the
  handover uses (test/support/sqliteFile.ts) instead of running drizzle;
  checked against drizzle's own migrator before it goes.
- tableRows.test.ts: every column the database has is declared, and every
  field survives a write and a read, so a new column cannot read back
  undefined unnoticed.

drizzle's migrator and schema.ts are still there; the next commit removes
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no query left on drizzle, its migrator was the last thing boot ran
from it. Kanbot's 0001_legacy_handover already applies whichever of
drizzle's migrations a database lacks, from the snapshot in
legacy.generated.ts, and records them in drizzle's ledger, so
runMigrations() now runs only Kanbot's migrator.

A fresh database comes out with the same sqlite_master as before
(compared table by table; only the whitespace of the ledger table's own
CREATE differs), and the migration tests cover a database drizzle
migrated, a fresh one and one stopped at drizzle's twelfth migration.
One difference for an old install: drizzle's recreate migrations now run
inside Kanbot's migrator, with foreign keys off, where drizzle ran them
with keys on.

Gone with it: server/database/schema.ts, drizzle-kit's migrations folder
and config, scripts/gen-legacy-migrations.mjs (the snapshot it wrote is
now the only copy, frozen), the db:generate script, KANBOT_MIGRATIONS_DIR
(server, Electron shell, tests) and the migrations the desktop app
shipped as extra resources. drizzle-orm and drizzle-kit leave
package.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shared connection is now Node's built-in DatabaseSync. The legacy
code reaches it through server/database/sql.ts and Effect through
SharedSqliteClient, still one connection, so the rule that a transaction
holds SQL only (and the client's guard against the scheduler yielding
inside one) stands unchanged; sharedSqlite.test.ts still shows the guard
is needed.

What differs from better-sqlite3, and how it is met:
- Rows come back with a null prototype; both sides copy them into plain
  objects, so readers and strict equality see what they saw before.
- Integers read as numbers and rows as objects (readBigInts and
  returnArrays off, said explicitly); SafeIntegers maps to setReadBigInts.
- No .pragma(), .transaction(), .reader or .raw(): pragmas go through exec,
  transactions were already ours (inTransaction), a statement returns rows
  when it has result columns, and returnArrays stands in for raw mode.
- The busy timeout defaults to 0; it is 5 s, as better-sqlite3's was.
  Extensions stay off.
- Errors carry SQLite's numeric code (errcode) rather than a string code;
  effect/sql classifies either.
Node 24.19 and Electron 44's Node 24.20 print no experimental warning for
node:sqlite. Nitro's bundler does not list node:sqlite as a built-in, so
it is named external in nuxt.config.ts instead of being warned about.

better-sqlite3 and its types leave package.json. sqliteLocks.test.ts,
which showed two SQLite copies in one process ignore each other's locks,
now pins that two node:sqlite connections do respect one and that
better-sqlite3 stays uninstalled. The lint boundaries name node:sqlite
where they named the old driver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With SQLite built into Node, the server bundle carries no .node file, so
one .output runs on every platform:
- desktop-prepare no longer keeps one better-sqlite3 prebuild per target
  (its --platform/--arch arguments are gone, and desktop:build:win stops
  passing them); it now fails the build if a .node file ever appears in
  the bundle, since that would load on one platform only.
- @electron/rebuild leaves devDependencies; nothing called it
  (electron-builder runs with npmRebuild: false and keeps its own copy).
- electron-builder.yml and the README stop describing a native SQLite
  module. Hardened runtime stays off as CLAUDE.md requires; its comment
  says the reason it was off (library validation refusing the .node) is
  gone but turning it on is untried.

Checked: the macOS app (electron-builder --mac --dir) passes
desktop-verify and its bundled server boots under ELECTRON_RUN_AS_NODE on
Electron's Node 24.20 and answers /api/status, /api/tasks and RPC; the
Windows x64 build (--win --x64 --dir) cross-builds from macOS. Neither
bundle, nor either app.asar, contains a .node file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: one node:sqlite connection, migrations without drizzle, no native module
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
7c6cc57492
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
packages/core reached into shared/types and shared/workflows by relative
path some fifty times, and shared/types reached back into the core's
schemas, so the two were one module graph split across folders. Before the
core can become a package with declared dependencies, one side has to own
them. The core uses them, so it does: shared/types moves to
packages/core/src/types (a module open to every context, like the kernel)
and shared/workflows to the workflow context's graph/. shared/types and
shared/workflows stay as barrels re-exporting them, so `#shared/types`,
`#shared/workflows` and Nuxt's auto-imports read the same names as before.

WorkflowWithUsage stays in shared: it is the contracts' WorkflowView, and
the core may not depend on the contracts.

Two leftovers of the same kind go too: execution reached into board's
errors/ instead of its schemas, and the server took McpServerConfig from
the Claude Agent SDK, which belongs to the claude-code plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
packages/{core,contracts,infra-sqlite,plugin-sdk} and plugins/* become
workspaces (@kanbot/core, @kanbot/contracts, @kanbot/infra-sqlite,
@kanbot/plugin-sdk, @kanbot/plugin-<id>), each with the dependencies it
imports and nothing else: effect pinned at the repo's exact version, the
workspace packages as "*" (npm links a workspace whose version satisfies
the range; it has no workspace: protocol). Only the SDK is not private.

The plugins take effect and the SDK as peers: the host supplies the one
copy of each, which is also what a prebuilt plugin bundle leaves external.
zod and the Claude Agent SDK leave the root for @kanbot/plugin-claude-code,
the only code that imports them. A plugin's package version is its
manifest's.

Each package's exports point at its TypeScript sources: the app, Nitro,
vitest and Storybook compile them as they did, through the workspace
symlinks. Imports still use relative paths; the next commit switches them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every import that crossed a package boundary by relative path, about nine
hundred of them, now names the package: @kanbot/core/<context> for a
context's index, @kanbot/core/<context>/schemas for its browser-safe
face, @kanbot/contracts/<context>, @kanbot/infra-sqlite/<module>,
@kanbot/plugin-sdk and /testing, @kanbot/plugin-<id>. Each package's
exports map lists exactly those entries, so the context boundaries are
the resolver's now, not only ESLint's: a deep import does not resolve.
Inside the core, contexts reach each other the same way (the package
importing itself), which is what lets lint hold a relative import inside
its context; the one that did not (execution reaching into board's
errors/) went in the previous commit.

The core's types/views.ts goes back to shared: SavedView carries the
board's filter, which is shared/filters' and which the core never reads.

Two ESLint rules (scripts/lint/packageBoundaries.mjs) do what
no-restricted-imports cannot, since they depend on where the importing
file is: kanbot/relative-imports (a relative import stays in its
package, in its context inside the core, and the app never reaches into
packages/ or plugins/ by path) and kanbot/declared-dependencies (a
workspace imports only what its package.json declares and Node's
built-ins). The latter replaces the per-plugin regex of allowed packages,
which package.json now says. The per-context patterns match package
specifiers, and now cover the app, shared and Storybook too.

gen-plugins resolves the plugins and the SDK as packages. esbuild's
`packages: 'external'` would leave @kanbot/* outside the bundle, as
TypeScript Node cannot run, so scripts/pluginBundle.ts compiles them in
and leaves the rest outside. `npm run build:plugins` also writes each
plugin as it would be published (plugins/<id>/dist: one ESM bundle with
its peers, effect and the SDK, left to the host). gen-plugins checks a
plugin's package name and version against its @Plugin.

Nitro inlines @kanbot/* (nitro.externals.inline): it does already, as
their real paths are outside node_modules, and now it is a rule. The
built server has no .ts file, no symlink and no @kanbot package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Outside plugin authors install the SDK from Forgejo's npm registry
(publishConfig.registry; README "Installing the SDK": the @kanbot scope
line for .npmrc, peers, versions, decorators, the test kits, and how a
plugin is bundled for publishing).

The build is tsc alone (tsconfig.build.json, NodeNext): ESM .js and .d.ts
in dist/, file for file, so the main entry and ./testing share one module
graph and one metadata registry, which two bundles would not. No new
dependency: a bundler would also need a declaration bundler to give
consumers types they can resolve. The sources import each other as
'./X.ts', which tsc rewrites to './X.js' (rewriteRelativeImportExtensions)
and scripts/build.mjs rewrites in the declarations, where tsc leaves it.

In the repository, exports still point at src/, so Vite, vitest, Nitro and
vue-tsc read the SDK as it is, with no build step and no custom resolve
condition to keep in step across them. npm has no per-publish exports, so
prepack builds and swaps in the published manifest (exports to dist/, no
scripts or devDependencies) and postpack restores the workspace's. The
tarball holds dist/, the README and package.json.

Peers: effect at the exact release Kanbot ships, since Effect 4 is still a
release candidate whose APIs change between candidates and the host
supplies the one copy (a second would not recognise its services and
Schemas); a range once Effect 4 is stable. @effect/vitest, which the test
kits import, is an optional peer, so the main entry never brings a test
runner. The version is 1.0.0: its major is API_VERSION, which a test now
holds, with the plugins' peers.

Checked outside the repository: a scratch project installs the packed
tarball with effect@4.0.0-rc.118, compiles a @Plugin + @TrackerType class
with tsc (NodeNext and Bundler, experimentalDecorators, skipLibCheck off)
and reads its manifest; the prebuilt redmine and discord bundles load
against the same SDK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
npm ci installs the root and every workspace in one go, so the steps stay
as they were; one is added: `npm pack --dry-run -w @kanbot/plugin-sdk`
runs prepack (the tsc build to dist/ and the published manifest) and
postpack, so a change that breaks the published SDK fails CI without
anything being published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Declare what the root's tests import; CLAUDE.md for workspaces and the SDK
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
fb49357486
The root's tests mock the Claude Agent SDK and lint-test with the
typescript-eslint parser; both resolved only because npm hoisted them from a
workspace, so the root now declares them as devDependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Devices context: what a paired device and a pairing offer are
Some checks failed
CI / check (push) Has been cancelled
4a2400c47c
The contract Phase 9 builds on: a Device (only its token's hash is stored),
a PairingOffer (one-time six-digit code, expiry, address, the hub's
certificate fingerprint), one PairingRefused for every way a code fails so a
guesser learns nothing, and the Devices RPC group Settings uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings › Devices needs the list, a pairing code and revoking, and the
/pair page needs the one call that arrives without a token. The RPC side
follows the other contexts: an api module (composables/api/devices), a
query composable refreshed on `devices.changed`, and a command composable
that re-reads the list after a revoke so the row greys without waiting for
the live message. usePairing is plain $fetch to POST /api/auth/pair: it is
the request that sets the HttpOnly cookie, so it cannot go through the RPC
client, and it maps the answers to what the page says (400/401 refused,
429 too many tries, anything else a failure to show).

The client merges `Devices` into KanbotRpcs on its own side until the
server serves the group (Phase 9 A adds it to KanbotRpcs with its
handlers; the merge goes then). DeviceNotFound and PairingRefused get
their catalogue keys, and the catalogues carry every string the pairing UI
uses, in English and Spanish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A protected Kanbot sends a browser it does not know to /pair, so the page
must render with nothing the auth would refuse: it reads no query (no
settings, no plugins, no live updates), uses a bare layout with no header,
tour or shortcuts, and the setup middleware leaves it alone. Its one
request is the pairing POST; on success it reloads the app at `redirect`,
a same-origin path only (safeRedirect refuses //host, /\host, schemes and
/pair itself), so everything afterwards goes out with the new cookie.

The code is one numeric field that keeps only digits, so a pasted
"123 456" works, and it pairs the moment the sixth digit arrives. A code
prefilled from the pairing address waits for the user, who should compare
the hub's fingerprint (`fp`, shown when the address carries it) first.
The device name defaults to the browser and system the User-Agent names.
Whoever opens /pair is not yet someone this Kanbot knows, so the page
speaks their browser's language rather than the hub owner's setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A section like the others (rail entry, searchable by its keywords) lists
the devices with their kind, when they were paired and last seen, and
"This device" on the one the page is open on; revoked ones are greyed and
hidden behind a toggle. Revoking asks first and warns when it is this
device, which the page then sends to /pair. "Pair a device" asks the hub
for a code when it opens and shows it big with its countdown, the address
and its QR code, and the hub's fingerprint to compare. Reopening keeps the
same code while it lasts, since asking again would end it; an expired one
offers a new one, and a device that pairs while the code is on screen
closes it with a toast.

The page passes the list and the commands as props, so every component
has a story with a fake hub. The QR code is uqr (pure JS, already in the
tree through Nuxt, now a direct dependency) drawn as one inline SVG path
on a white tile, which stays scannable in dark themes. The tour gains a
short step on the section. Component tests cover typing and pasting the
code, auto-submit, each outcome the /pair card shows, the countdown and
how usePairing reads the server's answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The address and its QR code carry the code, so after the countdown runs
out they only lead the other device to a refusal. They step aside until
"New code" brings a live one; a component test drives the expiry and the
renewal with fake timers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 9 gives the hub an identity a device can check when it paires,
Syncthing-style: a self-signed certificate whose SHA-256 fingerprint is shown
beside the pairing code. It is an ECDSA P-256 key from Node's WebCrypto, signed
by pkijs (pure JS, already in the tree through electron-builder): no native
module and no OpenSSL to shell out to, so the desktop bundle stays portable.

hub.crt and hub.key (0600) are written once, for this machine's id, valid ten
years, and read back on every boot; they are only remade when a file is
missing or the key is not the certificate's, since a new fingerprint is a new
identity to every paired device. HTTPS stays off until hub mode (Phase 11);
hubCertificatePaths is what it will read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Devices context's server half. A device is a row in `devices` (migration
0020) holding only the SHA-256 of its token: the token (32 random bytes,
base64url) exists in clear once, in pairDevice's answer, and a request's token
is checked by hashing it and finding that hash (compared again in constant
time). Revoking keeps the row and stops the token for good.

The pairing code lives in memory (PairingOffersMemory): it lasts five minutes,
so a restart withdrawing it only costs a new code, and nothing secret reaches
the disk. Six digits from crypto.randomInt, compared in constant time; a newer
offer replaces the older, a right code is spent, and five wrong tries burn it.
Every way a code fails is the one PairingRefused, so a guesser learns nothing.

DevicePaired and DeviceRevoked reach screens as `devices.changed`; pairing and
revoking are logged without tokens or codes. The 0013 migration moved verbatim
into its own file to keep the list under its size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
server/middleware/auth.ts runs before every route and page (after Nitro's
static files, which are the public build), and decides with one pure rule,
decideAccess (packages/core/src/devices):
- From another machine, by the socket's address (never X-Forwarded-For): a
  paired, unrevoked device's token, as the kanbot_device cookie or a Bearer
  header. Otherwise a page goes to /pair?redirect=…, anything else is 401 JSON.
- From this machine, with KANBOT_LOCAL_TOKEN set (the packaged app): that
  token (kanbot_local cookie or Bearer, compared in constant time) or a device
  token. The variable leaves the environment when boot reads it, so agents and
  setup commands do not inherit it.
- From this machine without it (dev, tests, scripts): open as before, but the
  Host must name this machine and port (DNS rebinding), and a foreign Origin,
  a cross-site request other than a top-level navigation, or an unsafe one
  from another site is 403 (CSRF from web pages).
- Open to anyone: /pair, POST /api/auth/pair, /_nuxt and /_i18n assets, the
  icons, and the browser handoff link; dot segments and encoded separators
  never count as open.

Nuxt renders pages by calling /api/rpc in process with the page request's
headers. The middleware gives each let-through request a random caller id,
in a header only this process writes (a client's is removed), so those calls
carry the page's caller; the RPC handlers read it for Devices.list's
isCurrent, and offerPairing/revoke die without one.

POST /api/auth/pair is rate-limited per address (IPv6 by /64): the eleventh
try in ten minutes is 429 before the code is read. A browser gets only the
HttpOnly SameSite=Strict cookie; a node or script the token, once. A device
may revoke itself (signing out): the hub's own window is never a device, so
no one is locked out. A stale device cookie is dropped. "Open in browser"
trades a one-minute, single-use nonce the shell asks for (local token only)
for the per-launch cookie, on this machine only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the server now refusing this machine's requests unless they show
KANBOT_LOCAL_TOKEN, the shell makes one for each launch (32 random bytes)
and is the only holder: it passes it to the server it forks, sets it as the
HttpOnly, SameSite=Strict `kanbot_local` cookie on the window's session before
loading the board, and sends it as a Bearer header from its own REST calls,
the boot poll of /api/status and the SSE client. Other local programs and
web pages can no longer drive the app.

"Open in browser" (tray and Help menu) asks the server for a one-time link
that gives the system browser the cookie, since a plain URL would now be
refused. desktop:dev (ELECTRON_DEV_URL) has no token, as its dev server has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge the pairing UI (wip/p9-ui) onto the auth server side
Some checks failed
CI / check (push) Has been cancelled
ee0e0f7551
The app's client takes Devices from KanbotRpcs again, now that the server
serves it. Both tracks added the same error strings; the translation files
also carried a duplicate machineUnavailable key from Phase 7. JSON keeps the
last of each, so the shorter pairing message and the machine message that
names the machine were being overridden. One of each is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The packaged app's tray and notifications listen to /api/events, and
since Phase 9 every request from this machine must carry the per-launch
token. main.js subscribed without it: the stream was answered 401 and
retried forever, so the tray never refreshed on its own and no desktop
notification was ever shown.

All of the shell's requests (the boot poll, the REST calls, the live
feed) now go through one client, electron/shellClient.js, made with the
launch's headers, so no call can be the one that forgets them. A test
checks all three carry the token and that main.js makes no request of
its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.js let the window follow a link or open a popup when the target
string started with the server's address. `http://127.0.0.1:PORT@evil.com/`
starts with it but is evil.com (the part before the @ is a user name), and
`http://127.0.0.1:PORT1` is another port: a link in a task's description,
an agent's output or a tracker issue could load another site inside the
Kanbot window, where it looks like Kanbot and can phish.

The window now compares parsed origins (electron/navigation.js), and
everything else opens in the system browser as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Devices handlers learnt who was calling from the `headers` the RPC
server hands them. Those are the HTTP request's headers with each
message's own headers concatenated after them, and the last value of a
name wins (RpcServer: requestHeaders.concat(message.headers)). The
message's headers are part of the NDJSON body the client writes, so a
client could put its own `x-kanbot-caller` there and override the id the
auth middleware set. Today that only lets a caller lose its identity (an
id it cannot know resolves to nobody), but any id it ever learnt would
let it act as that caller, and a device could make itself look like
someone else to list/revoke.

Handlers now read the caller from the HTTP request itself (rpcCaller):
the RPC server runs each handler with the request fiber's context, where
HttpServerRequest is the request server/api/rpc.ts passed, carrying only
the middleware's id. The headers-based helper is gone. The test sends a
paired device's Devices.list with a forged caller header in the message
and checks the handler still knows it is that device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any paired device could call Devices.offerPairing and Devices.revoke on
any device. A phone left unlocked, a stolen laptop or a compromised
script token could therefore mint codes and pair more devices of the
attacker's choosing (persistence that outlives revoking the first one),
or revoke every other device to lock the owner's laptop and phone out.

mayManageDevices (a core rule) now lets the computer Kanbot runs on (the
packaged app's window or, with no per-launch token, loopback) do both,
and a paired device only revoke itself (signing out). Anything else is
NotAllowedFromDevice, a domain error in the Devices contract with its
catalogue key. Settings › Devices, opened on a paired device, hides
"Pair a device" and the other devices' Revoke and says where to do it.

Tests: over HTTP a paired script lists devices and signs itself out but
gets NotAllowedFromDevice for offering a code and revoking another, while
loopback and the per-launch token may; the section hides the buttons on
a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Revoking a device stopped its next request, but not the ones already
open: the SSE feed (/api/events) and the RPC LiveUpdates stream kept
delivering every task, question and notification to the revoked device
until it happened to reconnect, which a stolen laptop left open never
does.

The callers registry now remembers, for every open request, how to cut
it off, and Devices.revoke closes all of the device's requests except
the one asking (a device signing itself out still gets its answer).

Cutting the connection was not enough for the RPC stream: h3 pipes a
streamed Response into the socket and never cancels it when the socket
goes, so the RPC server went on feeding a subscription nobody read,
one bus listener and an unbounded queue per closed tab, revoked or not.
server/api/rpc.ts now relays the body and cancels it when the connection
closes, which ends the RPC's scope and the subscription behind it.

The test opens a revoked device's SSE feed and LiveUpdates stream and a
bystander's feed, revokes, and checks the first two end at once, the
third stays open, and no bus listener is left behind; and that a device
revoking itself gets its answer while its feed ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/auth/pair is the one request anybody may make, and it read the
whole body into memory before looking at it (h3's readBody buffers it),
whatever its size: the review sent 400 MB and the server's memory grew by
1.2 GB, and nothing stops a stranger on the network from doing that
again and again until the hub runs out.

The route now reads the body through readBodyWithin before decoding it: a
Content-Length over 4 KB is answered 413 unread, and a chunked body stops
being read the moment it passes 4 KB, with Connection: close so the rest
is never read either. Pairing is the only open route that reads a body
(the others are GET), so it is the only one that needs it.

The test sends a declared 5 KB body, a chunked one that passes 4 KB and
stops (413 straight away), and 64 MB in 64 KB chunks, which is cut off
long before it is all sent; a normal pairing still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A task's attachments come from whoever can comment on it, from agents,
from Discord and from trackers, and GET /api/attachments/:id served them
with the type their name implied and nothing else. An SVG with a
<script> in it, opened from the task page (its file link opens it in a
new tab), ran as Kanbot's own origin: stored XSS with the whole API in
reach, Devices.offerPairing included, so one uploaded file could pair the
attacker's device.

Every attachment is now served with:
- X-Content-Type-Options: nosniff, so the browser takes the type we give
  and never runs a "picture" that is really HTML;
- Content-Security-Policy: default-src 'none'; img-src 'self' data:;
  media-src 'self'; style-src 'unsafe-inline'; sandbox, so whatever does
  open has no script, no requests and an origin of its own;
- Content-Disposition: inline only for a closed list (png, jpeg, gif,
  webp, avif, pdf, plain text, mp4, webm), attachment with the file's
  name (RFC 6266) for everything else.

SVG is a download, still served as image/svg+xml: a picture in a page
(the gallery's <img>) ignores the disposition and never runs script, and
opened by itself it is saved, not rendered; if a browser rendered it
anyway, the sandbox stops its script (checked in headless Chrome: script
runs without the headers, is blocked with the CSP, the navigation
downloads with the disposition, and <img> still shows it).

A PDF keeps the CSP without `sandbox`: the browser's PDF viewer is a
plugin, which a sandboxed document may not load, and a PDF's own scripts
run inside that viewer, never as the page's origin.

Every answer also gets a baseline beside X-Frame-Options: nosniff and
Referrer-Policy: same-origin. No site-wide CSP: Nuxt's pages need inline
scripts and styles, so one strict enough to matter would break them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a per-launch token (npm run dev, npm start, a server someone
runs by hand), a request from this machine's socket is trusted. Put a
reverse proxy on the same machine in front of it (nginx, Caddy, a
tunnel, `tailscale serve`) and every client of the proxy arrives from
127.0.0.1 with a loopback Host: anyone who can reach the proxy had the
whole API.

A request from a loopback socket that carries X-Forwarded-For,
X-Forwarded-Host, X-Real-IP or Forwarded now counts as another machine
when no per-launch token is set: a paired device's token works, the
open routes (/pair and its POST, the assets) stay open so devices can
pair through the proxy, and anything else is refused 403 with its own
key, error.behindProxy, which says to pair the device or run Kanbot with
a per-launch token. The headers are only checked for presence, never
believed: who the client is stays unknown. With a per-launch token set
the token decides, as for any request from this machine.

Known limit: through the proxy every client shares one pairing rate
limit bucket (127.0.0.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under `nuxt dev` the app runs in a worker on a Unix socket, behind Nuxt's
listener and Nitro's dev proxy. The socket has no address, so the auth
middleware counted every request as this machine: with `nuxt dev --host`
(or NUXT_HOST/HOST set), anyone on the network who sent a loopback Host
had the whole API, no token needed.

The forwarding headers cannot fix that: Nitro's dev proxy passes a
client's own X-Forwarded-For through untouched and adds one only when
there is none (nitropack createHTTPProxy, `if (!hasHeader)`), so a client
that sends `X-Forwarded-For: 127.0.0.1` is believed. Reading the last
entry would have kept the hole.

Instead the dev listener itself says who the client is: nuxt.config's
`listen` hook (dev only, given the real listener, forked or not) adds a
request listener that runs before anything else and writes the socket's
address, its local port and whether the request carried forwarding
headers into x-kanbot-dev-peer, overwriting whatever the client sent,
signed with a random key put in the environment the Nitro workers
inherit (and taken out of the worker's at boot, so agents do not get it).
In dev the worker believes only a stamp with that key; a request without
one (someone talking to the socket directly) is another machine, never
this one. Outside dev the stamp is removed and ignored. The address also
feeds the pairing rate limit (event.context.clientAddress), which counted
every dev client as one 'unknown'.

The test puts a real listener with the stamp and a proxy that behaves
like Nitro's in front of the middleware on a Unix socket: this machine
gets in, another machine with a loopback Host, a forged X-Forwarded-For
or a forged stamp does not, it can pair and use its token, and a request
straight to the socket proves nothing. Checked against a real
`nuxt dev --host 127.0.0.1`: loopback 200, a forwarded request 403
behindProxy (the stamp's flag got through), a request straight to the
worker's socket 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Electron passed the per-launch token to its server as KANBOT_LOCAL_TOKEN,
and the server deleted it from process.env at boot. Deleting it changes
only the copy JavaScript sees: the process's initial environment, which
`ps -E` (macOS) and /proc/<pid>/environ (Linux) show to every program of
the same user, still has it. Those programs include the agents the
server runs on arbitrary repositories, so a prompt-injected agent could
read the token and drive the app (approve its own commands, offer pairing
codes) as if it were the window.

The shell now starts the server with only KANBOT_LOCAL_TOKEN_VIA=parentport,
which carries no secret, and answers the server's request for the token
over the utility process's message port (electron/localAuth.js). At boot
the server marks the token as pending before Nitro listens, so every
request from this machine is refused until it arrives (the shell's boot
poll simply retries), then asks for it on process.parentPort; the first
well-formed answer is the token and nothing changes it afterwards.
Started with the flag but without a parent port, it stays shut rather
than open. KANBOT_LOCAL_TOKEN is still read (tests, a server run by hand).

Chosen over a 0600 file in the data dir: a file is readable by the same
user's programs for as long as it exists and leaves the token on disk if
the server dies before unlinking it; the port is private to the parent
and the child and never touches disk or the environment.

Tests: the server asks over a fake parent port, refuses everything until
the token arrives, takes only the first well-formed one and the token is
in no environment variable; without a port it stays shut; the shell
answers the request on the port and main.js puts no token in the env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop app answers under its own name, kanbot-<random>.localhost
Some checks failed
CI / check (push) Has been cancelled
6765b4d88c
Cookies are not scoped by port. The packaged app set its per-launch
cookie (kanbot_local) on 127.0.0.1, and after the tray's "Open in
browser" the system browser had it too: it then sent it to every server
on 127.0.0.1, whatever the port, for instance a dev server an agent
started from a cloned repository's code, which could log it (and drive
Kanbot with it) or overwrite it with a Set-Cookie of its own.

The shell now makes a name for this machine once per install,
kanbot-<16 hex>.localhost (kept 0600 in the user-data folder), and the
window, its cookie and the browser handoff all use it; the shell's own
REST calls and boot poll stay on 127.0.0.1 with the Bearer header. The
name goes to the server with the token over the message port (it is not
secret, but must be known), and hostIsLoopback accepts exactly that name
besides the loopback names; the server takes no name the shell could not
have made (/^kanbot-[a-z0-9]{12,32}\.localhost$/). Nothing changes in
how the server listens: it still binds 127.0.0.1.

Browsers resolve *.localhost to this machine by themselves (Chromium,
Firefox), and Safari asks macOS's resolver, which does too (getaddrinfo
answers 127.0.0.1 and ::1 here), so the handoff needs no fallback to
127.0.0.1. Checked in headless Chrome against a server bound to
127.0.0.1: the name resolves, and a cookie set under it is not sent to
127.0.0.1 or localhost on another port.

Remaining: the cookie still goes to the same name on another port, so a
program that reads the name from the user-data folder and gets the
browser to open it on its own port could still read the cookie; it is
one more step than before, not a wall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: authentication, as built and as reviewed
Some checks failed
CI / check (push) Has been cancelled
3d34b19495
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
startRun went through TaskRun.execute, which computes the execution id by
hashing the idempotency key with crypto.subtle.digest. WebCrypto runs that
digest on libuv's thread pool, so the run was handed to the engine only when
the pool answered: after an unbounded number of event-loop turns, and for
runs started together, in whichever order their digests came back. The task's
state already names the execution (executionIdOf, the same hash done
synchronously with node:crypto), so the run now goes straight to
WorkflowEngine.execute under that id, as Workflow.execute would after its
hash.

That hop is what made the scheduler and gate tests flaky in full-suite runs,
where the forks contend for the pool: settle() only lets event-loop turns go
by, so a sweep's runs were sometimes not started yet when the test looked
(test/machines/scheduler.test.ts: "expected null to be 'slot'", "expected []
to deeply equal [1, 2]"; test/characterisation/scheduler.test.ts: enqueuePlan
"called 0 times"), started b before a, or were a stage behind at the gate
(test/rpc/workflow.test.ts: "expected 'agent' to be 'human'"). Delaying the
digest by a few milliseconds reproduces all of them on every run.

test/workflow/runStart.test.ts makes the digest never answer: two runs
started one after the other still reach their first stage within settle(),
in order, under the execution id their state names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RunHost.record writes the run's state only while its runId is still the
task's, so a stopped run can never put its state back (nothing else in the
run writes the read model, it has no finalizer or onInterrupt, and an
interrupt's replay answers every activity from the engine's record). But a
step is recorded in one activity and its action runs in the next, and the
action did not check: a stop landing between the two (the human finishing at
the gate, a card dragged away, a run dropped) still ran the node. At a gate
that moved the finished card back to the gate's column, set it waiting and
notified; at a stage it started an agent for a run nobody had any more.
parkOnCap and blockOnFailure, which follow a wait, had the same gap.

The node kinds now return Stop when the task's run is not theirs, and
parkOnCap and blockOnFailure take the runId and do nothing for a stale run,
as stopLooping already did.

test/workflow/staleRun.test.ts stops the run from inside the step's own
write (the task.workflow message saveState sends), which is exactly between
the two activities every time: finishing at the gate leaves the task
finished, not waiting, and dropping a run as it reaches Develop starts no
agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: how a run reaches the engine, and why its actions check their run
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
1b53a50242
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refuse to start on a Node older than 24
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
f400664f0b
On Node 22 node:sqlite has no DatabaseSync.isTransaction, so the dev worker
died at boot and every page answered a bare 500 with no reason given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No bare @ in the catalogues
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
1c2f11f08b
vue-i18n reads @ as a linked message, so plugins.problem.noMetadata and
undecorated failed to compile and took the dev page down with them. They are
reworded rather than escaped as {'@'}, which the server's translate() would
print verbatim. A test compiles every message, the plugins' included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 10 contract: what a node serves the hub
Some checks failed
CI / check (push) Has been cancelled
f48d583454
NodeRpcs (NodeHello, NodeSessions, NodeWorkspace) over the WebSocket a node
dials. The hub is the client; a session's messages are the stream of the
call that started it, numbered so a reconnect attaches after the last one
seen, and the SessionSpec's callbacks (tools, gate, ask) are messages the
hub answers with NodeSessions.reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NodeLinks: the hub's registry of connected nodes
Some checks failed
CI / check (push) Has been cancelled
3d1c540c06
The seam between the transport, which registers a link per open socket, and
everything that reaches a node through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The board, the host's notifications and the app's text read the Kanbot
database (settings, tasks), so the host took them in through pluginContext
and could not run where there is none. They are now handed in with the
host's options: appSurface on the hub; a node will hand in its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Auth: the node socket is its own route kind, open to a paired device only
Some checks failed
CI / check (push) Has been cancelled
b8a1072317
classifyRoute names GET /api/nodes/ws 'nodeSocket'; decideAccess lets
nothing but a valid device token through it (not loopback, not the
per-launch token). NodeLinks' device ids are strings, as devices' are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NodeHandlersLive serves NodeRpcs on a node. Sessions run as fibers of a
FiberMap in the layer's scope, detached from the call that started them;
their messages are numbered per session and journalled in memory until the
hub acks them (JOURNAL_LIMIT unacked messages ends the session). Tool calls,
gate requests and questions are messages the hub answers with reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test the node's handlers through an in-memory RPC client
Some checks failed
CI / check (push) Has been cancelled
aedc52690a
Sessions (events, callbacks, stop, drop and re-attach, concurrent
attaches, ack, errors, the journal bound), Node.hello, NodeWorkspace over
a real repository, the runtime contract through the handlers, and a guard
that the handlers' module graph reaches no database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: a node's handlers and its sessions
Some checks failed
CI / check (push) Has been cancelled
598ddbd6c2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Component tests read the catalogue's text, not its keys
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
1850fccc19
They were written while en.json failed to compile (a bare @), when every
message rendered as its key. Now the catalogue loads, so they look text up
through the app's own i18n (test/support/text.ts) and check what is shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge branch 'refactor/phase-9-auth' into refactor/phase-10-nodes
Some checks failed
CI / check (push) Has been cancelled
4ae242ad76
test/support/text.ts typechecks under the test tsconfig
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
fc61c05f69
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge branch 'refactor/phase-9-auth' into refactor/phase-10-nodes
Some checks failed
CI / check (push) Has been cancelled
3856ca5e29
A machine is online (this one always, a node while it has a link: read,
never stored); MachineConnected/MachineDisconnected with the Machines port's
registerNode and seen; machines.device_id (0021). A session can be
unreachable (it keeps its slot) or lost, and a task's agent unreachable.
MachineRuntimes chooses a session's runtime by its machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MachineWorkspaces and MachineRuntimes reach a connected node over its link;
RemoteRuntime drives NodeSessions, answers callbacks once per call id,
acknowledges what it passed on, waits a dropped link out for a lease and
re-attaches after the last message, else the session is lost and its task
resumes by external id when the node is back. Node links register their
machine (followLinks, in the background). A node away takes no work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Show nodes online or offline, and tasks whose node is unreachable
Some checks failed
CI / check (push) Has been cancelled
d11f35095a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nitro's websockets are on. The upgrade checks a node device's Bearer
token itself (upgrades never reach the auth middleware); the open peer
becomes an Effect Socket, and the accept runs on the app's runtime: an
RpcClient for NodeRpcs over it, Node.hello, the protocol and hub-id
checks, then NodeLinks.register until the socket closes. NodeLinksLive
keeps the links by machine, a newer one replacing the older, and revoking
a device closes its links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A SocketServer whose run dials the hub with the node's token and dials
again with backoff (500 ms doubling to 30 s, reset by a connection that
lasted 10 s), under RpcServer's socket protocol. The node's layer runs
the runtime plugins on an in-memory store with no board, settings or
HostOS, and the stub handlers until track N's NodeHandlersLive. main.ts
pairs (node.json, 0600) and runs; scripts/build-node.ts bundles it to
.output/node/index.mjs without the Nitro server or the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests: the node socket over real HTTP, and the node's entry
Some checks failed
CI / check (push) Has been cancelled
c5a781d947
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge refactor/phase-10-nodes
Some checks failed
CI / check (push) Has been cancelled
7341e4c061
CLAUDE.md: Nodes, the socket's transport and auth
Some checks failed
CI / check (push) Has been cancelled
4051fe6cfa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The node process serves the real handlers
Some checks failed
CI / check (push) Has been cancelled
2928c13ba3
nodeLayer builds NodeHandlersLive over the node's identity, its local
workspace and the runtime plugins, in place of the stub (kept for the
transport tests). One NodeIdentity: the plain type is the service's shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
linkedNode registers the node's real handlers into the app's NodeLinks and
cuts the link like a dead socket; the runtime contract passes through
RemoteRuntime; the scheduler's second machine is a connected node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merged base serves NodeLinksLive (string device ids), so the in-memory
stand-in goes and the hub's layers sit on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
End-to-end: a session from the hub to a node over the real socket
Some checks failed
CI / check (push) Has been cancelled
4e694f2f9f
nodeLayer takes a runtimes layer in place of the plugins' (tests only).
That exposed a bug: Node.hello read AgentRuntimes from the RPC server's
context at call time while the session host used the layer's, so hello
could announce runtimes other than the ones sessions start. Hello now
uses the runtimes its layer was built with.

The test pairs a node built from nodeLayer, runs a scripted session
(events, tools, the gate, a question answered over RPC) on a project on
the node, cuts the socket from the hub with the question pending and
from the node while the runtime keeps emitting, checks the timeline
line by line, and revokes the node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's token opens the node socket and nothing else
Some checks failed
CI / check (push) Has been cancelled
bb33c48f76
decideAccess now sees the device's kind: a node device is refused
(Forbidden: nodeDevice) on every route but the node socket, from anywhere,
as a Bearer or a cookie, and the node socket lets in only node devices.
A node taken over is no longer the user on the hub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Projects.create can put a project on a connected node
Some checks failed
CI / check (push) Has been cancelled
a08d14adc9
ProjectInput takes an optional machineId; the core already checked the
folder on that machine and recorded it, but the contract dropped the field,
so there was no way to make a project on a node but by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's hello names its machine id; the hub took its word, so another
paired node could say the same id and take its projects, sessions and
prompts. claimNodeMachine now decides, in one transaction before the link
is registered, whether the device may be that machine (decideNodeClaim):
a machine bound to another paired, unrevoked device is refused
(machineTaken), and a device is one machine for good (deviceTaken); the
row is bound in the same step. Revoking the device frees the machine for
the device paired next. A refused node is closed 4403 and logged.

NodeInfo is bounded: machine id-like and at most 64 characters, a name of
1-80, at most 32 runtimes and 1000 held sessions; a hello that does not
decode is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tool resolved any path the agent gave and read it on the hub, so a
node (or a prompt-injected agent) could attach the hub's secrets.key, its
database or ~/.ssh keys as a screenshot. MachineWorkspace.readImage now
reads it on the session's machine: the path must stay inside the working
folder once symlinks are followed (outside as written is refused without
looking), be a PNG, JPEG, GIF or WebP by name and bytes, and be at most
5 MB. A node serves it as NodeWorkspace.readImage (base64, bounded), so a
node's screenshots now work, and the hub checks the bytes again. The agent
is told why an image was refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WebSocket upgrades only on the node socket's path, frames capped at 8 MiB
Some checks failed
CI / check (push) Has been cancelled
bd98b6f610
With Nitro's websocket support on, crossws accepted an upgrade on any path
without hooks of its own, past the auth middleware, with ws's 100 MiB
frames: anyone on the LAN could hold sockets open on / or /api/rpc. A
Nitro plugin (server/plugins/websockets.ts) now sets crossws's global
upgrade hook (guardWebSockets) before the upgrade listener is made: 404 on
every path but /api/nodes/ws, whose own hook decides, and maxPayload 8 MiB
(a 5 MB screenshot as base64 fits). The test app uses the same guard.

Checked by hand against nuxt dev (the dev proxy reaches the worker's
crossws: node socket opens, other paths 404) and the production build
(also: an oversized frame closes the socket with 1009).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bound what one node can cost the hub
Some checks failed
CI / check (push) Has been cancelled
98b0fcea06
- Orphans a node's hello lists are stopped in the background, eight at a
  time, off followLinks' change loop: a node listing many fake sessions no
  longer stalls every other node's connect (each stop waited 5 s, in turn).
- The peer socket closes (1008) when more than 32 MiB or 4096 frames wait
  unread, instead of buffering without bound.
- A node device may upgrade 30 times a minute; past that, 429 with
  Retry-After before a socket opens (error.tooManyConnects).
- At most 64 callbacks per session wait on an answer; past that a call is
  Rejected at once. The once-per-call-id set is now the calls in flight
  (replays are already skipped by seq), so it no longer grows forever.
- NodeInfo's runtimes and sessions were bounded in the binding commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Markdown: links only to the web or mail, no remote images
Some checks failed
CI / check (push) Has been cancelled
b4ad20ddc5
Escaping < kept raw HTML out, but [x](javascript:alert(1)) still rendered
as a link (marked 18 outputs the href), and text from a tracker, an agent
or now a node is rendered in the app. renderMarkdown now uses its own
Marked instance whose renderer writes a link only for http(s) and mailto
hrefs (anything else is its text) and never an <img>: an image is a link
to it, so no remote image loads by itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A device revoked while its node's hello is in flight gets no link
Some checks failed
CI / check (push) Has been cancelled
33c2b092c5
Revoking closed the device's links, but a node whose hello was still on
its way registered right after and stayed up. NodeLinks now remembers the
devices it closed for good: a link of one registered later is closed at
once and never announced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's AskRequest reaches the board only as its kind's payload
Some checks failed
CI / check (push) Has been cancelled
8807642a67
The payload crossed the wire as Schema.Unknown and was handed to spec.ask
as is, so a node could put any shape on the board as a question. It is now
decoded against its kind's Schema from the SDK (FreeQuestion,
ChoiceQuestions, PlanQuestion, ApprovalQuestion; extra keys dropped);
one that does not decode is Rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kanbot-node pair: http(s) hubs only, no redirects, a warning in clear
Some checks failed
CI / check (push) Has been cancelled
864445e9f3
fetch followed a 307/308 and re-POSTed the pairing code to wherever it
pointed. pairNode now refuses redirects (redirect: 'error') and any hub
address that is not http(s); the CLI warns that a plain-http hub sees the
code and the token cross the network in clear until Phase 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
node.json is written atomically, 0600 from the start, in a 0700 data dir
Some checks failed
CI / check (push) Has been cancelled
24cf1dc5bb
writeNodeConfig wrote over an existing node.json and narrowed its mode
afterwards (readable by others for a moment if it was), and could leave it
half written. It now writes a new 0600 file beside it and renames it over;
the data dir it (or kanbot-node at start) creates is 0700. main.ts says to
prefer node.json to KANBOT_NODE_TOKEN, which same-user processes can read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pairing code pairs the kind of device it was offered for
Some checks failed
CI / check (push) Has been cancelled
bbd979b6ba
Any code from Settings › Devices could pair a node, so a code offered for
someone's phone could become a node the hub sends prompts to. An offer is
now for a node or, by default, for a browser or a script
(Devices.offerPairing takes an optional { kind }; no payload still works),
and redeeming it as the other kind fails like a wrong code (it counts
towards burning it; every failure is the same PairingRefused / 401).

Settings › Devices' pairing card has a "For a node" switch: it asks for a
node's code and shows the kanbot-node pair command to run there instead of
the address and QR code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's project does not open on the hub's editor or read the hub's disk
Some checks failed
CI / check (push) Has been cancelled
45177f5b3c
Opening a project or task on a node ran the hub's editor or terminal on a
path that is the node's (it could name one of the hub's own folders), and
the project list read the launcher labels from the hub's file system at
that path. openFolder now refuses a project not on this machine with
HostActionFailed error.opensOnItsMachine (naming the machine) before
anything is read or launched, and the labels of a node's project are the
global ones until they are read through its workspace (Phase 11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: the node hardening, and what a node and the hub trust by design
Some checks failed
CI / check (push) Has been cancelled
5016c14fb9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
End-to-end test pairs its node with a node code
Some checks failed
CI / check (push) Has been cancelled
e04457cf78
A pairing code now pairs only the kind it was offered for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's session never sends a frame over the hub's cap
Some checks failed
CI / check (push) Has been cancelled
3c38c97867
The node's session streams went out as whatever the RPC server took from
the queue at once, written as one WebSocket frame: a backlog replayed after
a reconnect, or one large event, passed the hub's 8 MiB cap, the hub closed
the link (1009), the node redialled and was attached after the same seq,
forever.

- followSession streams one message per chunk (Stream.rechunk(1)).
- clipEvent holds an event under 1 MiB (strings cut with a truncation
  marker, a tool's input as its cut JSON); a tool call, gate request or
  question over 4 MiB is settled on the node, failing closed.
- boundReply holds the hub's replies under the cap too.
- Backstop: the peer socket keeps its close code (ws reports the cap as an
  error before closing 1009); three links in a row closing 1009 with the
  session's messages pending and none through fail the session with
  error.nodeMessageTooLarge instead of re-attaching forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The node socket's upgrade guard compares the raw path
Some checks failed
CI / check (push) Has been cancelled
6a805c5a60
The guard compared `new URL(request.url).pathname`, which resolves `..`,
`.` and `%2e`, while h3 picks a route's websocket hooks from the path as
written. `/x/../api/nodes/ws`, `/api/nodes/./ws` and `/api/nodes/%2e/ws`
passed the guard, resolved to no route, and crossws accepted them with no
token (101). The guard now takes the path exactly as the request line wrote
it (and a plain Host), and lets through only `/api/nodes/ws` with an
optional query, so h3 always resolves what it lets through to the node
route and its token check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A screenshot may come from the temp folders or the task's attachments
Some checks failed
CI / check (push) Has been cancelled
b8f1178722
attach_screenshot read only inside the working folder, so a screenshot
saved where browser tools save them (Playwright MCP under the OS temp
folder, Chrome DevTools often /tmp) was refused, and so were the human's
files in the task's attachments folder. readImage now also accepts the
machine's temp folders (Platform.tempDirs: the OS's, /tmp, /private/tmp on
macOS) and, for a session on the hub's own machine, the task's attachments
folder, each checked after symlinks as the working folder is; the image
checks are unchanged. The tool's description says where images may be read
from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node's question is bounded before it reaches the board
Some checks failed
CI / check (push) Has been cancelled
efa98cede5
The hub decoded an AskRequest's payload against the SDK's question
Schemas, whose strings and arrays are unbounded, so a node could put a
question of megabytes on a task. The hub now decodes it against bounded
copies (askPayloads: text, context, plan and command up to 20,000
characters, headers, questions, labels, descriptions, tool and reason up to
500, at most 20 options and 10 questions); past them the call is Rejected.
The SDK's public Schemas are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing the hub forks for a node outlives its link or session uncounted
Some checks failed
CI / check (push) Has been cancelled
37097fbf8e
Orphan stops were forked detached, so they outlived the link that listed
them; they are now forked in the link's scope (NodeLink.scope: the accept's
scope, the socket's lifetime). Callbacks refused past
MAX_CALLBACKS_IN_FLIGHT were each forked uncounted, every one waiting for a
link to send its refusal; the refusals waiting are now counted
(RemoteState.refusing) and capped at as many again, past which a call is
dropped with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
readImage checks that the file it opened is the one its path leads to
Some checks failed
CI / check (push) Has been cancelled
4991c16027
readImage resolved the path, checked it was inside an allowed folder, then
opened the resolved path, following symlinks again: a folder on the path
swapped for a symlink out between the check and the open handed over a
file outside. It now opens with O_NOFOLLOW (where the OS has it) and, after
the open, resolves the path again, checks it is still inside, and refuses
unless the opened file and that one have the same device and inode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node may not claim a machine that was never a node
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
49948ef511
decideNodeClaim refused a machine bound to another live device, but a
machines row with no device that is not this hub's current id (the hub's
own row under an earlier machine id, with its projects) could be claimed by
any node device, which then took over its projects. The claim now also
reads the row's role and refuses one that is not `node` (`notANode`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 11 contract: hub settings, Discovery, Shell, NodeHost
Some checks failed
CI / check (push) Has been cancelled
fcd67bfc76
The hub section (off, port 7433) switches on the LAN listener and the
announcement; Discovery lists the hubs mDNS sees; Shell asks the desktop
shell to switch to Connect; NodeHost is a node's HostOS, for the desktop a
person sits at. The groups are wired by the tracks that implement them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add bonjour-service 1.4.4 for LAN discovery
Some checks failed
CI / check (push) Has been cancelled
3d534b496b
Pure JS (multicast-dns); announces and finds hubs as _kanbot._tcp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hub mode's HTTPS listener, opened and moved live from the hub settings
Some checks failed
CI / check (push) Has been cancelled
0f5c61efdd
A second listener on hub.port with the data dir's certificate serves the
same app (pages, RPC, upgrades through crossws behind the same guard);
every socket it accepts is another machine's to the auth check. Hub.status
and Discovery.hubs over RPC; the LAN behind a Lan port (bonjour-service).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code offered for a desktop (Devices.offerPairing({ kind: 'desktop' }))
pairs two devices in one transaction: the window's browser device (its
token is the kanbot_device cookie, Secure over HTTPS) and the node it
runs (its token in the answer: { deviceId, nodeDeviceId, nodeToken }).
devices.companion_id (migration 0022) points each at the other.
Revoking either revokes both and cuts both off. A code for another kind
fails like a wrong one. Settings › Devices offers the three kinds and
shows a desktop as one entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Host actions follow the person: NodeHost on the node, routing on the hub
Some checks failed
CI / check (push) Has been cancelled
a69d68ee22
A node serves NodeHost over its link, from a HostOS that reaches no
database (the local machine's, with English text); HostOSNone is gone.
On the hub, Host.* and openProject/openTask ask hostOfCaller: this
machine opens on its own desktop, a window paired together with a
linked node opens on that node's, anyone else is refused
(error.opensOnYourComputer). A project opens only from the machine it
is on. The test RPC client carries a caller (loopback by default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shell.openConnect asks the desktop shell for its connect screen
Some checks failed
CI / check (push) Has been cancelled
8e37b69758
Over the utility process's parent port ({ type: 'kanbot-shell',
action: 'open-connect' }), only from the desktop app's own window (a
Local caller) and only when the shell started the server to talk over
that port; anything else is ShellUnavailable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Desktop shell: connect to a hub
Some checks failed
CI / check (push) Has been cancelled
be25b37386
shell.json says whether this computer runs its own board or a hub's. The
connect screen (a local page) lists hubs mDNS finds, shows the certificate
fingerprint an address presents, and pairs as a desktop over a connection
pinned to it: the window's device cookie goes into the session, the node's
pairing into userData/node/node.json, and the app restarts in connect mode,
with no local server, the hub's board in a window whose session accepts only
the pinned certificate for the hub's host, and the bundled node forked as a
utility process. An offline page offers Retry and Use this computer only;
the tray says which hub this is and can disconnect (best-effort revoke).
desktop-prepare ships the node's external packages and a bundled
bonjour-service; desktop-verify checks they are in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project picks its machine, and moves to another when idle
Some checks failed
CI / check (push) Has been cancelled
96d1cbbf59
The project form has a machine picker (reachable machines, this one by
default) for a new project, and Projects.update takes machineId: the
project moves only to a machine that is reachable (MachineUnavailable),
has its folder (NotADirectory, checked there), and while none of its
sessions is open (ProjectBusy, error.projectBusy). Its tasks forget their
resume ids (those were the old machine's). ProjectChanged with machineId
tells the screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: Desktop modes
Some checks failed
CI / check (push) Has been cancelled
4eb5af018a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nodes pin the hub's certificate; kanbot-node discover
Some checks failed
CI / check (push) Has been cancelled
f1401b65d9
kanbot-node pair reads the https hub's certificate first: --fingerprint must
match it, or a person confirms it at a terminal; the code goes over a
connection pinned to it and node.json keeps the pin. Every wss dial is
pinned (undici's WebSocket over an Agent whose connector checks
fingerprint256), KANBOT_HUB_FINGERPRINT overrides. Tests over real HTTPS:
remote treatment on the hub listener (dev too), Secure cookies, pairing,
the node over pinned wss end to end, discovery over a fake and real mDNS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One hub certificate for callers at the same moment; CLAUDE.md on hub mode
Some checks failed
CI / check (push) Has been cancelled
d4c72deed5
The identity and hub mode's listener both ensure the certificate as the
runtime starts: on a first boot each made a pair, and one went on with a
fingerprint the files no longer had. CLAUDE.md: hub mode, remote treatment
on its listener, LAN discovery, node pinning; the real-mDNS test is skipped
under CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Machines page: the computers, sharing this one as a hub, the hubs around
Some checks failed
CI / check (push) Has been cancelled
89532433fd
/machines lists the machines (role, reachable, last seen, agents
running from Machines.activity, each one's limit), switches the hub
sharing (hub.enabled, port) with where the listener stands, lists the
hubs on the network, and offers Connect to a hub through the desktop
shell (off, and saying why, outside the desktop app's own window).
Linked from Settings › General and the app menu.

Hub.status and Discovery.hubs are track A's: useHubStatus and
useDiscoveredHubs are stand-ins (settings only, no hubs) to swap at
the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Setup step Machines: this computer alone, shared as a hub, or connected
Some checks failed
CI / check (push) Has been cancelled
da5c0abcd9
A core step (since 2, right after welcome), so a fresh install sees it
in order and an install set up before (version 1) sees it once with the
end. Three choices: this computer only (default; sharing off), share as
a hub (sharing on, with its addresses and fingerprint), connect to a hub
(the desktop shell's connect screen; off in a browser, saying why). The
summary at the end says which. The story fixture for a step no screen
knows is now 'backups'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ESLint leaves .worktrees alone
Some checks failed
CI / check (push) Has been cancelled
ad5b34b2e0
Agents' checkouts of the repo live there; linted from the main checkout,
their files missed the suppressions baseline and failed lint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Machines page and setup read the real Hub.status and Discovery.hubs
Some checks failed
CI / check (push) Has been cancelled
d1793d9e2c
The stand-ins go: useHubStatus reads Hub.status (refreshed on
settings.changed and every 3 s on screen, since the listener follows the
setting by itself) and shows its hubMode.error key; useDiscoveredHubs
polls Discovery.hubs. A test pins the desktop shell's Disconnect
(Devices.revoke as the window's Bearer, no Origin) revoking both devices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/hub/desktopEndToEnd.test.ts runs it over real sockets with the shell's own
modules (discovery, hubPin, pairing, hubLink) and the node process's layer: a
desktop code over pinned HTTPS, the node over pinned wss, a project on the
node's machine in a real git repo, a scripted session answered from the window,
Host.openProject reaching the node's (recording) desktop and refused for a
browser paired alone, an impostor pin never linking, and Disconnect revoking
both devices. nodeLayer takes a host desktop for tests; startNode keeps its logs,
so nodeOverHub waits on the refusal instead of sleeping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hub mode no longer renames the Mac: announce under the hub's own host name
Some checks failed
CI / check (push) Has been cancelled
343d497b46
bonjour-service publishes the SRV target with A/AAAA records for
os.hostname() by default, a name macOS's mDNSResponder owns; answering for it
from a second responder is a conflict macOS settles by renaming the computer
(Alexs-Mac-mini-691 -> -746 on turning hub mode on, and again on every run of
the real-mDNS test). The announcement now points at kanbot-<machine id>.local,
which only this hub answers for; hubs are reached by address, so nothing
else changes. The real-mDNS test checks the heard host name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: the hub announces under its own host name
Some checks failed
CI / check (push) Has been cancelled
1e306a1608
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
desktopEndToEnd: type the shell's pairing outcome
Some checks failed
CI / check (push) Has been cancelled
f9bb98b9e5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discovery keeps a hub's announcements apart from one posing as it
Some checks failed
CI / check (push) Has been cancelled
f8c5b0f3b9
Announcements are kept per service identity (fqdn, port, addresses), not
per the machine id they claim; one id announced with different
fingerprints is listed once per fingerprint, each marked conflict, on the
Machines card, the shell's connect list and kanbot-node discover. The
announced fingerprint is labelled unverified, and discover no longer says
to pair with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hub listener bounds handshakes and connections, and closes at once
Some checks failed
CI / check (push) Has been cancelled
6dfc18c6a7
A 10 s TLS handshake timeout, header and request timeouts, at most 256
connections and 32 per address; every TCP connection is tracked from
'connection', so closing the listener ends those still handshaking instead
of waiting up to 120 s for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Browse the LAN for hubs only while someone looks for them
Some checks failed
CI / check (push) Has been cancelled
96d983161c
Discovery.hubs starts browsing and it stops 60 s after the last ask, instead
of every server process browsing mDNS from boot; LanBonjour closes its
multicast socket once nothing announces or browses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge origin/refactor/phase-11-discovery into wip/p11-security
Some checks failed
CI / check (push) Has been cancelled
c75f195bb7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shell keeps at most 64 announced hubs and tells changes in batches
Some checks failed
CI / check (push) Has been cancelled
a7388e4336
electron/discovery.js drops the announcement heard longest ago past 64 (the
server's cap) and publishes a burst of changes as one list after 250 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Another desktop opens folders for the hub only with a preset
Some checks failed
CI / check (push) Has been cancelled
8d7da23a06
The hub's launcher settings are hub-wide and any paired device can change
them, so a custom command or picked app sent over NodeHost would run on
someone else's computer. remoteHost sends the node platform's default
preset instead, and NodeHost refuses a custom or app choice from the wire
(error.remoteLauncherRefused). This machine keeps its custom launchers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only this computer may change hub sharing and Open with
Some checks failed
CI / check (push) Has been cancelled
8165d9b93a
Settings.update refuses the hub and launchers sections to a paired device
(mayChangeSettings -> SettingOnlyHere, error.settingOnlyHere), so a device
cannot switch hub mode off, move its port, or choose the command folders
open with. The sharing card and Settings > Open with show locked, with the
reason, when the page is not on this computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project move checks again for open sessions inside its transaction
Some checks failed
CI / check (push) Has been cancelled
c18e588bd2
The check before the target's networked folder call could go stale: the
scheduler's sweep may start a session meanwhile. The transaction that
moves the project now re-reads its open sessions (SQL only) and refuses
ProjectBusy, writing nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's node never inherits KANBOT_HUB_FINGERPRINT
Some checks failed
CI / check (push) Has been cancelled
2521ef0814
It overrides node.json's pin, so nodeEnvironment drops it with the hub URL
and token variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The window hands only web and mail links to the system
Some checks failed
CI / check (push) Has been cancelled
51aa30e897
setWindowOpenHandler and will-navigate passed any scheme to
shell.openExternal; in connect mode the page is the hub's. They now go
through mayOpenExternally (electron/navigation.js): http, https, mailto,
and VS Code's file links in local mode only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The window's pin matches an IPv6 hub's host name
Some checks failed
CI / check (push) Has been cancelled
12f04da7ef
Chromium hands setCertificateVerifyProc the bare address (fd00::1) while
new URL(pin.url).hostname keeps the brackets, so an IPv6 hub's pinned
certificate was left to Chromium and refused. Both sides are compared
without brackets, case-insensitively.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Connect to a hub" looks disabled when it is
Some checks failed
CI / check (push) Has been cancelled
67604691c2
Outside the desktop app the Machines card's button was disabled but kept
the primary fill (Nuxt UI only dims it to 75%); it now turns neutral and
subtle. In the setup step a disabled choice's card is dimmed too, and
from a paired device the share / alone choices are disabled (the server
refuses the hub section to a device since the previous commits).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Connect mode leaves out "Open in browser"; CLAUDE.md says who trusts whom
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
60d4d12b10
In connect mode the system browser would reach a self-signed hub with no
pin, so the tray and the Help menu offer it in local mode only. CLAUDE.md
records the trust model: a connected hub runs code on the desktop, pairing
is trust on first use (the fingerprint on the hub itself is the only check
against a relay), all devices are equally trusted, a moved project sends
its task text to the node, and a LAN neighbour can burn the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KANBOT_HUB_ENABLED / KANBOT_HUB_PORT force the hub settings section (the
environment wins, Settings.update refuses it with SettingFromEnvironment,
Hub.status says setByEnvironment and the card shows it locked).
KANBOT_LOCAL_AGENTS=0 gives this machine a capacity of 0 (Machine.agentsOff).
kanbot-hub offer/status/health/backup talks to the server over loopback,
bundled on its own to .output/hub-cli (npm run build:hub-cli), sharing
desktop-prepare's externals vendoring (scripts/bundleExternals.mjs). The
log says how to get a first code while hub mode listens with no browser paired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headless hub image: Dockerfile, compose example, docs
Some checks failed
CI / check (push) Has been cancelled
3c4391d718
Multi-stage build on node:24.19.0-bookworm (pinned by digest), runtime on
bookworm-slim with git, ca-certificates and tini, as uid 1000, data in the
/data volume, hub mode forced on 7433 (the one EXPOSE), the plain listener
on the container's loopback, no local agents, kanbot-hub on PATH and as the
HEALTHCHECK. docs/headless-hub.md covers building, pairing browsers, nodes
and desktops, backups (kanbot-hub backup), upgrades, limits and security.
test/hub/dockerImage.test.ts keeps the files consistent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: Headless hub
Some checks failed
CI / check (push) Has been cancelled
75a3072b3f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KANBOT_HUB_PUBLIC_URL: where a containerised hub is reached
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
2387d3e94f
Inside a container the hub only sees its Docker address (172.17.0.x), which
it gave out in Hub.status, the pairing link, the first-pairing hint and
kanbot-hub offer. The variable (https origins, comma-separated) is given out
instead; the compose example reads it from .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge pull request 'Phase 12: a headless hub in Docker' (#20) from refactor/phase-12-headless into refactor/phase-11-discovery
Some checks failed
CI / check (push) Failing after 2m18s
CI / check (pull_request) Failing after 2m18s
5079b2df8a
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/20
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/19
Merge pull request 'Phase 10: nodes connect to the hub' (#18) from refactor/phase-10-nodes into refactor/phase-9-auth
Some checks failed
CI / check (push) Failing after 2m3s
CI / check (pull_request) Failing after 2m10s
a76074d757
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/18
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/17
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/16
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/15
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/14
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/13
alex merged commit 08052e37d2 into main 2026-10-29 18:47:09 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Overlight/App!12
No description provided.