Compare commits

...

15 Commits

Author SHA1 Message Date
a8bfde8f8d feat: relicense AGPL-3.0 → MIT (v2.7.0)
Clear the 3 Unsloth-Studio-derived AGPL files and flip LICENSE + 5
package.json from AGPL-3.0-only to MIT.

- html-to-md.ts → MIT node-html-markdown (parse5 dropped)
- llama-args-validator.ts → clean-room (flag denylist = facts)
- tool-call-parser.ts → delete dead Unsloth-ported code; keep
  extractToolCallBlocks/stripToolMarkup byte-identical (no behavior change)
- LICENSE → MIT (Copyright (c) 2026 indifferentketchup); 5 package.json → MIT;
  AGPL SPDX headers removed; README License section; license-mit guard test
- roadmap License-debt batch marked shipped; openspec/changes/license-debt-mit

Decouples the relicense from the native-parsing retirement (the ported parser
was dead code). Server suite 519 passing; build + coder typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:16:03 +00:00
9c1ddcaa7c Merge v2611-followups: v2.6.11 apps/server close-hook caller + DiffPanel staging hint 2026-06-01 02:35:21 +00:00
217f487395 docs(changelog): v2.6.11-close-hooks-staging (closes the v2.6 openspec)
CHANGELOG + roadmap (through v2.6.11) + openspec v2-6 Phase 3 fully closed (3.7 + apps/server close-hook caller done).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 02:35:21 +00:00
2dfbef4c41 feat: v2.6 follow-ups — apps/server close-hook caller + DiffPanel staging hint (3.7)
apps/server fire-and-forgets BooCoder's Phase-3 close hooks (new coder-notify.ts, reuses BOOCODER_URL, never-rejects) on session-delete + chat archive/archive-all/delete, so warm backends + worktrees tear down immediately (idle-evict/reaper was the backstop). 3.7: BooCoder DiffPanel shows a muted one-liner when the selected provider can't see another agent's unapplied worktree edits (pure derivation from per-change agent + current provider, no new state). 6 new server tests (coder-notify); 537 server tests pass; web+server tsc/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 02:35:11 +00:00
c7a8128059 Merge phase3-lifecycle: v2.6.10 lifecycle hardening (completes v2.6 persistent agent sessions) 2026-06-01 01:10:16 +00:00
986c8a83a9 docs(changelog): v2.6.10-lifecycle-hardening (completes v2.6)
CHANGELOG + roadmap (through v2.6.10; v2.6 marked complete) + openspec v2-6 Phase 3 checked off (3.1-3.6; 3.7 frontend + apps/server caller as follow-ups).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:10:16 +00:00
aa3797e356 feat(coder): v2.6 Phase 3 — lifecycle hardening (idle evict, crash recovery, worktree reaper)
Idle TTL eviction per (chat,agent) + LRU cap (never a busy backend); pure lifecycle-decisions.ts (TDD). Crash recovery lifts openchamber's health-monitor + busy-aware-restart + stale-grace state machine into opencode-server.ts (+ port reclaim) and warm-acp.ts; opencode crash -> fresh sessions, ACP -> re-session/new. F.1 turn-guard + U.6 usage preserved (their tests pass). Orphan worktree reaper (1h grace, superset-style dirty/unpushed preflight, Paseo soft-delete) + close hooks + diff re-baseline after apply_pending. 35 new tests + DB-opt-in reconnect test; 215 coder tests pass; tsc + build clean. Completes v2.6. Follow-ups out of scope: apps/server close-hook caller, 3.7 DiffPanel staging hint, live smokes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:10:09 +00:00
850d48853f Merge phase2-warm-acp: v2.6.9 warm ACP backend for goose/qwen 2026-05-31 23:57:14 +00:00
f619ae0978 docs(changelog): v2.6.9-warm-acp
CHANGELOG + roadmap (through v2.6.9) + openspec v2-6 Phase 2 checked off (2.1-2.4; Smoke 2/2b pending live).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:57:09 +00:00
0d3d08f5f2 feat(coder): v2.6 Phase 2 — warm ACP backend for goose/qwen
WarmAcpBackend (AgentBackend) holds one persistent goose acp / qwen --acp child + ClientSideConnection + ACP session per (chat,agent); initialize+session/new once, reused across turns. Abort = session/cancel the prompt only (never kills the child); child exit -> agent_sessions.status='crashed' -> re-spawn next turn. Dispatcher routes goose/qwen chat-tab tasks to the pooled warm backend via pure shouldUseWarmBackend (needs session_id+chat_id); one-shot runExternalAgent kept as fallback for arena/MCP/new_task. handleSessionUpdate extracted to a shared pure acp-event-map.ts (one-shot path byte-identical). SDK: installed @agentclientprotocol/sdk@^0.22.1 has stable resumeSession/loadSession; resume moot in the warm hot path, deferred to Phase 3. 15 new tests (warm-acp-routing, acp-event-map); 180 coder tests pass; tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:57:03 +00:00
0658d19b64 Merge phase1-ux: v2.6.8 agent attribution (DiffPanel badges + composer chip + agent-sessions route + opencode usage) 2026-05-31 22:07:39 +00:00
631af5dd4c docs(changelog): v2.6.8-agent-attribution
CHANGELOG + roadmap shipped record (through v2.6.8) + openspec v2-6 Phase 1-UX checked off (U.1-U.6; Smoke U pending the frontend Docker rebuild).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:07:32 +00:00
5db6551361 feat(web): Phase 1-UX frontend — DiffPanel agent badges + resumed/new-session chip
DiffPanel renders a per-row agent badge (icon+label; null -> 'manual') + a 'Changes from X, Y' note when the pending set spans >1 agent. AgentComposerBar gains an optional sessionId prop -> resumed/history/new-session chip beside the Provider picker (gated, so BooChat callers are unchanged), driven by a new useAgentSessions hook (refetch on message-complete). providerIcon extracted to shared components/coder/providerIcons.tsx; api.coder gains agentSessions(sessionId); PendingChange type gains agent. web tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:07:26 +00:00
c060778258 feat(coder): Phase 1-UX backend — agent attribution + agent-sessions route + opencode usage
pending_changes.agent stamped at every queue site (native -> 'boocode', dispatched external -> task.agent, manual RightRail -> NULL) + flows through listPending. New GET /api/sessions/:id/agent-sessions -> [{agent,status,has_session,last_active_at}] per (chat,agent). opencode warm server consumes session.next.step.ended, accumulating input_tokens/output_tokens/cost onto agent_sessions (new idempotent columns) via a pure opencode-usage.ts mapper. Tests: agent-sessions.routes (3) + opencode-usage (6); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:07:14 +00:00
48c1d70baf Merge f1-interrupt-guard: F.1 opencode post-interrupt stale-terminal guard + doc reconciliation (v2.6.7) 2026-05-31 21:32:25 +00:00
54 changed files with 4095 additions and 1715 deletions

View File

@@ -2,6 +2,26 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.7.0-mit — 2026-06-01
Relicenses BooCode from AGPL-3.0 back to MIT by clearing the three Unsloth-Studio-derived files the `v2.4.0`/`v2.4.1` lifts pulled in — the root `LICENSE` and all five `package.json` had been `AGPL-3.0-only`, making the network-served work AGPL §13-encumbered. The enabling finding decoupled the relicense from the long-planned native-llama-server-parsing retirement: `tool-call-parser.ts`'s Unsloth-ported algorithm (`parseToolCallsFromText`/`scanBalancedBraces` + unused nudge constants) was **dead code** with no production import, so it was simply deleted while the load-bearing `extractToolCallBlocks`/`stripToolMarkup` (BooCode-authored streaming helpers) were kept byte-identical — no behavior change to the live tool-call path. `html-to-md.ts` was swapped to the MIT `node-html-markdown` library (`parse5` dropped; the only behavior delta is column-aligned tables, GFM hard-break `<br>`, and `<ol start>` renumbering, all feeding the LLM via `web_fetch`), and `llama-args-validator.ts` was clean-room rewritten with the managed-flag denylist re-derived from the public llama-server flag list (facts, not copyrightable). The license flip set `LICENSE` to MIT (`Copyright (c) 2026 indifferentketchup`), the five `package.json` to `MIT`, removed every AGPL SPDX header, added a README License section, and added a `license-mit` guard test that fails if AGPL provenance returns. Built by three parallel agents over the disjoint files; full server suite 519 passing (incl. 9 new guard tests), server build + coder typecheck clean. Resolves `boocode_code_review_v2.md` §1 #1 / §5k and the roadmap's `License-debt` batch (openspec `license-debt-mit`); supersedes that batch's original staged plan, which had entangled the flip with a live qwen3.6 validation window.
## v2.6.11-close-hooks-staging — 2026-06-01
The two v2.6 follow-ups left after `v2.6.10-lifecycle-hardening`. **Server close-hook caller:** `apps/server` (BooChat) now fire-and-forgets BooCoder's Phase-3 close hooks so warm agent backends + worktrees tear down *immediately* on delete/archive instead of waiting for the idle-evict/reaper backstop — a new `coder-notify.ts` `notifyCoderClose(kind,id)` (reusing the v2.6.2 `BOOCODER_URL` reach, never-rejects) is `void`-called after the WS frame at session-delete (`POST /api/sessions/:id/close`) and chat archive / archive-all / delete (`POST /api/chats/:id/close`); an unreachable coder can never block or fail the user's delete/archive. **Staging-boundary hint (task 3.7):** the BooCoder DiffPanel now shows a muted one-liner when the selected provider can't see another agent's unapplied worktree edits — native boocode selected + external-agent-staged changes (or vice-versa) → "<agent>'s edits live in its worktree — BooCode won't see them until applied" — derived purely from the per-change `agent` + current provider, no new state. 6 new server tests (`coder-notify`), 537 server tests pass; web + server tsc/build clean. **With these the v2.6 openspec is fully closed** — only the live Smoke 2/2b/3 remain (manual exercise).
## v2.6.10-lifecycle-hardening — 2026-06-01
v2.6 Phase 3 (the last phase) — lifecycle hardening of the warm-process backends. **Idle eviction + LRU cap:** the agent pool runs a 60s sweep that evicts backends/sessions idle past `AGENT_POOL_IDLE_TTL_MS` (30 min default) and any beyond `AGENT_POOL_MAX_LIVE` (10, LRU) — **never a busy one** (in-flight turn, double-checked via a new `isBusy()` backend hook); the worktree persists (DB-backed) and the next turn re-spawns + reattaches. The eviction/LRU/restart decisions are factored into a pure `lifecycle-decisions.ts` (modeled on the inference `selectPruneTargets` pattern). **Crash recovery:** lifts openchamber's health-monitor + busy-aware-restart + consecutive-failure + stale-busy-grace state machine into `opencode-server.ts` (with port reclaim) and `warm-acp.ts` — an opencode server crash settles in-flight turns as failed, marks the rows `crashed`, and recreates fresh sessions (a fresh server can't hold the old in-memory id), while a warm-ACP child crash re-`session/new`s next turn; the F.1 turn-guard and U.6 usage are preserved (their tests still pass). **Worktree reaper:** a periodic reaper removes orphan on-disk worktrees (no live `worktrees` row, 1h grace) behind a superset-style preflight that skips dirty/unpushed/unmerged work, with Paseo-style soft-delete (`status='archived'`). Plus close hooks (`/api/chats/:id/close`, `/api/sessions/:id/close`, awaiting the apps/server caller) and diff re-baseline after `apply_pending`. Built test-first — 35 new tests (`lifecycle-decisions` 22, `agent-pool` 13) + a DB-opt-in reconnect integration test; 215 coder tests pass, tsc + build clean. **This completes v2.6** (Phase 03 + F.1 + Phase 1-UX). Remaining follow-ups (out of v2.6 scope): the apps/server close-hook caller, the 3.7 DiffPanel staging-boundary hint (frontend), and live Smoke 2/2b/3.
## v2.6.9-warm-acp — 2026-05-31
v2.6 Phase 2: goose and qwen now run as **warm ACP backends** instead of one-shot-per-task. A new `WarmAcpBackend` (`backends/warm-acp.ts`, implementing the same `AgentBackend` interface as the opencode warm server) holds one persistent `goose acp` / `qwen --acp` child + `ClientSideConnection` + ACP session per `(chat, agent)`, running `initialize` + `session/new` once and reusing the connection across turns; per-turn abort cancels the in-flight prompt (`session/cancel`) without killing the child, and a child exit marks `agent_sessions.status='crashed'` for re-spawn on the next turn. The dispatcher routes `goose`/`qwen` chat-tab tasks to the pooled warm backend via a pure `shouldUseWarmBackend(task)` predicate (warm only when both `session_id` and `chat_id` are set), keeping the one-shot `runExternalAgent` path as the fallback for session-less creators (arena, MCP, `new_task`); broker frames + `persistExternalAgentTurn` + the latest-wins `pending_changes` diff are identical to the opencode path. The `acp-dispatch.ts` `handleSessionUpdate` switch was extracted into a pure shared `acp-event-map.ts` mapper used by both the one-shot and warm paths (one-shot behavior byte-identical, all existing acp tests green). The design's `unstable_resumeSession` concern is resolved — the installed `@agentclientprotocol/sdk@^0.22.1` exposes stable `resumeSession`/`loadSession`, but resume is moot in the hot path (warm reuse needs none); cross-restart resume + idle eviction are deferred to Phase 3. Built test-first (15 new tests: `warm-acp-routing`, `acp-event-map`); 180 coder tests pass, tsc + build clean. **Smoke 2/2b (live two-message warm reuse + the opencode→boocode→opencode switch round-trip) to be run post-deploy.** Phase 3 (lifecycle hardening) is the last v2.6 phase.
## v2.6.8-agent-attribution — 2026-05-31
v2.6 Phase 1-UX: agent attribution + switch affordances over the already-shipped `pending_changes.agent` column and `agent_sessions` table (read+display, no new backend capability). **Backend:** `pending_changes.agent` is now stamped at every queue site (native write tools → `'boocode'`, dispatched external agents → the task's agent, manual RightRail create → `NULL`) and flows through `listPending`; a new `GET /api/sessions/:id/agent-sessions` route returns `[{agent,status,has_session,last_active_at}]` per `(chat,agent)` for the session's chats; and the opencode warm-server backend consumes opencode's `session.next.step.ended` events, accumulating `input_tokens`/`output_tokens`/`cost` onto the `agent_sessions` row (new columns, idempotent). **Frontend:** the BooCoder DiffPanel renders a per-row agent badge (provider icon + label; `null` → "manual") with a "Changes from X, Y" note when a pending set spans multiple agents, and the AgentComposerBar shows a resumed / history / new-session chip beside the Provider picker — gated on an optional `sessionId` prop so BooChat is unaffected — driven by a new `useAgentSessions` hook that refetches on message-complete; `providerIcon` was extracted to a shared `components/coder/providerIcons.tsx`. Built by three parallel subagents over disjoint file sets; web + coder typecheck clean, 165 coder tests pass (9 new across `opencode-usage` and `agent-sessions.routes`). U.6's persisted token totals are conversation-cumulative and not yet surfaced in the UI (deferred). Implements the U.1U.6 "remaining" plan from the v2.6 openspec reconciliation; Phase 2 (warm ACP goose/qwen) + Phase 3 (lifecycle hardening) remain.
## v2.6.7-interrupt-guard — 2026-05-31 ## v2.6.7-interrupt-guard — 2026-05-31
Fixes a post-interrupt correctness bug in the `v2.6.1-phase1-opencode` warm-server backend, made one-click reachable by `v2.6.5-panes-tabs-composer`'s Send→Stop composer. `opencode-server.ts` settled an in-flight turn on opencode's `session.idle`/`session.error` by calling `activeTurn.settle()` on whatever turn currently held the session slot — but opencode emits one trailing terminal event for a *cancelled* turn after `client.session.abort()`, and those events carry only a `sessionID` (no turn id). So after the user hit Stop and immediately sent another message, the aborted turn's orphan `session.idle` settled the *new* turn early as success (Paseo hit and fixed the same class in `1d38aac`). The fix adds a small pure guard (`turn-guard.ts`: `armAbortGuard`/`noteTurnActivity`/`consumeTerminal` over a per-session `swallowNextTerminal` flag): abort arms it, the next terminal is swallowed once, and a new turn's first delta self-heals the flag so a never-arriving orphan can't strand a real turn. Implemented test-first — three regression tests in `turn-guard.test.ts` (swallow-the-orphan, settle-when-no-abort, self-heal); full coder suite green (156 passed). This is the F.1 "fix-next" item from the v2.6 openspec reconciliation; Phase 1-UX / Phase 2 / Phase 3 remain. Fixes a post-interrupt correctness bug in the `v2.6.1-phase1-opencode` warm-server backend, made one-click reachable by `v2.6.5-panes-tabs-composer`'s Send→Stop composer. `opencode-server.ts` settled an in-flight turn on opencode's `session.idle`/`session.error` by calling `activeTurn.settle()` on whatever turn currently held the session slot — but opencode emits one trailing terminal event for a *cancelled* turn after `client.session.abort()`, and those events carry only a `sessionID` (no turn id). So after the user hit Stop and immediately sent another message, the aborted turn's orphan `session.idle` settled the *new* turn early as success (Paseo hit and fixed the same class in `1d38aac`). The fix adds a small pure guard (`turn-guard.ts`: `armAbortGuard`/`noteTurnActivity`/`consumeTerminal` over a per-session `swallowNextTerminal` flag): abort arms it, the next terminal is swallowed once, and a new turn's first delta self-heals the flag so a never-arriving orphan can't strand a real turn. Implemented test-first — three regression tests in `turn-guard.test.ts` (swallow-the-orphan, settle-when-no-abort, self-heal); full coder suite green (156 passed). This is the F.1 "fix-next" item from the v2.6 openspec reconciliation; Phase 1-UX / Phase 2 / Phase 3 remain.

682
LICENSE
View File

@@ -1,661 +1,21 @@
GNU AFFERO GENERAL PUBLIC LICENSE MIT License
Version 3, 19 November 2007
Copyright (c) 2026 indifferentketchup
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Permission is hereby granted, free of charge, to any person obtaining a copy
of this license document, but changing it is not allowed. of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
Preamble to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
The GNU Affero General Public License is a free, copyleft license for furnished to do so, subject to the following conditions:
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
our General Public Licenses are intended to guarantee your freedom to IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
share and change all versions of a program--to make sure it remains free FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
software for all its users. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
When we speak of free software, we are referring to freedom, not OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
price. Our General Public Licenses are designed to make sure that you SOFTWARE.
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -84,3 +84,7 @@ See [`boocode_roadmap.md`](boocode_roadmap.md) for full version history. Highlig
## Planned ## Planned
- **v2.3 provider lifecycle** — config-backed provider registry (`/data/coder-providers.json`), enable/disable toggles, two-tier probe (openspec drafted). See [`CURRENT.md`](CURRENT.md). - **v2.3 provider lifecycle** — config-backed provider registry (`/data/coder-providers.json`), enable/disable toggles, two-tier probe (openspec drafted). See [`CURRENT.md`](CURRENT.md).
## License
MIT — see [`LICENSE`](LICENSE).

View File

@@ -24,5 +24,5 @@
"tsx": "^4.16.2", "tsx": "^4.16.2",
"typescript": "^5.5.0" "typescript": "^5.5.0"
}, },
"license": "AGPL-3.0-only" "license": "MIT"
} }

View File

@@ -31,5 +31,5 @@
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"license": "AGPL-3.0-only" "license": "MIT"
} }

View File

@@ -35,6 +35,21 @@ const ConfigSchema = z.object({
// SSH access to the host for external agent dispatch (Phase 5) // SSH access to the host for external agent dispatch (Phase 5)
BOOCODER_SSH_HOST: z.string().default('100.114.205.53'), BOOCODER_SSH_HOST: z.string().default('100.114.205.53'),
BOOCODER_SSH_USER: z.string().default('samkintop'), BOOCODER_SSH_USER: z.string().default('samkintop'),
// v2.6 Phase 3 (lifecycle hardening). Idle TTL: evict a non-busy warm backend
// (opencode server / warm-ACP child) after this long with no turn — its worktree
// + agent_sessions row persist, so the next turn re-spawns + reattaches. 30 min
// default (design §6).
AGENT_POOL_IDLE_TTL_MS: z.coerce.number().int().positive().default(1_800_000),
// LRU cap: max live warm backends before the least-recently-used (non-busy) ones
// are evicted. Bounds the long-lived-daemon's per-(chat,agent) Map growth.
AGENT_POOL_MAX_LIVE: z.coerce.number().int().positive().default(10),
// Periodic sweep cadence (idle/LRU pool eviction + orphan-worktree reap). 60s
// mirrors the apps/server truncation/stale-streaming sweeper.
LIFECYCLE_SWEEP_INTERVAL_MS: z.coerce.number().int().positive().default(60_000),
// Orphan-worktree grace: an on-disk worktree dir with no live `worktrees` row is
// only reaped after it's been untouched this long (avoids sweeping a dir mid
// ensureSessionWorktree create). 1h default.
ORPHAN_WORKTREE_GRACE_MS: z.coerce.number().int().positive().default(3_600_000),
}); });
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -25,16 +25,19 @@ import { setInferenceContext, clearInferenceContext } from './services/tools/inf
import { registerMessageRoutes } from './routes/messages.js'; import { registerMessageRoutes } from './routes/messages.js';
import { registerSkillRoutes } from './routes/skills.js'; import { registerSkillRoutes } from './routes/skills.js';
import { registerPendingRoutes } from './routes/pending.js'; import { registerPendingRoutes } from './routes/pending.js';
import { registerAgentSessionRoutes } from './routes/agent-sessions.js';
import { registerTaskRoutes } from './routes/tasks.js'; import { registerTaskRoutes } from './routes/tasks.js';
import { registerInboxRoutes } from './routes/inbox.js'; import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js'; import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js'; import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js'; import { registerProviderRoutes } from './routes/providers.js';
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js'; import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
import { registerLifecycleRoutes } from './routes/lifecycle.js';
import { registerWebSocket } from './routes/ws.js'; import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe // Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js'; import { createDispatcher } from './services/dispatcher.js';
import { agentPool } from './services/agent-pool.js'; import { agentPool } from './services/agent-pool.js';
import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js';
import { probeAgents } from './services/agent-probe.js'; import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js'; import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js'; import { setPermissionHooks } from './services/permission-waiter.js';
@@ -180,10 +183,30 @@ async function main() {
// Phase 4: dispatcher — polls tasks table and runs inference // Phase 4: dispatcher — polls tasks table and runs inference
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config }); const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
dispatcher.start(); dispatcher.start();
// v2.6 Phase 3: configure + start the agent-pool lifecycle sweep (idle-TTL +
// LRU-cap eviction of warm backends, plus each backend's proactive health probe)
// and the orphan-worktree reaper. Both run on the same periodic timer.
agentPool.configure({
idleTtlMs: config.AGENT_POOL_IDLE_TTL_MS,
maxLive: config.AGENT_POOL_MAX_LIVE,
sweepIntervalMs: config.LIFECYCLE_SWEEP_INTERVAL_MS,
log: app.log,
});
agentPool.startReaper(app.log);
const orphanReaper = createOrphanWorktreeReaper({
sql,
log: app.log,
intervalMs: config.LIFECYCLE_SWEEP_INTERVAL_MS,
graceMs: config.ORPHAN_WORKTREE_GRACE_MS,
});
orphanReaper.start();
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
// stop() first so in-flight dispatcher turns settle, then drain the pool. // stop() first so in-flight dispatcher turns settle, then stop the reapers and
// Pool is empty in Phase 0 (nothing spawns yet) — dispose() is inert. // drain the pool (kills opencode server + warm ACP children).
await dispatcher.stop(); await dispatcher.stop();
orphanReaper.stop();
await agentPool.dispose(); await agentPool.dispose();
}); });
@@ -191,12 +214,14 @@ async function main() {
registerMessageRoutes(app, sql, broker, inferenceApi); registerMessageRoutes(app, sql, broker, inferenceApi);
registerSkillRoutes(app, sql, broker, inferenceApi); registerSkillRoutes(app, sql, broker, inferenceApi);
registerPendingRoutes(app, sql); registerPendingRoutes(app, sql);
registerAgentSessionRoutes(app, sql);
registerTaskRoutes(app, sql, inferenceApi); registerTaskRoutes(app, sql, inferenceApi);
registerInboxRoutes(app, sql); registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql); registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql); registerArenaRoutes(app, sql);
registerProviderRoutes(app, sql, config); registerProviderRoutes(app, sql, config);
registerWorktreeSafetyRoutes(app, sql); registerWorktreeSafetyRoutes(app, sql);
registerLifecycleRoutes(app, sql);
registerWebSocket(app, sql, broker); registerWebSocket(app, sql, broker);
// Serve static frontend (built web app). In production, the dist/ is // Serve static frontend (built web app). In production, the dist/ is

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { registerAgentSessionRoutes } from '../agent-sessions.js';
import type { Sql } from '../../db.js';
// Mock the porsager surface this route uses: a tagged-template `sql` dispatched by
// query substring. Two queries: the session-existence check and the agent_sessions
// JOIN. We return post-coercion shapes (booleans/strings) exactly as porsager would
// hand them to the route — `has_session` already a JS boolean, `last_active_at` a
// string|null — so the asserted JSON matches the API contract end-to-end.
interface MockState {
sessionExists: boolean;
rows: Array<{ agent: string; status: string; has_session: boolean; last_active_at: string | null }>;
}
function mockSql(state: MockState): Sql {
return ((strings: TemplateStringsArray) => {
const q = strings.join('');
if (q.includes('SELECT id FROM sessions')) {
return Promise.resolve(state.sessionExists ? [{ id: 'session-1' }] : []);
}
if (q.includes('FROM agent_sessions')) {
return Promise.resolve(state.rows);
}
return Promise.resolve([]);
}) as unknown as Sql;
}
function buildApp(state: MockState): FastifyInstance {
const app = Fastify();
registerAgentSessionRoutes(app, mockSql(state));
return app;
}
describe('GET /api/sessions/:id/agent-sessions', () => {
it('returns the per-(chat,agent) rows in the contracted shape', async () => {
const app = buildApp({
sessionExists: true,
rows: [
{ agent: 'opencode', status: 'active', has_session: true, last_active_at: '2026-05-31T12:00:00.000Z' },
{ agent: 'goose', status: 'idle', has_session: false, last_active_at: null },
],
});
const res = await app.inject({ method: 'GET', url: '/api/sessions/session-1/agent-sessions' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toEqual([
{ agent: 'opencode', status: 'active', has_session: true, last_active_at: '2026-05-31T12:00:00.000Z' },
{ agent: 'goose', status: 'idle', has_session: false, last_active_at: null },
]);
// Contract field types.
expect(typeof body[0].agent).toBe('string');
expect(typeof body[0].status).toBe('string');
expect(typeof body[0].has_session).toBe('boolean');
expect(body[1].last_active_at).toBeNull();
await app.close();
});
it('returns an empty array when the session has no agent_sessions rows', async () => {
const app = buildApp({ sessionExists: true, rows: [] });
const res = await app.inject({ method: 'GET', url: '/api/sessions/session-1/agent-sessions' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([]);
await app.close();
});
it('404s when the session does not exist', async () => {
const app = buildApp({ sessionExists: false, rows: [] });
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/agent-sessions' });
expect(res.statusCode).toBe(404);
expect(res.json()).toEqual({ error: 'session not found' });
await app.close();
});
});

View File

@@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
// v2.6 Phase 1-UX (design §9b): chat-scoped "resumed vs new session" indicator.
// `agent_sessions` is keyed (chat_id, agent) — the tab/chat is the agent-context
// unit (P1.5-b). The route param is a SESSION id, so we resolve every chat in the
// session and return the union of their agent_sessions rows. A session with two
// opencode tabs yields two rows (one per chat); the frontend keys the chip per
// chat, but the wire shape is a flat per-(chat,agent) list.
//
// has_session = agent_session_id IS NOT NULL — i.e. a native backend session id
// (opencode/ACP) was created and stored, so switching back resumes rather than
// starts fresh.
export interface AgentSessionRow {
agent: string;
status: string;
has_session: boolean;
last_active_at: string | null;
}
export function registerAgentSessionRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/sessions/:sessionId/agent-sessions — list the agent-session rows for
// every chat in the session (drives the AgentComposerBar resumed/new chip).
app.get<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/agent-sessions',
async (req, reply) => {
const sessionId = req.params.sessionId;
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
// Join through chats so the session-scoped param resolves to its (chat,agent)
// rows. last_active_at first → the frontend reads the freshest activity.
const rows = await sql<AgentSessionRow[]>`
SELECT
a.agent AS agent,
a.status AS status,
(a.agent_session_id IS NOT NULL) AS has_session,
a.last_active_at AS last_active_at
FROM agent_sessions a
JOIN chats c ON c.id = a.chat_id
WHERE c.session_id = ${sessionId}
ORDER BY a.last_active_at DESC NULLS LAST, a.agent ASC
`;
return rows;
},
);
}

View File

@@ -0,0 +1,122 @@
/**
* v2.6 Phase 3 (3.3) — chat/session close-or-archive cleanup hook (coder side).
*
* Chat/session close + archive + delete all live in apps/server (Docker), which
* cannot see the host worktree dirs (/tmp/booworktrees), run git on them, or reach
* the warm agent processes the dispatcher pooled in THIS (host systemd) process. So
* — exactly like the `worktree-risk` guard — the server signals the coder when a
* chat/session closes, and the coder does the real teardown:
* 1. dispose the chat's warm-ACP backends (`agentPool.closeChat`) — kills the
* goose/qwen child processes for that chat,
* 2. close the chat's opencode session on the shared server (`closeSession`),
* 3. mark every `agent_sessions` row for the chat 'closed' + (when the session's
* last open chat closes) remove the shared session worktree, preflighting
* work-at-risk so uncommitted/unmerged work is never silently dropped
* (`closeChatBackendState`).
*
* Idempotent: closing an already-closed chat is a no-op (0 rows, no backend).
*
* SERVER WIRING (not done here — apps/server, out of this batch's scope): the
* server's `POST /api/chats/:id/archive`, `DELETE /api/chats/:id`, and the
* session archive/delete routes should fire-and-forget
* fetch(`${BOOCODER_URL}/api/chats/${id}/close`, { method: 'POST' })
* after publishing their WS frame (best-effort; the orphan-worktree reaper +
* idle-pool eviction are the backstop if the call is missed).
*/
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import { agentPool, OPENCODE_POOL_KEY } from '../services/agent-pool.js';
import { closeChatBackendState } from '../services/worktrees.js';
import type { AgentSessionHandle } from '../services/agent-backend.js';
export function registerLifecycleRoutes(app: FastifyInstance, sql: Sql): void {
// POST /api/chats/:chatId/close — tear down all warm state for a chat tab.
app.post<{ Params: { chatId: string }; Querystring: { force?: string } }>(
'/api/chats/:chatId/close',
async (req) => {
const chatId = req.params.chatId;
const force = req.query.force === 'true' || req.query.force === '1';
// 1. Close the chat's opencode session on the SHARED server (the server is
// not chat-keyed, so agentPool.closeChat won't touch it). Resolve the
// stored opencode session id and ask the backend to drop it.
const ocRows = await sql<{ agent: string; agent_session_id: string | null; worktree_id: string | null; session_id: string | null }[]>`
SELECT agent, agent_session_id, worktree_id, session_id
FROM agent_sessions
WHERE chat_id = ${chatId} AND backend = 'opencode_server'
`;
const ocBackend = agentPool.peek(OPENCODE_POOL_KEY, 'opencode');
if (ocBackend) {
for (const row of ocRows) {
if (!row.agent_session_id) continue;
const handle: AgentSessionHandle = {
sessionId: row.session_id ?? '',
agent: row.agent,
backend: 'opencode_server',
chatId,
worktreeId: row.worktree_id ?? '',
agentSessionId: row.agent_session_id,
serverPort: null,
};
await ocBackend.closeSession(handle).catch((err) => {
app.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'lifecycle: opencode closeSession threw');
});
}
}
// 2. Dispose any warm-ACP backends pooled under this chat (kills the
// goose/qwen child + marks its agent row closed via the backend).
const disposed = await agentPool.closeChat(chatId);
// 3. DB + worktree truth: mark agent rows closed; remove the shared session
// worktree iff this was the session's last open chat (preflight at-risk).
const result = await closeChatBackendState(sql, chatId, { force });
app.log.info({ chatId, disposed, ...result }, 'lifecycle: chat closed');
return { ok: true, disposed, ...result };
},
);
// POST /api/sessions/:sessionId/close — close every open chat in a session
// (session archive/delete). Loops the chat-close path so the same preflight +
// teardown applies per chat; the worktree is removed on the last one.
app.post<{ Params: { sessionId: string }; Querystring: { force?: string } }>(
'/api/sessions/:sessionId/close',
async (req) => {
const sessionId = req.params.sessionId;
const force = req.query.force === 'true' || req.query.force === '1';
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId}
`;
const results: { chatId: string; disposed: string[]; worktreeRemoved: boolean; worktreeAtRisk: boolean }[] = [];
for (const c of chats) {
const ocBackend = agentPool.peek(OPENCODE_POOL_KEY, 'opencode');
if (ocBackend) {
const ocRows = await sql<{ agent: string; agent_session_id: string | null; worktree_id: string | null; session_id: string | null }[]>`
SELECT agent, agent_session_id, worktree_id, session_id
FROM agent_sessions WHERE chat_id = ${c.id} AND backend = 'opencode_server'
`;
for (const row of ocRows) {
if (!row.agent_session_id) continue;
await ocBackend.closeSession({
sessionId: row.session_id ?? '',
agent: row.agent,
backend: 'opencode_server',
chatId: c.id,
worktreeId: row.worktree_id ?? '',
agentSessionId: row.agent_session_id,
serverPort: null,
}).catch(() => {});
}
}
const disposed = await agentPool.closeChat(c.id);
const r = await closeChatBackendState(sql, c.id, { force });
results.push({ chatId: c.id, disposed, worktreeRemoved: r.worktreeRemoved, worktreeAtRisk: r.worktreeAtRisk });
}
app.log.info({ sessionId, chats: results.length }, 'lifecycle: session closed');
return { ok: true, results };
},
);
}

View File

@@ -10,6 +10,7 @@ import {
queueCreate, queueCreate,
} from '../services/pending_changes.js'; } from '../services/pending_changes.js';
import { WriteGuardError } from '../services/write_guard.js'; import { WriteGuardError } from '../services/write_guard.js';
import { rebaselineWorktreeAfterApply } from '../services/worktrees.js';
const CreateBody = z.object({ const CreateBody = z.object({
file_path: z.string().min(1), file_path: z.string().min(1),
@@ -90,6 +91,8 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
parsed.data.file_path, parsed.data.file_path,
parsed.data.content, parsed.data.content,
projectRoot, projectRoot,
// Manual RightRail create — no agent staged it; renders as "manual".
null,
); );
return change; return change;
} catch (err) { } catch (err) {
@@ -115,6 +118,15 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
} }
const results = await applyAll(sql, sessionId, projectRoot); const results = await applyAll(sql, sessionId, projectRoot);
// v2.6 Phase 3 (3.5): re-baseline the session worktree's diff to the applied
// state, so the next external-agent turn diffs against applied-not-original
// and doesn't re-surface the just-applied changes. Best-effort: a worktree
// session may not exist (native-only chat), and a re-baseline hiccup must not
// fail the apply the user just requested.
if (results.some((r) => r.success)) {
await rebaselineWorktreeAfterApply(sql, sessionId).catch(() => {});
}
return { results }; return { results };
}, },
); );
@@ -134,6 +146,15 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
const result = await applyOne(sql, changeId, projectRoot); const result = await applyOne(sql, changeId, projectRoot);
if (!result.success) { if (!result.success) {
reply.code(422); reply.code(422);
} else {
// v2.6 Phase 3 (3.5): re-baseline the session worktree after a successful
// apply so the next external-agent turn diffs against applied-not-original.
// Resolve the change's session; best-effort, never fails the apply.
const sessRows = await sql<{ session_id: string }[]>`
SELECT session_id FROM pending_changes WHERE id = ${changeId}
`;
const sessionId = sessRows[0]?.session_id;
if (sessionId) await rebaselineWorktreeAfterApply(sql, sessionId).catch(() => {});
} }
return result; return result;
}, },

View File

@@ -131,6 +131,17 @@ END $$;
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change). -- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT; ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
-- v2.6 Phase 1-UX (U.6): opencode token/cost usage, ACCUMULATED per (chat_id, agent).
-- opencode's warm server emits `session.next.step.ended` once per LLM step (several
-- per multi-tool turn) carrying {tokens{input,output,reasoning,cache},cost}. We sum
-- each step's normalized {input,output,cost} onto the session row — running totals
-- for the whole conversation context, not last-step. Backend-only; no route/UI yet.
-- input_tokens folds in cache read+write; output_tokens folds in reasoning (see
-- backends/opencode-usage.ts). Defaults 0 so accumulation (col + delta) is well-defined.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS input_tokens BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS output_tokens BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS cost DOUBLE PRECISION NOT NULL DEFAULT 0;
-- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ─── -- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ───
-- The TAB (a chat) is the context unit: two opencode tabs in one session = two -- The TAB (a chat) is the context unit: two opencode tabs in one session = two
-- independent contexts sharing one worktree. So agent_sessions keys on -- independent contexts sharing one worktree. So agent_sessions keys on

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import type { SessionNotification } from '@agentclientprotocol/sdk';
import { mapSessionUpdate } from '../acp-event-map.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
/**
* Pure event-mapping shared by the one-shot ACP dispatch (AcpStreamContext) and
* the warm ACP backend (Phase 2). Mirrors the original handleSessionUpdate switch
* verbatim but returns normalized AgentEvents instead of publishing broker frames.
*/
describe('mapSessionUpdate (shared ACP event mapping)', () => {
function note(update: SessionNotification['update']): SessionNotification {
return { sessionId: 's1', update };
}
it('maps an agent_message_chunk text → a text event', () => {
const events = mapSessionUpdate(
note({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hello' } }),
);
expect(events).toEqual([{ type: 'text', text: 'hello' }]);
});
it('maps an agent_thought_chunk text → a reasoning event', () => {
const events = mapSessionUpdate(
note({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking' } }),
);
expect(events).toEqual([{ type: 'reasoning', text: 'thinking' }]);
});
it('ignores non-text content on message/thought chunks', () => {
const img = mapSessionUpdate(
note({
sessionUpdate: 'agent_message_chunk',
content: { type: 'image', data: 'x', mimeType: 'image/png' },
} as never),
);
expect(img).toEqual([]);
});
it('maps a tool_call → a tool_call event with a merged snapshot', () => {
const events = mapSessionUpdate(
note({
sessionUpdate: 'tool_call',
toolCallId: 't1',
title: 'read_file',
status: 'pending',
rawInput: { path: 'a.ts' },
} as never),
);
expect(events).toHaveLength(1);
expect(events[0]!.type).toBe('tool_call');
const snap = (events[0] as { type: 'tool_call'; toolCall: AcpToolSnapshot }).toolCall;
expect(snap.toolCallId).toBe('t1');
expect(snap.title).toBe('read_file');
expect(snap.status).toBe('pending');
expect(snap.rawInput).toEqual({ path: 'a.ts' });
});
it('maps a tool_call_update → a tool_update event merged over the prior snapshot', () => {
const prior = new Map<string, AcpToolSnapshot>([
['t1', { toolCallId: 't1', title: 'read_file', status: 'pending', rawInput: { path: 'a.ts' } }],
]);
const events = mapSessionUpdate(
note({
sessionUpdate: 'tool_call_update',
toolCallId: 't1',
status: 'completed',
rawOutput: 'file body',
} as never),
prior,
);
expect(events).toHaveLength(1);
expect(events[0]!.type).toBe('tool_update');
const snap = (events[0] as { type: 'tool_update'; toolCall: AcpToolSnapshot }).toolCall;
expect(snap.toolCallId).toBe('t1');
// merged: title carried from prior, status updated, output added, input retained
expect(snap.title).toBe('read_file');
expect(snap.status).toBe('completed');
expect(snap.rawOutput).toBe('file body');
expect(snap.rawInput).toEqual({ path: 'a.ts' });
});
it('maps available_commands_update → a commands event', () => {
const events = mapSessionUpdate(
note({
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'plan', description: 'make a plan' },
{ name: 'review', description: null },
],
} as never),
);
expect(events).toEqual([
{
type: 'commands',
commands: [
{ name: 'plan', description: 'make a plan' },
{ name: 'review', description: undefined },
],
},
]);
});
it('returns [] for unhandled update kinds (plan, mode change)', () => {
expect(mapSessionUpdate(note({ sessionUpdate: 'plan', entries: [] } as never))).toEqual([]);
expect(
mapSessionUpdate(note({ sessionUpdate: 'current_mode_update', currentModeId: 'code' } as never)),
).toEqual([]);
});
});

View File

@@ -0,0 +1,233 @@
import { describe, it, expect, vi } from 'vitest';
import { AgentPool, OPENCODE_POOL_KEY } from '../agent-pool.js';
import type {
AgentBackend,
AgentSessionHandle,
EnsureSessionOpts,
PromptCtx,
TurnResult,
} from '../agent-backend.js';
/**
* v2.6 Phase 3 — AgentPool lifecycle unit test (T.1). No DB / no child process:
* a fake AgentBackend records dispose + reports busy/health, so we exercise
* get-or-create, idle eviction, the LRU cap, the busy-never-evict rule, closeChat,
* and dispose-drains directly. The pure decisions are covered separately in
* backends/__tests__/lifecycle-decisions.test.ts; this verifies the wiring.
*/
class FakeBackend implements AgentBackend {
disposed = 0;
closedSessions = 0;
private busyFlag = false;
tickHealthCalls = 0;
constructor(public readonly name = 'fake') {}
setBusy(b: boolean): void {
this.busyFlag = b;
}
// — AgentBackend —
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
return {
sessionId,
agent: opts.agent,
backend: 'acp_warm',
chatId: opts.chatId,
worktreeId: opts.worktreeId,
agentSessionId: 'fake-session',
serverPort: null,
};
}
async prompt(_h: AgentSessionHandle, _input: string, _ctx: PromptCtx): Promise<TurnResult> {
return { ok: true };
}
async closeSession(): Promise<void> {
this.closedSessions++;
}
async dispose(): Promise<void> {
this.disposed++;
}
health(): 'up' | 'down' {
return 'up';
}
isBusy(): boolean {
return this.busyFlag;
}
async tickHealth(): Promise<void> {
this.tickHealthCalls++;
}
}
describe('AgentPool — get/register/touch (3.1)', () => {
it('register then get returns the same backend', () => {
const pool = new AgentPool();
const b = new FakeBackend();
pool.register('chat-1', 'goose', b);
expect(pool.get('chat-1', 'goose')).toBe(b);
expect(pool.get('chat-1', 'qwen')).toBeUndefined();
});
it('peek does NOT exist for a missing key', () => {
const pool = new AgentPool();
expect(pool.peek('nope', 'goose')).toBeUndefined();
});
it('health reports size + busy count', () => {
const pool = new AgentPool();
const a = new FakeBackend();
const b = new FakeBackend();
b.setBusy(true);
pool.register('c1', 'goose', a);
pool.register('c2', 'qwen', b);
expect(pool.health()).toEqual({ size: 2, busy: 1 });
});
});
describe('AgentPool.sweep — idle TTL eviction (3.1)', () => {
it('evicts an idle backend past the TTL and disposes it', async () => {
const pool = new AgentPool({ idleTtlMs: 1_000, maxLive: 100 });
const b = new FakeBackend();
pool.register('c1', 'goose', b);
// Sweep with now far past the registration → idle → evicted.
const { evicted } = await pool.sweep(Date.now() + 10_000);
expect(evicted).toEqual(['c1:goose']);
expect(b.disposed).toBe(1);
expect(pool.get('c1', 'goose')).toBeUndefined();
});
it('never evicts a busy backend even past the TTL', async () => {
const pool = new AgentPool({ idleTtlMs: 1_000, maxLive: 100 });
const b = new FakeBackend();
b.setBusy(true);
pool.register('c1', 'goose', b);
const { evicted } = await pool.sweep(Date.now() + 10_000);
expect(evicted).toEqual([]);
expect(b.disposed).toBe(0);
expect(pool.get('c1', 'goose')).toBe(b);
});
it('touch keeps a backend warm so the TTL measures from the last turn', async () => {
const pool = new AgentPool({ idleTtlMs: 5_000, maxLive: 100 });
const b = new FakeBackend();
pool.register('c1', 'goose', b);
const base = Date.now();
// 4s later, touch — resets activity. A sweep at +6s from base is only +2s from
// the touch → still within TTL → not evicted.
vi.spyOn(Date, 'now').mockReturnValue(base + 4_000);
pool.touch('c1', 'goose');
vi.restoreAllMocks();
const { evicted } = await pool.sweep(base + 6_000);
expect(evicted).toEqual([]);
});
});
describe('AgentPool.sweep — LRU cap (3.4)', () => {
it('evicts the least-recently-used beyond the cap', async () => {
const pool = new AgentPool({ idleTtlMs: 1_000_000, maxLive: 2 });
const base = 1_000_000;
const mk = (key: string, regAt: number) => {
vi.spyOn(Date, 'now').mockReturnValue(regAt);
const b = new FakeBackend(key);
const [chat, agent] = key.split(':');
pool.register(chat!, agent!, b);
vi.restoreAllMocks();
return b;
};
const a = mk('c1:goose', base + 100);
const b = mk('c2:goose', base + 300);
const c = mk('c3:goose', base + 200);
// 3 entries, cap 2, all within idle TTL → LRU (oldest = a@+100) evicted.
const { evicted } = await pool.sweep(base + 1_000);
expect(evicted).toEqual(['c1:goose']);
expect(a.disposed).toBe(1);
expect(b.disposed).toBe(0);
expect(c.disposed).toBe(0);
});
});
describe('AgentPool.sweep — proactive health probe (3.2)', () => {
it('drives each backend tickHealth before eviction', async () => {
const pool = new AgentPool({ idleTtlMs: 1_000_000, maxLive: 100 });
const b = new FakeBackend();
pool.register('c1', 'opencode', b);
await pool.sweep(Date.now());
expect(b.tickHealthCalls).toBe(1);
});
});
describe('AgentPool.closeChat — chat-close teardown (3.3)', () => {
it('disposes only the matching chat keys, leaving others + the shared server', async () => {
const pool = new AgentPool();
const goose = new FakeBackend('goose');
const qwen = new FakeBackend('qwen');
const other = new FakeBackend('other-chat');
const ocServer = new FakeBackend('opencode-server');
pool.register('chat-1', 'goose', goose);
pool.register('chat-1', 'qwen', qwen);
pool.register('chat-2', 'goose', other);
pool.register(OPENCODE_POOL_KEY, 'opencode', ocServer);
const removed = await pool.closeChat('chat-1');
expect(removed.sort()).toEqual(['chat-1:goose', 'chat-1:qwen']);
expect(goose.disposed).toBe(1);
expect(qwen.disposed).toBe(1);
// other chat + shared opencode server untouched.
expect(other.disposed).toBe(0);
expect(ocServer.disposed).toBe(0);
expect(pool.peek('chat-2', 'goose')).toBe(other);
expect(pool.peek(OPENCODE_POOL_KEY, 'opencode')).toBe(ocServer);
});
it('does not dispose a busy backend on closeChat', async () => {
const pool = new AgentPool();
const b = new FakeBackend();
b.setBusy(true);
pool.register('chat-1', 'goose', b);
const removed = await pool.closeChat('chat-1');
expect(removed).toEqual([]);
expect(b.disposed).toBe(0);
});
it('does not match a chat id that is a prefix of another', async () => {
// 'chat-1' must not match 'chat-10' — keys are `${chatId}:${agent}` so the
// colon delimiter prevents the prefix collision.
const pool = new AgentPool();
const a = new FakeBackend();
const b = new FakeBackend();
pool.register('chat-1', 'goose', a);
pool.register('chat-10', 'goose', b);
await pool.closeChat('chat-1');
expect(a.disposed).toBe(1);
expect(b.disposed).toBe(0);
expect(pool.peek('chat-10', 'goose')).toBe(b);
});
});
describe('AgentPool.dispose — drain all (T.1)', () => {
it('disposes every backend and clears the map', async () => {
const pool = new AgentPool();
const a = new FakeBackend();
const b = new FakeBackend();
pool.register('c1', 'goose', a);
pool.register('c2', 'qwen', b);
await pool.dispose();
expect(a.disposed).toBe(1);
expect(b.disposed).toBe(1);
expect(pool.health()).toEqual({ size: 0, busy: 0 });
});
it('tolerates a backend whose dispose throws', async () => {
const pool = new AgentPool();
const good = new FakeBackend();
const bad = new FakeBackend();
bad.dispose = async () => {
throw new Error('boom');
};
pool.register('c1', 'goose', bad);
pool.register('c2', 'qwen', good);
await expect(pool.dispose()).resolves.toBeUndefined();
expect(good.disposed).toBe(1);
});
});

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { rm, mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import postgres from 'postgres';
import {
ensureSessionWorktree,
closeChatBackendState,
rebaselineWorktreeAfterApply,
} from '../worktrees.js';
import { reapOrphanWorktrees } from '../orphan-worktree-reaper.js';
import { hostExec } from '../host-exec.js';
/**
* v2.6 Phase 3 (3.6) — reconnect-after-restart integration test.
*
* Proves the DB-truth side of crash/restart recovery: a BooCoder restart wipes the
* in-memory pool, but the persistent `worktrees` + `agent_sessions` rows survive,
* so the "next turn" re-resolves the SAME worktree (reattach, no new dir) and the
* agent-session row is still there to resume from. Also exercises the chat-close
* hook (3.3), the apply re-baseline (3.5), and the orphan reaper (3.4) end-to-end
* against a real git repo + postgres.
*
* Requires DATABASE_URL (DB-opt-in; skips cleanly otherwise) AND git on PATH. Runs:
* DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/coder test
*/
describe.runIf(!!process.env.DATABASE_URL)('reconnect after restart (Phase 3)', () => {
let sql: ReturnType<typeof postgres>;
const stamp = Date.now();
const projectDir = `/tmp/boocode-reconnect-proj-${stamp}`;
let projectId: string;
let sessionId: string;
let chatId: string;
beforeAll(async () => {
sql = postgres(process.env.DATABASE_URL!, { max: 3 });
// Both schemas land in the one boochat DB: server owns sessions/chats/projects,
// coder owns worktrees/agent_sessions (FK targets must pre-exist → server first).
const serverSchema = resolve(__dirname, '../../../../server/src/schema.sql');
const coderSchema = resolve(__dirname, '../../schema.sql');
await sql.unsafe(readFileSync(serverSchema, 'utf8'));
await sql.unsafe(readFileSync(coderSchema, 'utf8'));
// A real git repo with one commit so worktree add / diff / rev-parse work.
await mkdir(projectDir, { recursive: true });
await hostExec(
`cd ${projectDir} && git init -q && git config user.email t@t && git config user.name t ` +
`&& echo hello > README.md && git add -A && git commit -qm init`,
{ timeoutMs: 20_000 },
);
const [project] = await sql<{ id: string }[]>`
INSERT INTO projects (name, path, status) VALUES ('reconnect-test', ${projectDir}, 'open') RETURNING id
`;
projectId = project!.id;
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${projectId}, 'recon', 'm', 'open') RETURNING id
`;
sessionId = session!.id;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status) VALUES (${sessionId}, 'tab', 'open') RETURNING id
`;
chatId = chat!.id;
});
afterAll(async () => {
if (sql) {
// Best-effort worktree cleanup before dropping rows.
const rows = await sql<{ path: string }[]>`SELECT path FROM worktrees WHERE session_id = ${sessionId}`.catch(() => []);
for (const r of rows) {
await hostExec(`git -C ${projectDir} worktree remove ${r.path} --force`, { timeoutMs: 10_000 }).catch(() => {});
}
await sql`DELETE FROM agent_sessions WHERE chat_id = ${chatId}`.catch(() => {});
await sql`DELETE FROM worktrees WHERE session_id = ${sessionId}`.catch(() => {});
await sql`DELETE FROM chats WHERE id = ${chatId}`.catch(() => {});
await sql`DELETE FROM sessions WHERE id = ${sessionId}`.catch(() => {});
await sql`DELETE FROM projects WHERE id = ${projectId}`.catch(() => {});
await sql.end({ timeout: 5 });
}
await rm(projectDir, { recursive: true, force: true });
});
it('reattaches the SAME worktree across a simulated restart (no new dir)', async () => {
// "Turn 1" — first ensureSessionWorktree creates the worktree + row.
const first = await ensureSessionWorktree(sql, projectDir, sessionId);
expect(existsSync(first.worktreePath)).toBe(true);
expect(first.baseCommit).toBeTruthy();
// Simulate an agent_sessions row written by turn 1 (opencode).
await sql`
INSERT INTO agent_sessions (chat_id, session_id, worktree_id, agent, backend, agent_session_id, status, last_active_at)
VALUES (${chatId}, ${sessionId}, ${first.worktreeId}, 'opencode', 'opencode_server', 'oc-sess-1', 'active', clock_timestamp())
ON CONFLICT (chat_id, agent) DO NOTHING
`;
// "Restart" = brand-new resolution with NO in-memory state. ensureSessionWorktree
// must return the EXISTING row (same id + path), proving reattach not re-create.
const second = await ensureSessionWorktree(sql, projectDir, sessionId);
expect(second.worktreeId).toBe(first.worktreeId);
expect(second.worktreePath).toBe(first.worktreePath);
expect(second.baseCommit).toBe(first.baseCommit);
// The agent_sessions row survived the "restart" with its resume handle intact.
const [row] = await sql<{ agent_session_id: string; status: string }[]>`
SELECT agent_session_id, status FROM agent_sessions WHERE chat_id = ${chatId} AND agent = 'opencode'
`;
expect(row!.agent_session_id).toBe('oc-sess-1');
});
it('re-baselines the worktree diff after apply (3.5)', async () => {
const wt = await ensureSessionWorktree(sql, projectDir, sessionId);
const baseBefore = wt.baseCommit;
// Make a change in the worktree (as an external agent would).
await hostExec(`cd ${wt.worktreePath} && echo change >> README.md`, { timeoutMs: 10_000 });
const r = await rebaselineWorktreeAfterApply(sql, sessionId);
expect(r.rebaselined).toBe(true);
expect(r.newBaseCommit).toBeTruthy();
expect(r.newBaseCommit).not.toBe(baseBefore);
const [row] = await sql<{ base_commit: string }[]>`
SELECT base_commit FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'
`;
expect(row!.base_commit).toBe(r.newBaseCommit);
// Idempotent: a second re-baseline with no new edits is a no-op.
const r2 = await rebaselineWorktreeAfterApply(sql, sessionId);
expect(r2.rebaselined).toBe(false);
});
it('chat-close hook closes agent rows + removes the worktree on the last chat (3.3)', async () => {
// Sanity: an active worktree + agent row exist from the prior tests.
const beforeWt = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`;
expect(beforeWt.length).toBe(1);
const result = await closeChatBackendState(sql, chatId);
expect(result.agentRowsClosed).toBeGreaterThanOrEqual(1);
// chatId is the session's only chat → worktree removed (it was clean after the
// re-baseline commit), not at-risk.
expect(result.worktreeAtRisk).toBe(false);
expect(result.worktreeRemoved).toBe(true);
const [agentRow] = await sql<{ status: string }[]>`
SELECT status FROM agent_sessions WHERE chat_id = ${chatId} AND agent = 'opencode'
`;
expect(agentRow!.status).toBe('closed');
const activeWt = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`;
expect(activeWt.length).toBe(0); // archived, no longer active
});
it('orphan reaper leaves a live worktree alone and reaps a row-less dir (3.4)', async () => {
// Recreate a live worktree for this session (the close test archived the old one).
const live = await ensureSessionWorktree(sql, projectDir, sessionId);
expect(existsSync(live.worktreePath)).toBe(true);
// A live worktree (active row) with grace 0 must NOT be reaped.
const r1 = await reapOrphanWorktrees(sql, console as never, 0, Date.now());
expect(r1.reaped).not.toContain(live.worktreePath);
// Now archive its row (simulating a leaked dir) and reap again — it becomes an
// orphan and is reclaimed (it's clean → not at-risk).
await sql`UPDATE worktrees SET status = 'archived' WHERE id = ${live.worktreeId}`;
const r2 = await reapOrphanWorktrees(sql, console as never, 0, Date.now());
expect(r2.reaped).toContain(live.worktreePath);
expect(existsSync(live.worktreePath)).toBe(false);
});
});

View File

@@ -32,9 +32,9 @@ import { createAcpNdJsonStream } from './acp-stream.js';
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js'; import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js'; import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js'; import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
import { mapSessionUpdate } from './acp-event-map.js';
import { import {
type AcpToolSnapshot, type AcpToolSnapshot,
mergeToolSnapshot,
snapshotToWireToolCall, snapshotToWireToolCall,
synthesizeCanceledSnapshots, synthesizeCanceledSnapshots,
} from './acp-tool-snapshot.js'; } from './acp-tool-snapshot.js';
@@ -159,63 +159,47 @@ class AcpStreamContext {
} as WsFrame); } as WsFrame);
} }
handleToolUpdate(toolCallId: string, update: Parameters<typeof mergeToolSnapshot>[1]): void {
const previous = this.toolSnapshots.get(toolCallId);
const snapshot = mergeToolSnapshot(toolCallId, update, previous);
this.toolSnapshots.set(toolCallId, snapshot);
this.publishToolSnapshot(snapshot);
}
async handleSessionUpdate(params: SessionNotification): Promise<void> { async handleSessionUpdate(params: SessionNotification): Promise<void> {
const update = params.update; // v2.6 Phase 2: the case-by-case mapping now lives in the shared, pure
switch (update.sessionUpdate) { // `mapSessionUpdate` (reused by the warm ACP backend). This method keeps the
case 'agent_message_chunk': { // identical broker-publishing side effects — it just translates the normalized
const content = update.content; // AgentEvents back into the same frames it always emitted. `this.toolSnapshots`
if (content.type === 'text' && 'text' in content) { // is the merge accumulator, so a later tool_call_update merges over its
const text = (content as { text: string }).text; // tool_call (the prior `handleToolUpdate` behavior, byte-for-byte).
this.textChunks.push(text); for (const event of mapSessionUpdate(params, this.toolSnapshots)) {
switch (event.type) {
case 'text':
this.textChunks.push(event.text);
if (this.canStream()) { if (this.canStream()) {
this.opts.broker!.publishFrame(this.opts.sessionId!, { this.opts.broker!.publishFrame(this.opts.sessionId!, {
type: 'delta', type: 'delta',
message_id: this.opts.messageId!, message_id: this.opts.messageId!,
chat_id: this.opts.chatId!, chat_id: this.opts.chatId!,
content: text, content: event.text,
} as WsFrame); } as WsFrame);
} }
}
break; break;
} case 'reasoning':
case 'agent_thought_chunk': { this.reasoningChunks.push(event.text);
const content = update.content;
if (content.type === 'text' && 'text' in content) {
const text = (content as { text: string }).text;
this.reasoningChunks.push(text);
if (this.canStream()) { if (this.canStream()) {
this.opts.broker!.publishFrame(this.opts.sessionId!, { this.opts.broker!.publishFrame(this.opts.sessionId!, {
type: 'reasoning_delta', type: 'reasoning_delta',
message_id: this.opts.messageId!, message_id: this.opts.messageId!,
chat_id: this.opts.chatId!, chat_id: this.opts.chatId!,
content: text, content: event.text,
} as WsFrame); } as WsFrame);
} }
}
break; break;
}
case 'tool_call': case 'tool_call':
this.handleToolUpdate(update.toolCallId, update); case 'tool_update':
// mapSessionUpdate already stored the merged snapshot in this.toolSnapshots.
this.publishToolSnapshot(event.toolCall);
break; break;
case 'tool_call_update': case 'commands':
this.handleToolUpdate(update.toolCallId, update); if (this.opts.taskId && event.commands.length > 0) {
break; mergeTaskCommands(this.opts.taskId, event.commands);
case 'available_commands_update': {
const commands = update.availableCommands.map((cmd) => ({
name: cmd.name,
description: cmd.description ?? undefined,
}));
if (this.opts.taskId && commands.length > 0) {
mergeTaskCommands(this.opts.taskId, commands);
if (this.canStream() && this.opts.sessionId) { if (this.canStream() && this.opts.sessionId) {
const all = getTaskCommands(this.opts.taskId) ?? commands; const all = getTaskCommands(this.opts.taskId) ?? event.commands;
this.opts.broker!.publishFrame(this.opts.sessionId, { this.opts.broker!.publishFrame(this.opts.sessionId, {
type: 'agent_commands', type: 'agent_commands',
task_id: this.opts.taskId, task_id: this.opts.taskId,
@@ -226,8 +210,6 @@ class AcpStreamContext {
} }
break; break;
} }
default:
break;
} }
} }

View File

@@ -0,0 +1,68 @@
/**
* Shared ACP session-update → normalized AgentEvent mapping.
*
* Extracted verbatim (v2.6 Phase 2) from `AcpStreamContext.handleSessionUpdate`
* in `acp-dispatch.ts` so the warm ACP backend (`backends/warm-acp.ts`) and the
* one-shot dispatch share ONE mapping. The one-shot path translates the returned
* events into broker frames itself (preserving its prior behavior byte-for-byte);
* the warm backend forwards them to the dispatcher's `ctx.onEvent` exactly like
* the opencode-server backend does. No I/O, no broker — pure, so it's unit-testable.
*
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2b.
*/
import type { SessionNotification } from '@agentclientprotocol/sdk';
import type { AgentEvent } from './agent-backend.js';
import { type AcpToolSnapshot, mergeToolSnapshot } from './acp-tool-snapshot.js';
/**
* Map one ACP `session/update` notification to zero-or-more normalized AgentEvents.
*
* `priorSnapshots` is the caller-owned tool-call snapshot accumulator (toolCallId →
* snapshot). For `tool_call` / `tool_call_update` the merged snapshot is written
* back into it (mutated in place, mirroring `AcpStreamContext.handleToolUpdate`)
* so a later `tool_call_update` merges over the earlier `tool_call`. Pass an empty
* Map for a stateless single call.
*
* Returns an array (never throws) so the caller can splat it onto `onEvent`.
*/
export function mapSessionUpdate(
params: SessionNotification,
priorSnapshots: Map<string, AcpToolSnapshot> = new Map(),
): AgentEvent[] {
const update = params.update;
switch (update.sessionUpdate) {
case 'agent_message_chunk': {
const content = update.content;
if (content.type === 'text' && 'text' in content) {
return [{ type: 'text', text: (content as { text: string }).text }];
}
return [];
}
case 'agent_thought_chunk': {
const content = update.content;
if (content.type === 'text' && 'text' in content) {
return [{ type: 'reasoning', text: (content as { text: string }).text }];
}
return [];
}
case 'tool_call': {
const snapshot = mergeToolSnapshot(update.toolCallId, update, priorSnapshots.get(update.toolCallId));
priorSnapshots.set(update.toolCallId, snapshot);
return [{ type: 'tool_call', toolCall: snapshot }];
}
case 'tool_call_update': {
const snapshot = mergeToolSnapshot(update.toolCallId, update, priorSnapshots.get(update.toolCallId));
priorSnapshots.set(update.toolCallId, snapshot);
return [{ type: 'tool_update', toolCall: snapshot }];
}
case 'available_commands_update': {
const commands = update.availableCommands.map((cmd) => ({
name: cmd.name,
description: cmd.description ?? undefined,
}));
return [{ type: 'commands', commands }];
}
default:
return [];
}
}

View File

@@ -70,6 +70,12 @@ export interface PromptCtx {
model: string; model: string;
signal: AbortSignal; signal: AbortSignal;
onEvent: (e: AgentEvent) => void; onEvent: (e: AgentEvent) => void;
/** Phase 2: per-turn task id, so a warm ACP backend can route permission /
* elicitation prompts back to the UI via the permission-waiter. Optional —
* the opencode-server backend (autonomous) ignores it. */
taskId?: string;
/** Phase 2: per-turn mode id (gates autonomous mode in the permission-waiter). */
modeId?: string;
} }
/** Result of a completed turn (§2). Diff/persist happen outside the backend. */ /** Result of a completed turn (§2). Diff/persist happen outside the backend. */
@@ -93,4 +99,21 @@ export interface AgentBackend {
dispose(): Promise<void>; dispose(): Promise<void>;
/** Liveness for health endpoint + dispatcher fallback decision. §2 */ /** Liveness for health endpoint + dispatcher fallback decision. §2 */
health(): 'up' | 'down'; health(): 'up' | 'down';
/**
* v2.6 Phase 3: true iff a turn is in flight on this backend. The pool's idle
* eviction + LRU cap NEVER evict a busy backend (design §6 busy rule); the
* health-monitor defers a restart while busy (stale-grace). Optional so the
* Phase-0 scaffold and any test double stay compatible — absent ⇒ treated as
* not busy. opencode-server (multi-session) is busy iff ANY session has an
* active turn; warm-acp (single session) iff its one slot is active.
*/
isBusy?(): boolean;
/**
* v2.6 Phase 3: optional proactive health probe + busy-aware self-restart, run
* by the pool's periodic sweep. The opencode-server backend implements it
* (detects a hung-but-not-exited server and restarts when non-busy). Backends
* with no long-lived shared process (warm-ACP recovers lazily on its own child
* exit) can omit it. Must never throw — the sweep ignores rejections.
*/
tickHealth?(now?: number): Promise<void>;
} }

View File

@@ -1,44 +1,246 @@
/** /**
* v2.6 — AgentPool (Phase 0 scaffold). * v2.6 — AgentPool.
* *
* Lazy get-or-create registry of `AgentBackend` instances keyed by * Lazy get-or-create registry of `AgentBackend` instances keyed by
* `${sessionId}:${agent}`. Phase 0 ships the skeleton only: an in-memory Map, * `${primary}:${agent}` (primary = chatId for warm-ACP, a fixed sentinel for the
* lookup / register / health, and clean disposal wired to the server's onClose. * single shared opencode server). Phase 0 shipped the skeleton (Map + health +
* Spawning lands in Phase 1/2; nothing populates the map yet. * dispose). Phase 3 adds the LIFECYCLE: per-entry idle tracking, a periodic
* idle-TTL + LRU-cap sweep (the pure decisions live in
* `backends/lifecycle-decisions.ts`), and a `closeChat` helper for the chat-close
* hook. Reattach after eviction is implicit — the next turn's `ensureSession`
* rebuilds the backend from `agent_sessions` / `worktrees` (DB is the source of
* truth; the in-memory pool is a warm cache).
* *
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2. * The hard rule (design §6): NEVER evict a busy backend (one with an in-flight
* turn). `selectIdleEvictionTargets` / `selectLruEvictionTargets` enforce it via
* `backend.isBusy()`; a long turn that outlives the TTL is left alone.
*
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2 / §6.
*/ */
import type { FastifyBaseLogger } from 'fastify';
import type { AgentBackend } from './agent-backend.js'; import type { AgentBackend } from './agent-backend.js';
import {
selectIdleEvictionTargets,
selectLruEvictionTargets,
DEFAULT_IDLE_TTL_MS,
DEFAULT_MAX_LIVE_BACKENDS,
} from './backends/lifecycle-decisions.js';
interface PoolEntry {
primary: string;
agent: string;
backend: AgentBackend;
/** Epoch ms of the last turn boundary (register or touch). Drives idle/LRU. */
lastActiveAt: number;
}
export interface AgentPoolOpts {
/** Idle TTL before a non-busy backend is evicted. Default 30 min. */
idleTtlMs?: number;
/** Max live backends before the LRU cap evicts the least-recently-used. */
maxLive?: number;
/** Sweep cadence. Default 60s (mirrors the server's periodic sweeper). */
sweepIntervalMs?: number;
log?: FastifyBaseLogger;
}
const DEFAULT_SWEEP_INTERVAL_MS = 60_000;
export class AgentPool { export class AgentPool {
private readonly backends = new Map<string, AgentBackend>(); private readonly backends = new Map<string, PoolEntry>();
private idleTtlMs: number;
private maxLive: number;
private sweepIntervalMs: number;
private log: FastifyBaseLogger | undefined;
private sweepTimer: ReturnType<typeof setInterval> | null = null;
/** Serializes sweep runs so a slow eviction can't overlap the next tick. */
private sweeping = false;
private key(sessionId: string, agent: string): string { constructor(opts: AgentPoolOpts = {}) {
return `${sessionId}:${agent}`; this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
this.maxLive = opts.maxLive ?? DEFAULT_MAX_LIVE_BACKENDS;
this.sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
this.log = opts.log;
} }
/** Map lookup only. Spawning is Phase 1/2 — never creates here. */ /** Apply env-derived knobs to the module singleton at bootstrap (before
get(sessionId: string, agent: string): AgentBackend | undefined { * startReaper). Only overrides explicitly-provided fields. */
return this.backends.get(this.key(sessionId, agent)); configure(opts: AgentPoolOpts): void {
if (opts.idleTtlMs != null) this.idleTtlMs = opts.idleTtlMs;
if (opts.maxLive != null) this.maxLive = opts.maxLive;
if (opts.sweepIntervalMs != null) this.sweepIntervalMs = opts.sweepIntervalMs;
if (opts.log) this.log = opts.log;
} }
/** Store a backend instance for this (session, agent). */ private key(primary: string, agent: string): string {
register(sessionId: string, agent: string, backend: AgentBackend): void { return `${primary}:${agent}`;
this.backends.set(this.key(sessionId, agent), backend); }
/** Map lookup only. Spawning happens in the dispatcher (Phase 1/2). A hit also
* marks the entry recently-active so a resolve-without-prompt doesn't get it
* evicted out from under an imminent turn. */
get(primary: string, agent: string): AgentBackend | undefined {
const entry = this.backends.get(this.key(primary, agent));
if (entry) entry.lastActiveAt = Date.now();
return entry?.backend;
}
/** Store a backend instance for this (primary, agent). */
register(primary: string, agent: string, backend: AgentBackend): void {
this.backends.set(this.key(primary, agent), { primary, agent, backend, lastActiveAt: Date.now() });
}
/** Mark a backend recently-active (call at turn start AND settle so a long turn
* keeps its slot warm). No-op if the key isn't pooled. */
touch(primary: string, agent: string): void {
const entry = this.backends.get(this.key(primary, agent));
if (entry) entry.lastActiveAt = Date.now();
}
/** Snapshot for the decision helpers (busy is read live from the backend). */
private snapshots(): { key: string; lastActiveAt: number; busy: boolean }[] {
const out: { key: string; lastActiveAt: number; busy: boolean }[] = [];
for (const [key, e] of this.backends) {
out.push({ key, lastActiveAt: e.lastActiveAt, busy: e.backend.isBusy?.() ?? false });
}
return out;
} }
/** Summary for the health endpoint. */ /** Summary for the health endpoint. */
health(): { size: number } { health(): { size: number; busy: number } {
return { size: this.backends.size }; let busy = 0;
for (const e of this.backends.values()) if (e.backend.isBusy?.()) busy++;
return { size: this.backends.size, busy };
}
// ─── Phase 3: idle-TTL + LRU eviction sweep ──────────────────────────────────
/** Start the periodic idle + LRU sweep. Idempotent; unref'd so it never holds
* the process open on its own. */
startReaper(log?: FastifyBaseLogger): void {
if (log) this.log = log;
if (this.sweepTimer) return;
this.sweepTimer = setInterval(() => {
void this.sweep().catch((err) => {
this.log?.warn({ err: errMsg(err) }, 'agent-pool: sweep error');
});
}, this.sweepIntervalMs);
this.sweepTimer.unref?.();
}
stopReaper(): void {
if (this.sweepTimer) {
clearInterval(this.sweepTimer);
this.sweepTimer = null;
}
}
/**
* One sweep pass: evict idle-past-TTL backends, then enforce the LRU cap.
* Deduped (a key can't appear in both lists for one pass). Busy backends are
* excluded by the decision helpers — a live turn is never torn down.
*/
async sweep(now: number = Date.now()): Promise<{ evicted: string[] }> {
if (this.sweeping) return { evicted: [] };
this.sweeping = true;
try {
// Phase 3: drive each backend's optional proactive health probe first (the
// opencode server's busy-aware hung-detect + self-restart). Best-effort —
// a probe must never fail the sweep.
for (const e of this.backends.values()) {
if (e.backend.tickHealth) {
await e.backend.tickHealth(now).catch((err) => {
this.log?.warn({ key: this.key(e.primary, e.agent), err: errMsg(err) }, 'agent-pool: tickHealth threw');
});
}
}
const snaps = this.snapshots();
const idle = selectIdleEvictionTargets(snaps, now, this.idleTtlMs);
// LRU runs on what remains after idle eviction, so the two never double-evict.
const idleSet = new Set(idle);
const remaining = snaps.filter((s) => !idleSet.has(s.key));
const lru = selectLruEvictionTargets(remaining, this.maxLive);
const targets = [...idle, ...lru];
if (targets.length === 0) return { evicted: [] };
const evicted: string[] = [];
for (const key of targets) {
const entry = this.backends.get(key);
if (!entry) continue;
// Re-check busy right before teardown — a turn may have started since the
// snapshot. Defensive; the decision already excluded busy at snapshot time.
if (entry.backend.isBusy?.()) continue;
this.backends.delete(key);
try {
await entry.backend.dispose();
} catch (err) {
this.log?.warn({ key, err: errMsg(err) }, 'agent-pool: backend dispose threw during eviction');
}
evicted.push(key);
}
if (evicted.length > 0) {
this.log?.info({ evicted, size: this.backends.size }, 'agent-pool: evicted idle/over-cap backends');
}
return { evicted };
} finally {
this.sweeping = false;
}
}
// ─── Phase 3: chat-close cleanup (3.3) ───────────────────────────────────────
/**
* Tear down every pooled backend whose key is for this chat. Used by the
* chat-close hook. The opencode server is shared (keyed on a sentinel, not the
* chat), so it is NOT disposed here — only its session is closed via
* `closeSession`, which the hook calls directly with the per-(chat,agent)
* handle. Returns the keys it removed. Skips busy entries (a close mid-turn is
* rare but must not kill a live stream — the idle sweep reaps it shortly after).
*/
async closeChat(chatId: string): Promise<string[]> {
const removed: string[] = [];
const prefix = `${chatId}:`;
for (const [key, entry] of [...this.backends]) {
if (!key.startsWith(prefix)) continue;
if (entry.backend.isBusy?.()) continue;
this.backends.delete(key);
try {
await entry.backend.dispose();
} catch (err) {
this.log?.warn({ key, err: errMsg(err) }, 'agent-pool: dispose threw during closeChat');
}
removed.push(key);
}
return removed;
}
/** Look up a backend by exact key without bumping its activity (for closeSession). */
peek(primary: string, agent: string): AgentBackend | undefined {
return this.backends.get(this.key(primary, agent))?.backend;
} }
/** Dispose every backend and clear the map. Tolerates throwing backends. */ /** Dispose every backend and clear the map. Tolerates throwing backends. */
async dispose(): Promise<void> { async dispose(): Promise<void> {
this.stopReaper();
const entries = [...this.backends.values()]; const entries = [...this.backends.values()];
this.backends.clear(); this.backends.clear();
await Promise.allSettled(entries.map((b) => b.dispose())); await Promise.allSettled(entries.map((e) => e.backend.dispose()));
} }
} }
/** Single shared instance — referenced only by the server's onClose hook in Phase 0. */ function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* The shared opencode server is pooled under a FIXED sentinel (one server per
* BooCoder process, multiplexing all opencode sessions internally) rather than a
* chat id — so it is NOT torn down by `closeChat(chatId)` (only its per-chat
* session is closed). Exported so the dispatcher + the lifecycle close-hook agree
* on the key without drift.
*/
export const OPENCODE_POOL_KEY = '__opencode_server__';
/** Single shared instance — registered by the dispatcher, swept + drained by the
* server's onClose hook. */
export const agentPool = new AgentPool(); export const agentPool = new AgentPool();

View File

@@ -0,0 +1,176 @@
import { describe, it, expect } from 'vitest';
import {
selectIdleEvictionTargets,
selectLruEvictionTargets,
decideRestart,
selectOrphanWorktreeTargets,
DEFAULT_IDLE_TTL_MS,
DEFAULT_MAX_LIVE_BACKENDS,
type PoolEntrySnapshot,
} from '../lifecycle-decisions.js';
/**
* v2.6 Phase 3 — pure lifecycle decisions. No DB, no children, no timers; `now`
* is injected. Models prune.ts:selectPruneTargets — the caller acts on the keys.
*/
const NOW = 1_000_000_000_000;
function entry(key: string, ageMs: number, busy = false): PoolEntrySnapshot {
return { key, lastActiveAt: NOW - ageMs, busy };
}
describe('selectIdleEvictionTargets (3.1)', () => {
it('evicts entries idle past the TTL', () => {
const entries = [
entry('a:opencode', DEFAULT_IDLE_TTL_MS + 1),
entry('b:goose', DEFAULT_IDLE_TTL_MS - 1),
];
expect(selectIdleEvictionTargets(entries, NOW)).toEqual(['a:opencode']);
});
it('never evicts a busy entry even when idle past the TTL', () => {
const entries = [entry('a:opencode', DEFAULT_IDLE_TTL_MS * 10, /* busy */ true)];
expect(selectIdleEvictionTargets(entries, NOW)).toEqual([]);
});
it('respects a custom TTL', () => {
const entries = [entry('a:goose', 5_000), entry('b:qwen', 500)];
expect(selectIdleEvictionTargets(entries, NOW, 1_000)).toEqual(['a:goose']);
});
it('treats exactly-at-TTL as evictable (>=)', () => {
expect(selectIdleEvictionTargets([entry('a:x', 1_000)], NOW, 1_000)).toEqual(['a:x']);
});
it('returns empty for an empty pool', () => {
expect(selectIdleEvictionTargets([], NOW)).toEqual([]);
});
});
describe('selectLruEvictionTargets (3.4)', () => {
it('returns nothing when at or under the cap', () => {
const entries = [entry('a:x', 10), entry('b:y', 20)];
expect(selectLruEvictionTargets(entries, 2)).toEqual([]);
expect(selectLruEvictionTargets(entries, 5)).toEqual([]);
});
it('evicts the least-recently-used beyond the cap', () => {
// oldest first: c (300ms ago) is LRU, then a (100ms), then b (10ms).
const entries = [entry('a:x', 100), entry('b:y', 10), entry('c:z', 300)];
expect(selectLruEvictionTargets(entries, 2)).toEqual(['c:z']);
});
it('evicts multiple LRU entries to reach the cap', () => {
const entries = [
entry('a:x', 100),
entry('b:y', 10),
entry('c:z', 300),
entry('d:w', 200),
];
// cap 1: must remove 3, oldest-first c(300), d(200), a(100).
expect(selectLruEvictionTargets(entries, 1)).toEqual(['c:z', 'd:w', 'a:x']);
});
it('never evicts a busy entry even if it is the LRU', () => {
// c is LRU but busy → it cannot be evicted; fall to the next-oldest (a).
const entries = [entry('a:x', 100), entry('b:y', 10), entry('c:z', 300, true)];
expect(selectLruEvictionTargets(entries, 2)).toEqual(['a:x']);
});
it('can transiently exceed the cap when too many are busy', () => {
// cap 1, but both old entries busy → only the single idle one is evictable.
const entries = [entry('a:x', 100, true), entry('c:z', 300, true), entry('b:y', 10)];
expect(selectLruEvictionTargets(entries, 1)).toEqual(['b:y']);
});
it('uses the default cap when omitted', () => {
const entries = Array.from({ length: DEFAULT_MAX_LIVE_BACKENDS + 1 }, (_, i) =>
entry(`k${String(i).padStart(2, '0')}:a`, (i + 1) * 1000),
);
const evicted = selectLruEvictionTargets(entries);
// exactly one over the default cap → evict the single LRU (largest age).
expect(evicted).toHaveLength(1);
expect(evicted[0]).toBe(`k${String(DEFAULT_MAX_LIVE_BACKENDS).padStart(2, '0')}:a`);
});
});
describe('decideRestart (3.2, busy-aware)', () => {
const base = {
consecutiveFailures: 0,
busy: false,
unhealthyBusySince: 0,
now: NOW,
failureThreshold: 3,
staleBusyGraceMs: 120_000,
};
it('does nothing when healthy', () => {
expect(decideRestart({ ...base, processExited: false, healthy: true }))
.toEqual({ action: 'none', reason: 'healthy' });
});
it('restarts immediately when the process exited', () => {
expect(decideRestart({ ...base, processExited: true, busy: true }))
.toEqual({ action: 'restart', reason: 'process-exited' });
});
it('waits below the failure threshold', () => {
expect(decideRestart({ ...base, processExited: false, consecutiveFailures: 2 }))
.toEqual({ action: 'wait', reason: 'below-threshold' });
});
it('restarts at the threshold when idle', () => {
expect(decideRestart({ ...base, processExited: false, consecutiveFailures: 3 }))
.toEqual({ action: 'restart', reason: 'threshold' });
});
it('defers a restart while busy within the grace window', () => {
expect(decideRestart({
...base, processExited: false, consecutiveFailures: 5, busy: true,
unhealthyBusySince: NOW - 1_000,
})).toEqual({ action: 'wait', reason: 'busy-grace' });
});
it('force-restarts a busy backend after the stale-busy grace', () => {
expect(decideRestart({
...base, processExited: false, consecutiveFailures: 5, busy: true,
unhealthyBusySince: NOW - 120_001,
})).toEqual({ action: 'restart', reason: 'stale-busy-grace' });
});
it('waits (busy-grace) when busy + threshold but the window just started', () => {
// unhealthyBusySince === 0 means the caller is about to stamp it this cycle.
expect(decideRestart({
...base, processExited: false, consecutiveFailures: 5, busy: true,
unhealthyBusySince: 0,
})).toEqual({ action: 'wait', reason: 'busy-grace' });
});
});
describe('selectOrphanWorktreeTargets (3.4)', () => {
it('skips dirs tracked by a live worktrees row', () => {
const onDisk = [{ path: '/wt/sess-a', mtimeMs: NOW - 10_000_000 }];
expect(selectOrphanWorktreeTargets(onDisk, new Set(['/wt/sess-a']), NOW, 1000)).toEqual([]);
});
it('reaps an untracked dir older than the grace', () => {
const onDisk = [{ path: '/wt/sess-orphan', mtimeMs: NOW - 5000 }];
expect(selectOrphanWorktreeTargets(onDisk, new Set(), NOW, 1000)).toEqual(['/wt/sess-orphan']);
});
it('never reaps a dir younger than the grace (mid-create race)', () => {
const onDisk = [{ path: '/wt/sess-fresh', mtimeMs: NOW - 500 }];
expect(selectOrphanWorktreeTargets(onDisk, new Set(), NOW, 1000)).toEqual([]);
});
it('mixes tracked, fresh, and orphaned correctly', () => {
const onDisk = [
{ path: '/wt/sess-live', mtimeMs: NOW - 10_000 },
{ path: '/wt/sess-fresh', mtimeMs: NOW - 100 },
{ path: '/wt/sess-orphan', mtimeMs: NOW - 10_000 },
];
expect(selectOrphanWorktreeTargets(onDisk, new Set(['/wt/sess-live']), NOW, 1000))
.toEqual(['/wt/sess-orphan']);
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { stepEndedToUsage } from '../opencode-usage.js';
describe('stepEndedToUsage (U.6)', () => {
it('folds cache read+write into input and reasoning into output', () => {
const u = stepEndedToUsage({
cost: 0.0123,
tokens: { input: 100, output: 50, reasoning: 20, cache: { read: 10, write: 5 } },
});
expect(u).toEqual({ input: 115, output: 70, cost: 0.0123 });
});
it('handles a step with no cache and no reasoning', () => {
const u = stepEndedToUsage({
cost: 0,
tokens: { input: 8, output: 4, reasoning: 0, cache: { read: 0, write: 0 } },
});
expect(u).toEqual({ input: 8, output: 4, cost: 0 });
});
it('is defensive against a missing tokens block', () => {
const u = stepEndedToUsage({ cost: 0.5 } as never);
expect(u).toEqual({ input: 0, output: 0, cost: 0.5 });
});
it('is defensive against undefined props', () => {
expect(stepEndedToUsage(undefined)).toEqual({ input: 0, output: 0, cost: 0 });
});
it('drops NaN / negative noise to zero rather than poisoning the accumulated total', () => {
const u = stepEndedToUsage({
cost: Number.NaN,
tokens: {
input: -5,
output: Number.NaN,
reasoning: 3,
cache: { read: Number.POSITIVE_INFINITY, write: 2 },
},
});
// input: (-5→0) + (Inf→0) + 2 = 2; output: (NaN→0) + 3 = 3; cost: NaN→0
expect(u).toEqual({ input: 2, output: 3, cost: 0 });
});
it('rounds fractional token counts', () => {
const u = stepEndedToUsage({
cost: 1.5,
tokens: { input: 10.6, output: 4.4, reasoning: 0, cache: { read: 0, write: 0 } },
});
expect(u).toEqual({ input: 11, output: 4, cost: 1.5 });
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { shouldUseWarmBackend, isTurnOkForStopReason } from '../warm-acp-routing.js';
/**
* Phase 2 routing predicate: which goose/qwen tasks go to the warm pool backend
* vs the existing one-shot ACP path.
*
* The warm backend is keyed (chat_id, agent) — the persistent context unit (same
* as opencode-server). A task only routes warm when it carries BOTH a session_id
* and a chat_id, i.e. it originates from a real chat tab (the coder message route
* stamps both). Session-less creators (arena, MCP-created, generic /api/tasks,
* new_task) lack chat_id/session_id and keep the one-shot worktree-per-task path,
* which never spawns a warm process.
*/
describe('shouldUseWarmBackend (Phase 2 routing)', () => {
it('routes a chat-tab task (session_id + chat_id) to the warm backend', () => {
expect(shouldUseWarmBackend({ agent: 'qwen', session_id: 's1', chat_id: 'c1' })).toBe(true);
expect(shouldUseWarmBackend({ agent: 'goose', session_id: 's1', chat_id: 'c1' })).toBe(true);
});
it('keeps a session-less arena/MCP task on the one-shot path', () => {
expect(shouldUseWarmBackend({ agent: 'qwen', session_id: null, chat_id: null })).toBe(false);
});
it('keeps a task with a session but no chat on the one-shot path', () => {
// chat_id is the warm-key half; without it ensureSession would get a degenerate
// (null, agent) key, so fall back to one-shot rather than synthesize a chat.
expect(shouldUseWarmBackend({ agent: 'goose', session_id: 's1', chat_id: null })).toBe(false);
});
it('keeps a task with a chat but no session on the one-shot path', () => {
expect(shouldUseWarmBackend({ agent: 'qwen', session_id: null, chat_id: 'c1' })).toBe(false);
});
it('only applies to warm-capable agents (goose, qwen); others never warm here', () => {
// opencode has its own dedicated warm path; native/claude/etc. are not ACP-warm.
expect(shouldUseWarmBackend({ agent: 'opencode', session_id: 's1', chat_id: 'c1' })).toBe(false);
expect(shouldUseWarmBackend({ agent: 'claude', session_id: 's1', chat_id: 'c1' })).toBe(false);
expect(shouldUseWarmBackend({ agent: null, session_id: 's1', chat_id: 'c1' })).toBe(false);
});
});
describe('isTurnOkForStopReason (ACP stop-reason → ok/fail)', () => {
it('treats normal completions as ok', () => {
expect(isTurnOkForStopReason('end_turn')).toBe(true);
expect(isTurnOkForStopReason('max_tokens')).toBe(true);
expect(isTurnOkForStopReason('max_turn_requests')).toBe(true);
});
it('treats refusal and cancelled as failures', () => {
expect(isTurnOkForStopReason('refusal')).toBe(false);
expect(isTurnOkForStopReason('cancelled')).toBe(false);
});
it('defaults an absent stop reason to a successful end_turn', () => {
expect(isTurnOkForStopReason(undefined)).toBe(true);
expect(isTurnOkForStopReason(null)).toBe(true);
});
});

View File

@@ -0,0 +1,197 @@
/**
* v2.6 Phase 3 — pure lifecycle decision helpers.
*
* The eviction / LRU-cap / busy-aware-restart / reaper-target logic, factored out
* of AgentPool + the backends + the periodic sweeper so it's unit-testable with no
* DB, no child processes, no timers (modeled on
* apps/server/src/services/inference/prune.ts:selectPruneTargets — a pure decision
* core the caller acts on).
*
* Three decisions live here:
* 1. selectIdleEvictionTargets — which warm backends to evict for being idle.
* 2. selectLruEvictionTargets — which warm backends to evict to honour a max-live
* cap (least-recently-used beyond the cap), NEVER a busy one.
* 3. shouldRestartCrashedBackend (busy-aware) — openchamber's skip-while-busy +
* stale-grace state machine, re-implemented for BooCode's per-(chat,agent) pool.
*
* "Busy" = the backend has an in-flight turn. The hard rule (design §6, decisions):
* never evict or force-restart a busy backend; defer with a stale-grace.
*/
// ─── Idle TTL eviction (3.1) ─────────────────────────────────────────────────
/** Default idle TTL before a warm backend/session is evicted (design §6 ~30 min). */
export const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000;
/** A pool entry as the decision helpers see it (no backend internals). */
export interface PoolEntrySnapshot {
/** Pool key `${primary}:${agent}` — opaque to the decision, used for selection. */
key: string;
/** Epoch ms of the last turn activity (start or settle) on this backend. */
lastActiveAt: number;
/** True iff a turn is in flight right now. Busy entries are never evicted. */
busy: boolean;
}
/**
* Idle eviction: an entry is evictable when it has been idle (no turn) for longer
* than `ttlMs` AND is not currently busy. Returns the keys to evict.
*
* Pure: `now` is injected so tests don't depend on wall-clock. Busy entries are
* categorically excluded — a long-running turn that exceeds the TTL must NOT be
* torn down mid-stream (the §6 / openchamber busy rule).
*/
export function selectIdleEvictionTargets(
entries: ReadonlyArray<PoolEntrySnapshot>,
now: number,
ttlMs: number = DEFAULT_IDLE_TTL_MS,
): string[] {
const out: string[] = [];
for (const e of entries) {
if (e.busy) continue;
if (now - e.lastActiveAt >= ttlMs) out.push(e.key);
}
return out;
}
// ─── LRU cap (3.4) ───────────────────────────────────────────────────────────
/** Default max live warm backends/worktrees before the LRU cap evicts (env-overridable). */
export const DEFAULT_MAX_LIVE_BACKENDS = 10;
/**
* LRU cap: when more than `cap` non-busy entries are live, evict the
* least-recently-used ones (oldest `lastActiveAt` first) until at most `cap`
* remain. Busy entries are never evicted AND are not counted toward the cap's
* "kept" budget being freed — i.e. we only ever evict idle entries, so a burst of
* concurrent busy turns can transiently exceed the cap rather than kill live work.
*
* Returns the keys to evict, least-recently-used first. Pure / deterministic:
* ties broken by key for stable test output.
*/
export function selectLruEvictionTargets(
entries: ReadonlyArray<PoolEntrySnapshot>,
cap: number = DEFAULT_MAX_LIVE_BACKENDS,
): string[] {
if (cap < 0) cap = 0;
if (entries.length <= cap) return [];
// Only idle entries are eligible to be evicted.
const evictable = entries
.filter((e) => !e.busy)
.sort((a, b) => a.lastActiveAt - b.lastActiveAt || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
// We must shrink total live count down to `cap`. Busy entries can't be evicted,
// so the number we CAN remove is bounded by the evictable pool; evict the oldest
// (total - cap) of them, never more than exist.
const overBy = entries.length - cap;
const toEvict = evictable.slice(0, Math.max(0, overBy));
return toEvict.map((e) => e.key);
}
// ─── Busy-aware crash restart (3.2) — openchamber lift ───────────────────────
/**
* Default grace after which a backend that has stayed unhealthy WHILE busy is
* force-restarted anyway (openchamber's STALE_BUSY_GRACE_MS = 2 min). Guards
* against a permanently-stuck "busy" turn wedging recovery forever.
*/
export const DEFAULT_STALE_BUSY_GRACE_MS = 2 * 60 * 1000;
/** Default consecutive health-check failures before a restart is attempted. */
export const DEFAULT_HEALTH_FAILURE_THRESHOLD = 3;
export interface RestartDecisionInput {
/** True iff the process is actually dead (exited). A dead process restarts
* immediately regardless of busy/threshold — there's nothing to protect. */
processExited: boolean;
/** Consecutive failed health probes so far (including the current one). */
consecutiveFailures: number;
/** Whether the backend currently has an in-flight turn. */
busy: boolean;
/** Epoch ms when the unhealthy-while-busy window started, or 0 if not in one. */
unhealthyBusySince: number;
/** Injected clock. */
now: number;
failureThreshold?: number;
staleBusyGraceMs?: number;
}
export type RestartDecision =
| { action: 'restart'; reason: 'process-exited' | 'threshold' | 'stale-busy-grace' }
| { action: 'wait'; reason: 'below-threshold' | 'busy-grace' }
| { action: 'none'; reason: 'healthy' };
/**
* Decide whether to restart a backend after a health probe. Mirrors
* openchamber's `runHealthCheckCycle` + `shouldSkipRestartForBusySessions`,
* re-implemented as a pure function over injected state (the caller owns the
* mutable counters + the actual restart side-effect).
*
* Order (matches openchamber):
* - process exited → restart now (nothing live to protect).
* - below failure threshold → wait (transient blip; the next probe re-checks).
* - threshold reached + idle → restart now.
* - threshold reached + busy → skip UNLESS the unhealthy-busy window exceeded
* the stale grace, then force restart.
*
* `healthy: true` callers don't reach here; included for completeness so the
* caller can pass through and reset counters on a single code path.
*/
export function decideRestart(input: RestartDecisionInput & { healthy?: boolean }): RestartDecision {
if (input.healthy) return { action: 'none', reason: 'healthy' };
if (input.processExited) return { action: 'restart', reason: 'process-exited' };
const threshold = input.failureThreshold ?? DEFAULT_HEALTH_FAILURE_THRESHOLD;
if (input.consecutiveFailures < threshold) {
return { action: 'wait', reason: 'below-threshold' };
}
if (!input.busy) {
return { action: 'restart', reason: 'threshold' };
}
// Busy + unhealthy at/over threshold: defer, but not forever.
const grace = input.staleBusyGraceMs ?? DEFAULT_STALE_BUSY_GRACE_MS;
if (input.unhealthyBusySince > 0 && input.now - input.unhealthyBusySince >= grace) {
return { action: 'restart', reason: 'stale-busy-grace' };
}
return { action: 'wait', reason: 'busy-grace' };
}
// ─── Orphan worktree reaper target selection (3.4) ───────────────────────────
/** Default TTL: an on-disk worktree dir with no live `worktrees` row is reaped
* only after it's been orphaned at least this long (mtime-based grace so a
* just-created dir mid-`ensureSessionWorktree` race is never swept). */
export const DEFAULT_ORPHAN_WORKTREE_GRACE_MS = 60 * 60 * 1000; // 1h
export interface OnDiskWorktree {
/** Absolute path of the worktree dir on disk. */
path: string;
/** Last-modified epoch ms of the dir (newest of dir + contents, caller's choice). */
mtimeMs: number;
}
/**
* Reaper target selection: which on-disk worktree dirs are orphans safe to
* inspect-and-reap. An orphan is a dir under the worktree base that has NO live
* `worktrees` row (path not in `liveWorktreePaths`) AND whose mtime is older than
* the grace window (so an in-flight create isn't swept).
*
* Pure — the caller (the sweeper) then runs the at-risk preflight (dirty/unpushed)
* on each returned path and only physically removes the SAFE ones. This helper
* never decides to remove work-at-risk; it only narrows the candidate set.
*/
export function selectOrphanWorktreeTargets(
onDisk: ReadonlyArray<OnDiskWorktree>,
liveWorktreePaths: ReadonlySet<string>,
now: number,
graceMs: number = DEFAULT_ORPHAN_WORKTREE_GRACE_MS,
): string[] {
const out: string[] = [];
for (const w of onDisk) {
if (liveWorktreePaths.has(w.path)) continue; // tracked → not an orphan
if (now - w.mtimeMs < graceMs) continue; // too fresh → could be mid-create
out.push(w.path);
}
return out;
}

View File

@@ -21,9 +21,9 @@
* - promptAsync is fire-and-forget (204); the turn completes via a * - promptAsync is fire-and-forget (204); the turn completes via a
* 'session.idle' event for that opencode session id. * 'session.idle' event for that opencode session id.
*/ */
import { spawn, type ChildProcess } from 'node:child_process'; import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { createServer } from 'node:net'; import { createServer, connect as netConnect } from 'node:net';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { import {
createOpencodeClient, createOpencodeClient,
@@ -38,6 +38,8 @@ import type { ToolCallStatus } from '@agentclientprotocol/sdk';
import type { Sql } from '../../db.js'; import type { Sql } from '../../db.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js'; import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
import { armAbortGuard, noteTurnActivity, consumeTerminal } from './turn-guard.js'; import { armAbortGuard, noteTurnActivity, consumeTerminal } from './turn-guard.js';
import { stepEndedToUsage, type StepUsage } from './opencode-usage.js';
import { decideRestart, DEFAULT_HEALTH_FAILURE_THRESHOLD } from './lifecycle-decisions.js';
import type { import type {
AgentBackend, AgentBackend,
AgentEvent, AgentEvent,
@@ -103,6 +105,11 @@ export class OpenCodeServerBackend implements AgentBackend {
private port: number | null = null; private port: number | null = null;
private up = false; private up = false;
private serverStarting: Promise<void> | null = null; private serverStarting: Promise<void> | null = null;
// Phase 3 busy-aware health monitor (openchamber lift): consecutive failed
// probes + the start of an unhealthy-while-busy window feed `decideRestart`.
private consecutiveHealthFailures = 0;
private unhealthyBusySince = 0;
private restarting: Promise<void> | null = null;
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */ /** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
private readonly byOpencodeId = new Map<string, SessionState>(); private readonly byOpencodeId = new Map<string, SessionState>();
@@ -118,11 +125,30 @@ export class OpenCodeServerBackend implements AgentBackend {
return this.up ? 'up' : 'down'; return this.up ? 'up' : 'down';
} }
// ─── Server lifecycle (1.2: spawn once + client + ready) ───────────────────── /** Phase 3: busy iff ANY pooled opencode session has an in-flight turn. The
* pool reads this to skip idle/LRU eviction and the health-monitor to defer a
* restart (never tear down a session mid-stream). */
isBusy(): boolean {
for (const st of this.byOpencodeId.values()) {
if (st.activeTurn) return true;
}
return false;
}
/** Lazy: start the single server on first use. Idempotent — one server per backend. */ // ─── Server lifecycle (1.2: spawn once + client + ready; Phase 3 crash-restart) ──
/**
* Lazy: start the single server on first use; re-spawn after a crash. Idempotent
* within one live server — `serverStarting` caches the in-flight start, and is
* reset to null by the crash handler so the NEXT ensureServer re-spawns a fresh
* server (Phase 3 crash recovery). A dead-but-not-yet-reaped child (exit handler
* raced) is also treated as needing a restart.
*/
private ensureServer(): Promise<void> { private ensureServer(): Promise<void> {
if (!this.serverStarting) this.serverStarting = this.startServer(); const childDead = this.child != null && (this.child.exitCode !== null || this.child.signalCode !== null);
if (!this.serverStarting || (!this.up && childDead)) {
this.serverStarting = this.startServer();
}
return this.serverStarting; return this.serverStarting;
} }
@@ -142,11 +168,15 @@ export class OpenCodeServerBackend implements AgentBackend {
this.port = port; this.port = port;
// Child lifetime is the backend's (the pool's), NOT a request's. We never tie // Child lifetime is the backend's (the pool's), NOT a request's. We never tie
// it to a per-turn abort signal. On unexpected exit we mark down + log; crash // it to a per-turn abort signal. Phase 3: on unexpected exit we recover —
// recovery is Phase 3. // settle any in-flight turns as failed, mark their agent_sessions rows crashed,
// and reset `serverStarting` so the next ensureServer re-spawns. opencode keeps
// sessions on disk, but a fresh server's in-memory state is gone, so the next
// turn's ensureSession (rows now 'crashed') creates fresh opencode sessions.
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {
this.up = false; // Only react to THIS child's exit (a restart may have swapped in a new one).
this.log.warn({ code, signal, port }, 'opencode-server: child exited (recovery is Phase 3)'); if (this.child !== child) return;
this.handleServerCrash(code, signal, port);
}); });
await waitForReady(child, READY_TIMEOUT_MS); await waitForReady(child, READY_TIMEOUT_MS);
@@ -156,6 +186,136 @@ export class OpenCodeServerBackend implements AgentBackend {
this.log.info({ port }, 'opencode-server: ready'); this.log.info({ port }, 'opencode-server: ready');
} }
/**
* Crash handler (Phase 3, lift of openchamber's restart-on-exit path). The
* server died with N live opencode sessions; we can't restart it here (the next
* turn does, lazily — avoids a restart storm if the binary is broken). We:
* 1. fail every in-flight turn so its dispatcher unblocks + publishes an error,
* 2. mark each session's agent_sessions row 'crashed' so ensureSession won't
* resume a now-dead native session id (it creates fresh),
* 3. tear down the SSE loops + demux state (stale against the dead server),
* 4. reclaim the port + reset state so the next ensureServer re-spawns.
*/
private handleServerCrash(code: number | null, signal: NodeJS.Signals | null, port: number): void {
this.up = false;
const states = [...this.byOpencodeId.values()];
this.log.warn(
{ code, signal, port, liveSessions: states.length },
'opencode-server: child exited — recovering (fail in-flight, mark crashed, re-spawn next turn)',
);
const crashedIds: string[] = [];
for (const st of states) {
st.sseAbort?.abort();
if (st.activeTurn) {
st.activeTurn.settle({ ok: false, error: 'opencode server crashed mid-turn' });
st.activeTurn = null;
}
if (st.watchdog) {
clearTimeout(st.watchdog);
st.watchdog = null;
}
crashedIds.push(st.agentSessionId);
}
// Drop the demux map: every session id is stale against a fresh server.
this.byOpencodeId.clear();
this.client = null;
this.serverStarting = null; // force a re-spawn on the next ensureServer
if (crashedIds.length > 0) {
this.sql`
UPDATE agent_sessions SET status = 'crashed'
WHERE agent_session_id = ANY(${crashedIds}) AND status <> 'closed'
`.catch((err) => {
this.log.warn({ err: errMsg(err) }, 'opencode-server: failed to mark crashed sessions (non-fatal)');
});
}
// Reclaim the port so a re-spawn on a fixed/leaked port isn't blocked. Best
// effort; the next start uses a fresh ephemeral port anyway.
reclaimPort(port);
}
/**
* Phase 3 proactive health monitor (openchamber `runHealthCheckCycle` lift,
* busy-aware). Probes the server's /global/health; on a sustained failure of a
* NON-busy server, force a restart so the next turn isn't blocked by a wedged
* (hung-but-not-exited) process. Busy servers are deferred via the stale-grace in
* `decideRestart` — never tear down live work. Driven by the pool's periodic
* sweep (best-effort; a crash-exit is already handled by `handleServerCrash` +
* lazy `ensureServer` re-spawn, so this only catches the hung case). No-op when
* the server was never started or a restart is already in flight.
*/
async tickHealth(now: number = Date.now()): Promise<void> {
if (!this.child || this.restarting) return;
const childExited = this.child.exitCode !== null || this.child.signalCode !== null;
// An exited child is recovered lazily by ensureServer; don't double-restart it.
if (childExited) return;
const healthy = await this.probeHealth();
if (healthy) {
this.consecutiveHealthFailures = 0;
this.unhealthyBusySince = 0;
return;
}
this.consecutiveHealthFailures += 1;
const busy = this.isBusy();
const decision = decideRestart({
processExited: false,
consecutiveFailures: this.consecutiveHealthFailures,
busy,
unhealthyBusySince: this.unhealthyBusySince,
now,
failureThreshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
});
// Stamp the start of an unhealthy-while-busy window so the stale-grace can fire.
if (busy && this.unhealthyBusySince === 0) this.unhealthyBusySince = now;
if (decision.action === 'restart') {
this.log.warn(
{ failures: this.consecutiveHealthFailures, busy, reason: decision.reason },
'opencode-server: health monitor forcing restart',
);
this.consecutiveHealthFailures = 0;
this.unhealthyBusySince = 0;
await this.restartServer();
}
}
private async probeHealth(): Promise<boolean> {
if (!this.client) return false;
try {
const res = await this.client.global.health();
return !res.error;
} catch {
return false;
}
}
/** Force-kill the current server + reclaim its port; the next ensureServer
* re-spawns (lazy). Mirrors handleServerCrash's state reset but is initiated by
* the health monitor rather than the OS. */
private async restartServer(): Promise<void> {
if (this.restarting) return this.restarting;
this.restarting = (async () => {
const child = this.child;
const port = this.port;
this.up = false;
// Fail in-flight turns + mark sessions crashed via the same path as a crash.
if (child) {
this.handleServerCrash(null, null, port ?? 0);
if (!child.killed) child.kill('SIGTERM');
}
if (port) {
reclaimPort(port);
await waitForPortRelease(port, 3_000);
}
this.child = null;
})().finally(() => {
this.restarting = null;
});
return this.restarting;
}
// ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ─────────────────── // ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ───────────────────
/** Per-session SSE subscription, scoped to the session's worktree directory. /** Per-session SSE subscription, scoped to the session's worktree directory.
@@ -282,6 +442,19 @@ export class OpenCodeServerBackend implements AgentBackend {
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap }); st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
return; return;
} }
// ─── per-step usage (U.6) — token/cost accounting for opencode sessions ──
case 'session.next.step.ended': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
// Accumulate this step's normalized usage onto the (chat_id, agent) row.
// Fire-and-forget: a DB hiccup must not stall the turn. opencode emits this
// once per LLM step, so a multi-tool turn sums several deltas.
const usage = stepEndedToUsage(p);
void this.accumulateUsage(st, usage);
return;
}
// ─── message.part.* — terminal/post-hoc events (dedup gate) ──────────── // ─── message.part.* — terminal/post-hoc events (dedup gate) ────────────
case 'message.part.delta': { case 'message.part.delta': {
const p = ev.properties; const p = ev.properties;
@@ -428,6 +601,33 @@ export class OpenCodeServerBackend implements AgentBackend {
} }
} }
// ─── per-step usage persistence (U.6) ────────────────────────────────────────
/**
* Accumulate one `session.next.step.ended`'s normalized usage onto the session's
* agent_sessions row, keyed by the resumed `agent_session_id` (unique per active
* row — the dispatcher's `(chat_id, agent)` lookup wrote it). Running totals for
* the whole conversation context (not last-step). Zero-delta steps are skipped to
* avoid a no-op write. Errors are swallowed: usage telemetry must never fail a turn.
*/
private async accumulateUsage(st: SessionState, u: StepUsage): Promise<void> {
if (u.input === 0 && u.output === 0 && u.cost === 0) return;
try {
await this.sql`
UPDATE agent_sessions SET
input_tokens = input_tokens + ${u.input},
output_tokens = output_tokens + ${u.output},
cost = cost + ${u.cost}
WHERE agent_session_id = ${st.agentSessionId}
`;
} catch (err) {
this.log.warn(
{ err: errMsg(err), agentSessionId: st.agentSessionId },
'opencode-server: failed to persist step usage (non-fatal)',
);
}
}
// ─── ensureSession: create-or-resume against agent_sessions (1.5) ──────────── // ─── ensureSession: create-or-resume against agent_sessions (1.5) ────────────
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> { async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
@@ -715,6 +915,67 @@ function mapToolStatus(s: ToolState['status'] | undefined): ToolCallStatus | nul
} }
} }
/**
* Reclaim a loopback port a dead opencode child may still hold (lift of
* openchamber `killProcessOnPort`). Best-effort, POSIX-only (`lsof`/`kill`); a
* failure is harmless because the next spawn allocates a fresh ephemeral port.
* Never kills this process. Synchronous + short-timeout so the crash handler
* doesn't block.
*/
function reclaimPort(port: number | null): void {
if (!port || process.platform === 'win32') return;
try {
const res = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 3_000, windowsHide: true });
const out = res.stdout || '';
const myPid = process.pid;
for (const pidStr of out.split(/\s+/)) {
const pid = parseInt(pidStr.trim(), 10);
if (pid && pid !== myPid) {
try {
spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2_000 });
} catch {
// ignore — best effort
}
}
}
} catch {
// lsof absent or failed — the fresh-ephemeral-port spawn doesn't need this.
}
}
/**
* Resolve true once nothing is listening on `port` (lift of openchamber
* `waitForPortRelease`). Used before re-spawning on a fixed port; with ephemeral
* ports it's a fast no-op. Probes 127.0.0.1; resolves false at the deadline.
*/
function waitForPortRelease(port: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const attempt = () => {
const socket = netConnect({ port, host: '127.0.0.1' });
let settled = false;
const finish = (released: boolean) => {
if (settled) return;
settled = true;
socket.removeAllListeners();
socket.destroy();
if (released || Date.now() >= deadline) {
resolve(released);
return;
}
setTimeout(attempt, 150);
};
socket.once('connect', () => finish(false));
socket.once('error', (err: NodeJS.ErrnoException) => {
if (err && (err.code === 'ECONNREFUSED' || err.code === 'EHOSTUNREACH')) finish(true);
else finish(false);
});
socket.setTimeout(500, () => finish(true));
};
attempt();
});
}
/** Bind-probe an ephemeral port on loopback. */ /** Bind-probe an ephemeral port on loopback. */
function freePort(): Promise<number> { function freePort(): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -0,0 +1,77 @@
/**
* v2.6 Phase 1-UX (U.6) — pure mapper for opencode's per-step usage event.
*
* opencode's warm server emits `session.next.step.ended` once per completed LLM
* step (so a multi-tool turn fires it several times). Its `properties` carry the
* step's token + cost accounting:
*
* {
* timestamp: number;
* sessionID: string;
* finish: string;
* cost: number; // USD for this step
* tokens: {
* input: number; output: number; reasoning: number;
* cache: { read: number; write: number };
* };
* snapshot?: string;
* }
*
* (Verified against @opencode-ai/sdk@1.15.12 — `EventSessionNextStepEnded` in
* `dist/v2/gen/types.gen.d.ts`, a member of the `Event` union the SSE loop
* switches on.)
*
* We normalize to the review's target slice `{input, output, cost}` (the
* provider-agnostic `AgentUsage` shape lands later). cache read/write tokens are
* folded into `input` so the persisted input count reflects the real context the
* model billed for; reasoning tokens are folded into `output` since that's what
* the provider counts them as for generation. This keeps the persisted totals a
* faithful sum of what opencode reported, without inventing extra columns yet.
*/
/** The `properties` shape of a `session.next.step.ended` event (subset we read). */
export interface StepEndedProps {
cost: number;
tokens: {
input: number;
output: number;
reasoning: number;
cache: { read: number; write: number };
};
}
/** Normalized per-step usage delta persisted onto the agent_sessions row. */
export interface StepUsage {
input: number;
output: number;
cost: number;
}
/** Coerce a possibly-missing/NaN number to a non-negative finite integer (tokens). */
function n(v: unknown): number {
const x = typeof v === 'number' ? v : Number(v);
return Number.isFinite(x) && x > 0 ? Math.round(x) : 0;
}
/** Coerce a possibly-missing/NaN number to a non-negative finite float (cost USD). */
function f(v: unknown): number {
const x = typeof v === 'number' ? v : Number(v);
return Number.isFinite(x) && x > 0 ? x : 0;
}
/**
* Map a `session.next.step.ended` payload → the normalized `{input, output, cost}`
* delta. Defensive against missing/partial token blocks (the wire is trusted but
* we never want a NaN to poison the accumulated DB total). `input` folds in cache
* read+write; `output` folds in reasoning.
*/
export function stepEndedToUsage(props: Partial<StepEndedProps> | undefined): StepUsage {
const t = props?.tokens;
const cacheRead = n(t?.cache?.read);
const cacheWrite = n(t?.cache?.write);
return {
input: n(t?.input) + cacheRead + cacheWrite,
output: n(t?.output) + n(t?.reasoning),
cost: f(props?.cost),
};
}

View File

@@ -0,0 +1,41 @@
/**
* v2.6 Phase 2 — warm-vs-one-shot routing predicate for goose/qwen.
*
* The warm ACP backend keys its persistent process + ACP session on (chat_id,
* agent) — exactly like the opencode-server backend. A task therefore only routes
* to the warm pool when it carries BOTH a `session_id` and a `chat_id`, i.e. it
* came from a real chat tab (the coder message route + skills route stamp both).
*
* Session-less creators — arena contestants, MCP-created tasks, generic
* `POST /api/tasks`, `new_task` — leave one or both null. Those keep the existing
* one-shot worktree-per-task ACP path (`runExternalAgent`), which spawns a fresh
* `goose acp` / `qwen --acp` per turn and never holds a warm process. Routing them
* warm would either synthesize a degenerate (null, agent) key or create a chat per
* arena contestant — neither is wanted, so they stay one-shot.
*
* Pure, so it's unit-testable; the dispatcher consumes it.
*/
const WARM_CAPABLE_AGENTS = new Set(['goose', 'qwen']);
export function shouldUseWarmBackend(task: {
agent: string | null;
session_id: string | null;
chat_id: string | null;
}): boolean {
if (!task.agent || !WARM_CAPABLE_AGENTS.has(task.agent)) return false;
return task.session_id != null && task.chat_id != null;
}
/**
* Map an ACP prompt `stopReason` to the backend's ok/fail contract (TurnResult.ok).
*
* ACP's `StopReason` union includes normal completions (`end_turn`, `max_tokens`,
* `max_turn_requests`) and abnormal ones (`refusal`, `cancelled`). Only the latter
* two read as a failed turn; everything else (including an undefined/absent reason,
* which we default to `end_turn`) is a successful completion. Pure so it's testable
* independently of the warm process.
*/
export function isTurnOkForStopReason(stopReason: string | null | undefined): boolean {
const reason = stopReason ?? 'end_turn';
return reason !== 'refusal' && reason !== 'cancelled';
}

View File

@@ -0,0 +1,417 @@
/**
* v2.6 Phase 2 — WarmAcpBackend (goose, qwen).
*
* One persistent stdio process + ONE `ClientSideConnection` per (chat, agent),
* `initialize` + `session/new` done ONCE, reused across every turn — the warm
* analogue of the previous one-shot `acp-dispatch.ts` (which spawned/torn-down a
* fresh `goose acp` / `qwen --acp` per turn). Mirrors Paseo's `SpawnedACPProcess`.
*
* Implements the Phase 0 `AgentBackend` interface (same contract as
* `OpenCodeServerBackend`). Emits transport-agnostic `AgentEvent`s via the SHARED
* `mapSessionUpdate` (reused verbatim from the one-shot stack); the dispatcher maps
* those to WS frames + `persistExternalAgentTurn`, unchanged.
*
* Lifecycle decisions (design.md §2b / §10):
* - **Child lifetime is the pool's, not a request's.** Spawned once; never tied
* to a per-turn abort signal. Only the in-flight `prompt` gets `ctx.signal` —
* abort = ACP `session/cancel`, NOT killing the child.
* - **Per-turn abort** cancels the prompt on the warm connection so the SAME
* process serves the next turn.
* - **Crash** (child exit) marks `agent_sessions.status='crashed'` + logs; the
* next `ensureSession` re-spawns + re-`session/new` (Phase 3 hardens auto-restart).
* - **Resume across a process restart is NOT attempted in Phase 2.** goose ACP
* advertises no `loadSession`/`session.resume`; qwen does, but cross-restart
* resume is Phase 3. Within ONE live process the ACP session persists across
* turns (the whole point of "warm"); a restart re-`session/new` (memory loss
* across restart, accepted per §10). The agent's resume capabilities ARE
* probed and logged for forward-compat.
*
* Each WarmAcpBackend instance owns exactly one (chat, agent) — the dispatcher
* pools them under `agentPool.register(chatId, agent, backend)`.
*
* SDK note (@agentclientprotocol/sdk@^0.22.1, cross-checked against the design's
* `^0.14` worry): the resume method is the STABLE `resumeSession` (`session/resume`,
* gated by `agentCapabilities.sessionCapabilities.resume`), NOT the `^0.14`
* `unstable_resumeSession`. `loadSession` is gated by `agentCapabilities.loadSession`.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify';
import {
ClientSideConnection,
type Client,
type SessionNotification,
type RequestPermissionRequest,
type RequestPermissionResponse,
type ReadTextFileRequest,
type ReadTextFileResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
type CreateTerminalRequest,
type CreateTerminalResponse,
type CreateElicitationRequest,
type CreateElicitationResponse,
} from '@agentclientprotocol/sdk';
import type { Sql } from '../../db.js';
import { resolveLaunchSpec } from '../acp-spawn.js';
import { isTurnOkForStopReason } from './warm-acp-routing.js';
import { getResolvedRegistry, type ResolvedProviderDef } from '../provider-config-registry.js';
import { createAcpNdJsonStream } from '../acp-stream.js';
import { mapSessionUpdate } from '../acp-event-map.js';
import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js';
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from '../permission-waiter.js';
import { type AcpToolSnapshot, synthesizeCanceledSnapshots } from '../acp-tool-snapshot.js';
import type {
AgentBackend,
AgentEvent,
AgentSessionHandle,
EnsureSessionOpts,
PromptCtx,
TurnResult,
} from '../agent-backend.js';
/** State for one in-flight turn (only one at a time per backend — turns serialize). */
interface TurnState {
/** Per-turn task id, for routing permission prompts back to the UI. */
taskId: string | undefined;
/** BooCode session id for permission-waiter's broker frames. */
sessionId: string;
/** Per-turn mode id (autonomous-mode gate in permission-waiter). */
modeId: string | undefined;
onEvent: (e: AgentEvent) => void;
/** Tool-call snapshot accumulator for this turn — merge across tool_call_update. */
snapshots: Map<string, AcpToolSnapshot>;
}
export interface WarmAcpBackendDeps {
sql: Sql;
log: FastifyBaseLogger;
/** The (chat, agent) this backend serves — its pool identity + DB key. */
chatId: string;
agent: string;
/** Resolved binary for the agent (from available_agents.install_path), or null. */
installPath: string | null;
/** Optional override of the resolved registry def (defaults to a live lookup). */
resolved?: ResolvedProviderDef;
}
export class WarmAcpBackend implements AgentBackend {
readonly backend = 'acp_warm' as const;
private readonly sql: Sql;
private readonly log: FastifyBaseLogger;
private readonly chatId: string;
private readonly agent: string;
private readonly installPath: string | null;
private readonly resolvedOverride: ResolvedProviderDef | undefined;
private child: ChildProcess | null = null;
private connection: ClientSideConnection | null = null;
/** The single ACP session id for this warm process; null until session/new. */
private acpSessionId: string | null = null;
private up = false;
/** Idempotent spawn guard — one warm process per backend, started lazily. */
private starting: Promise<void> | null = null;
/** Resume capabilities probed at initialize, logged for forward-compat (Phase 3). */
private supportsLoadSession = false;
private supportsResumeSession = false;
/** The current in-flight turn; the Client closures read it. Null between turns. */
private activeTurn: TurnState | null = null;
constructor(deps: WarmAcpBackendDeps) {
this.sql = deps.sql;
this.log = deps.log;
this.chatId = deps.chatId;
this.agent = deps.agent;
this.installPath = deps.installPath;
this.resolvedOverride = deps.resolved;
}
/** §2: liveness for the health endpoint + dispatcher fallback decision. */
health(): 'up' | 'down' {
return this.up ? 'up' : 'down';
}
/** Phase 3: busy iff this backend's single session has an in-flight turn. The
* pool reads this to skip idle/LRU eviction (never kill the child mid-prompt). */
isBusy(): boolean {
return this.activeTurn != null;
}
// ─── warm-process lifecycle (2.1 spawn + initialize + session/new ONCE) ───────
/** Lazy: spawn the warm process on first use. Idempotent — one process per backend. */
private ensureProcess(worktreePath: string): Promise<void> {
if (this.up && this.connection && this.acpSessionId) return Promise.resolve();
if (!this.starting) {
this.starting = this.startProcess(worktreePath).catch((err) => {
// Reset so a later ensureSession can retry the spawn after a failed start.
this.starting = null;
throw err;
});
}
return this.starting;
}
private async startProcess(worktreePath: string): Promise<void> {
const resolved = this.resolvedOverride ?? getResolvedRegistry().get(this.agent);
const spec = resolved ? resolveLaunchSpec(resolved, this.installPath) : null;
if (!spec) throw new Error(`warm-acp: agent '${this.agent}' does not support ACP (no launch spec)`);
this.log.info({ agent: this.agent, chatId: this.chatId, binary: spec.binary, worktreePath }, 'warm-acp: spawning warm process');
// Child lifetime is the pool's. NOT tied to any per-turn abort signal — only
// the in-flight prompt is cancellable (via ACP session/cancel in prompt()).
const child = spawn(spec.binary, spec.args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...spec.env },
});
this.child = child;
// 2.3: supervise the child; react to its exit, never let a request scope kill it.
child.on('exit', (code, signal) => {
this.up = false;
this.connection = null;
this.acpSessionId = null;
this.starting = null;
this.log.warn({ agent: this.agent, chatId: this.chatId, code, signal }, 'warm-acp: warm process exited — marking crashed (rebuild on next turn)');
void this.markCrashed();
});
// A spawn error (e.g. ENOENT) surfaces here, not as an exit.
child.on('error', (err) => {
this.up = false;
this.log.error({ agent: this.agent, chatId: this.chatId, err: errMsg(err) }, 'warm-acp: warm process error');
});
const stream = createAcpNdJsonStream(child);
const connection = new ClientSideConnection(() => this.buildClient(worktreePath), stream);
const init = await connection.initialize({
protocolVersion: 1,
clientInfo: { name: 'boocoder', version: '2.6.0' },
clientCapabilities: {},
});
const caps = init.agentCapabilities;
this.supportsLoadSession = caps?.loadSession === true;
this.supportsResumeSession = caps?.sessionCapabilities?.resume != null;
const session = await connection.newSession({ cwd: worktreePath, mcpServers: [] });
this.connection = connection;
this.acpSessionId = session.sessionId;
this.up = true;
this.log.info(
{
agent: this.agent,
chatId: this.chatId,
acpSessionId: session.sessionId,
loadSession: this.supportsLoadSession,
resumeSession: this.supportsResumeSession,
},
'warm-acp: warm session ready',
);
}
/** Build the ACP Client callbacks ONCE per connection. They read `this.activeTurn`
* so each turn's events/permissions route to the right place — exactly the
* opencode-server `activeTurn` pattern. Worktree-scoped FS like AcpStreamContext. */
private buildClient(worktreePath: string): Client {
return {
sessionUpdate: async (params: SessionNotification): Promise<void> => {
const turn = this.activeTurn;
if (!turn) return; // between turns — drop (no orphan settles a future turn)
for (const event of mapSessionUpdate(params, turn.snapshots)) {
turn.onEvent(event);
}
},
requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
const turn = this.activeTurn;
if (turn?.taskId) {
// Route to the UI via the per-turn task id (same as the one-shot path).
return waitForPermissionResponse(turn.taskId, turn.sessionId, this.agent, turn.modeId, params);
}
const firstOption = params.options[0];
if (firstOption) return { outcome: { outcome: 'selected', optionId: firstOption.optionId } };
return { outcome: { outcome: 'cancelled' } };
},
readTextFile: async (params: ReadTextFileRequest): Promise<ReadTextFileResponse> => {
const content = await readWorktreeTextFile(worktreePath, params.path, params.line, params.limit);
return { content };
},
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
await writeWorktreeTextFile(worktreePath, params.path, params.content);
return {};
},
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
return { terminalId: 'noop' };
},
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
const turn = this.activeTurn;
if (turn?.taskId) {
return waitForElicitationResponse(turn.taskId, turn.sessionId, this.agent, turn.modeId, params);
}
return { action: 'decline' };
},
};
}
// ─── ensureSession: create-or-reuse the warm session (2.1) ───────────────────
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
await this.ensureProcess(opts.worktreePath);
if (!this.acpSessionId) throw new Error('warm-acp: session not ready after ensureProcess');
// P1.5-b: agent_sessions keys on (chat_id, agent). The ACP session id is the
// resume handle WITHIN the live process; across a process restart it's stale,
// so ensureProcess re-`session/new` and we upsert the fresh id here.
await this.sql`
INSERT INTO agent_sessions
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at)
VALUES
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'acp_warm', ${this.acpSessionId}, NULL, 'active', clock_timestamp())
ON CONFLICT (chat_id, agent) DO UPDATE SET
session_id = EXCLUDED.session_id,
worktree_id = EXCLUDED.worktree_id,
backend = 'acp_warm',
agent_session_id = EXCLUDED.agent_session_id,
server_port = NULL,
status = 'active',
last_active_at = clock_timestamp()
`.catch((err) => {
this.log.warn({ err: errMsg(err), chatId: opts.chatId, agent: opts.agent }, 'warm-acp: agent_sessions upsert failed (non-fatal)');
});
return {
sessionId,
agent: opts.agent,
backend: 'acp_warm',
chatId: opts.chatId,
worktreeId: opts.worktreeId,
agentSessionId: this.acpSessionId,
serverPort: null,
};
}
// ─── prompt: one turn on the warm connection (2.2) ───────────────────────────
async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> {
// The warm process may have crashed between ensureSession and here, or this
// backend was rebuilt — re-establish before prompting.
await this.ensureProcess(ctx.worktreePath);
const connection = this.connection;
const acpSessionId = this.acpSessionId;
if (!connection || !acpSessionId) {
return { ok: false, error: 'warm-acp: no live ACP connection' };
}
const snapshots = new Map<string, AcpToolSnapshot>();
// taskId routes permission/elicitation prompts back to the UI. The dispatcher
// passes it (plus mode) on the per-turn PromptCtx; permission-waiter keys on it.
const turn: TurnState = {
taskId: ctx.taskId,
sessionId: handle.sessionId,
modeId: ctx.modeId,
onEvent: ctx.onEvent,
snapshots,
};
this.activeTurn = turn;
// Per-turn abort: cancel the in-flight prompt on the SAME connection — never
// kill the child (that's the pool's lifetime). On cancel we also synthesize
// 'canceled' updates for any still-running tool calls so the UI doesn't leave
// them spinning (mirrors AcpStreamContext.markAborted).
let aborted = false;
const onAbort = () => {
if (aborted) return;
aborted = true;
connection.cancel({ sessionId: acpSessionId }).catch(() => {});
if (ctx.taskId) cancelPendingPermission(ctx.taskId);
for (const snap of synthesizeCanceledSnapshots(snapshots.values())) {
snapshots.set(snap.toolCallId, snap);
ctx.onEvent({ type: 'tool_update', toolCall: snap });
}
};
if (ctx.signal.aborted) {
this.activeTurn = null;
return { ok: false, error: 'aborted' };
}
ctx.signal.addEventListener('abort', onAbort, { once: true });
try {
const result = await connection.prompt({
sessionId: acpSessionId,
prompt: [{ type: 'text', text: input }],
});
if (aborted) return { ok: false, error: 'aborted' };
const stopReason = result.stopReason ?? 'end_turn';
return isTurnOkForStopReason(stopReason)
? { ok: true }
: { ok: false, error: `stop_reason: ${stopReason}` };
} catch (err) {
if (aborted) return { ok: false, error: 'aborted' };
return { ok: false, error: errMsg(err) };
} finally {
ctx.signal.removeEventListener('abort', onAbort);
this.activeTurn = null;
await this.sql`
UPDATE agent_sessions SET status = 'idle', last_active_at = clock_timestamp()
WHERE chat_id = ${this.chatId} AND agent = ${this.agent}
`.catch(() => {});
}
}
// ─── teardown ────────────────────────────────────────────────────────────────
async closeSession(handle: AgentSessionHandle): Promise<void> {
// Gracefully close the ACP session if the agent supports it; then kill the child.
if (this.connection && this.acpSessionId) {
await this.connection.closeSession({ sessionId: this.acpSessionId }).catch(() => {});
}
await this.killChild();
await this.sql`
UPDATE agent_sessions SET status = 'closed'
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
`.catch(() => {});
}
async dispose(): Promise<void> {
this.up = false;
this.activeTurn = null;
if (this.connection && this.acpSessionId) {
await this.connection.closeSession({ sessionId: this.acpSessionId }).catch(() => {});
}
await this.killChild();
this.connection = null;
this.acpSessionId = null;
this.starting = null;
}
private async killChild(): Promise<void> {
const child = this.child;
this.child = null;
if (!child || child.killed) return;
child.kill('SIGTERM');
await new Promise<void>((resolve) => {
const t = setTimeout(() => {
if (!child.killed) child.kill('SIGKILL');
resolve();
}, 5_000);
t.unref?.();
child.once('close', () => {
clearTimeout(t);
resolve();
});
});
}
private async markCrashed(): Promise<void> {
await this.sql`
UPDATE agent_sessions SET status = 'crashed'
WHERE chat_id = ${this.chatId} AND agent = ${this.agent}
`.catch(() => {});
}
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}

View File

@@ -12,8 +12,10 @@ import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
import { getManifestCommands } from './provider-commands.js'; import { getManifestCommands } from './provider-commands.js';
import { persistExternalAgentTurn } from './agent-turn-persist.js'; import { persistExternalAgentTurn } from './agent-turn-persist.js';
import { snapshotToWireToolCall, type AcpToolSnapshot } from './acp-tool-snapshot.js'; import { snapshotToWireToolCall, type AcpToolSnapshot } from './acp-tool-snapshot.js';
import { agentPool } from './agent-pool.js'; import { agentPool, OPENCODE_POOL_KEY } from './agent-pool.js';
import { OpenCodeServerBackend } from './backends/opencode-server.js'; import { OpenCodeServerBackend } from './backends/opencode-server.js';
import { WarmAcpBackend } from './backends/warm-acp.js';
import { shouldUseWarmBackend } from './backends/warm-acp-routing.js';
import type { AgentBackend, AgentEvent } from './agent-backend.js'; import type { AgentBackend, AgentEvent } from './agent-backend.js';
interface InferenceRunner { interface InferenceRunner {
@@ -121,10 +123,15 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent} SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
`; `;
if (agentRow) { if (agentRow) {
// v2.6 (1.7): opencode routes to the warm pool backend; every other // v2.6 (1.7): opencode routes to its warm HTTP-server backend.
// external agent keeps the existing one-shot ACP/PTY path untouched. // v2.6 Phase 2 (2.4): goose/qwen route to the warm ACP backend WHEN the
// task came from a real chat tab (session_id + chat_id) — shouldUseWarmBackend.
// Session-less creators (arena, MCP, new_task, generic /api/tasks) keep the
// existing one-shot worktree-per-task ACP/PTY path untouched.
if (task.agent === 'opencode') { if (task.agent === 'opencode') {
await runOpenCodeServerTask(task, agentRow.install_path); await runOpenCodeServerTask(task, agentRow.install_path);
} else if (shouldUseWarmBackend(task)) {
await runWarmAcpTask(task, agentRow.install_path);
} else { } else {
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path); await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
} }
@@ -441,10 +448,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const diff = await diffWorktree(worktreePath, projectPath, { signal: ac.signal }); const diff = await diffWorktree(worktreePath, projectPath, { signal: ac.signal });
if (diff) { if (diff) {
// Queue a single pending_change entry with the full unified diff // Queue a single pending_change entry with the full unified diff, stamped
// with the dispatched agent for DiffPanel attribution (v2.6 Phase 1-UX).
await sql` await sql`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}) VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}, ${agent})
`; `;
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff queued as pending change'); log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff queued as pending change');
} else { } else {
@@ -491,9 +499,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// OpenCode runs ONE server per BooCoder process, shared across all sessions // OpenCode runs ONE server per BooCoder process, shared across all sessions
// (the backend multiplexes sessions internally), so it's pooled under a fixed // (the backend multiplexes sessions internally), so it's pooled under a fixed
// key rather than per-session. Warm ACP backends (Phase 2) will be per-session. // key (OPENCODE_POOL_KEY, shared with the lifecycle close-hook) rather than
const OPENCODE_POOL_KEY = '__opencode_server__'; // per-session. Warm ACP backends (Phase 2) are per (chat, agent).
function getOpenCodeBackend(installPath: string | null): AgentBackend { function getOpenCodeBackend(installPath: string | null): AgentBackend {
let backend = agentPool.get(OPENCODE_POOL_KEY, 'opencode'); let backend = agentPool.get(OPENCODE_POOL_KEY, 'opencode');
if (!backend) { if (!backend) {
@@ -702,6 +709,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
signal: ac.signal, signal: ac.signal,
onEvent, onEvent,
}); });
// Phase 3: keep the pooled backend's slot warm across this (possibly long)
// turn so the idle sweep measures from turn END, not start.
agentPool.touch(OPENCODE_POOL_KEY, agent);
// Flush any text held back mid-tag at stream end (complete tags stripped). // Flush any text held back mid-tag at stream end (complete tags stripped).
const dcpTail = dcp.flush(); const dcpTail = dcp.flush();
@@ -787,6 +797,247 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
} }
} }
// ─── Path B (warm ACP): goose / qwen warm backend (v2.6 Phase 2) ─────────────
// Warm ACP backends are per (chat, agent): each owns ONE stdio process + ACP
// connection + session. Pool key = chatId; the AgentPool's secondary key is the
// agent. This mirrors agent_sessions' (chat_id, agent) PK.
function getWarmAcpBackend(chatId: string, agent: string, installPath: string | null): WarmAcpBackend {
let backend = agentPool.get(chatId, agent);
if (!backend) {
backend = new WarmAcpBackend({
sql,
log,
chatId,
agent,
installPath,
resolved: getResolvedRegistry().get(agent),
});
agentPool.register(chatId, agent, backend);
}
return backend as WarmAcpBackend;
}
async function runWarmAcpTask(
task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
chat_id: string | null;
},
installPath: string | null,
): Promise<void> {
const taskId = task.id;
const agent = task.agent!;
// shouldUseWarmBackend guarantees both non-null before we get here.
const sessionId = task.session_id!;
const chatId = task.chat_id!;
log.info({ taskId, agent, chatId }, 'dispatcher: starting task (path B — warm ACP)');
const [project] = await sql<{ path: string | null }[]>`
SELECT path FROM projects WHERE id = ${task.project_id}
`;
const projectPath = project?.path;
if (!projectPath) {
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
WHERE id = ${taskId}
`;
return;
}
const ac = new AbortController();
try {
await sql`
UPDATE tasks
SET state = 'running', started_at = clock_timestamp(), execution_path = 'acp'
WHERE id = ${taskId}
`;
// Persistent, session-keyed worktree (shared across turns + agents; NOT torn
// down per turn — Phase 3 reaps it). Same as the opencode-server path so a
// chat that switches opencode↔goose↔qwen shares one worktree.
const { worktreeId, worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
signal: ac.signal,
});
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready (warm ACP)');
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: assistantId,
chat_id: chatId,
role: 'assistant',
} as WsFrame);
const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands);
broker.publishFrame(sessionId, {
type: 'agent_commands',
task_id: taskId,
session_id: sessionId,
commands: manifestCommands,
} as WsFrame);
}
// Accumulate the turn's stream for persistence + the final message content.
const textChunks: string[] = [];
const reasoningChunks: string[] = [];
const toolSnaps = new Map<string, AcpToolSnapshot>();
// Map transport-agnostic AgentEvents → the SAME WS frames the one-shot ACP
// path emits (identical to runOpenCodeServerTask's onEvent). No dcp stripping:
// that's an opencode-plugin artifact; goose/qwen don't emit dcp tags.
const onEvent = (e: AgentEvent): void => {
switch (e.type) {
case 'text':
textChunks.push(e.text);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: e.text,
} as WsFrame);
break;
case 'reasoning':
reasoningChunks.push(e.text);
broker.publishFrame(sessionId, {
type: 'reasoning_delta',
message_id: assistantId,
chat_id: chatId,
content: e.text,
} as WsFrame);
break;
case 'tool_call':
case 'tool_update':
toolSnaps.set(e.toolCall.toolCallId, e.toolCall);
broker.publishFrame(sessionId, {
type: 'tool_call',
message_id: assistantId,
chat_id: chatId,
tool_call: snapshotToWireToolCall(e.toolCall),
} as WsFrame);
break;
case 'commands':
if (e.commands.length > 0) {
setTaskCommands(taskId, e.commands);
broker.publishFrame(sessionId, {
type: 'agent_commands',
task_id: taskId,
session_id: sessionId,
commands: e.commands,
} as WsFrame);
}
break;
}
};
const model = task.model ?? undefined;
const backend = getWarmAcpBackend(chatId, agent, installPath);
const handle = await backend.ensureSession(sessionId, {
agent,
model: model ?? '',
chatId,
worktreePath,
worktreeId,
projectId: task.project_id,
});
const result = await backend.prompt(handle, task.input, {
worktreePath,
model: model ?? '',
signal: ac.signal,
onEvent,
taskId,
modeId: task.mode_id ?? undefined,
});
// Phase 3: keep the pooled (chat,agent) backend warm across the turn.
agentPool.touch(chatId, agent);
const assistantContent = textChunks.join('').slice(0, 50_000);
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'warm ACP turn failed').slice(0, 500);
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
await sql`
UPDATE messages
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
WHERE id = ${assistantId}
`;
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: assistantId,
chat_id: chatId,
} as WsFrame);
if (stopping) {
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
return; // worktree persists (no cleanup); backend stays warm
}
// Diff the persistent worktree against its captured baseline and SUPERSEDE
// the session's prior pending row (latest-wins) — identical to opencode.
const diff = await diffWorktree(worktreePath, projectPath, {
signal: ac.signal,
baseRef: baseCommit ?? 'HEAD',
});
if (diff) {
await sql`
DELETE FROM pending_changes WHERE session_id = ${sessionId} AND status = 'pending'
`;
await sql`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}, ${agent})
`;
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff superseded prior pending change (warm ACP)');
} else {
log.info({ taskId }, 'dispatcher: no changes detected in session worktree (warm ACP)');
}
// NO worktree cleanup — persistent (Phase 3 reaps it). Backend stays warm.
const [extCostRow] = await sql<{ total: number | null }[]>`
SELECT SUM(tokens_used)::int AS total
FROM messages
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
`;
const extCostTokens = extCostRow?.total ?? null;
const finalState = result.ok ? 'completed' : 'failed';
await sql`
UPDATE tasks
SET state = ${finalState}, ended_at = clock_timestamp(), output_summary = ${outputSummary}, cost_tokens = ${extCostTokens}
WHERE id = ${taskId}
`;
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (warm ACP)');
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.error({ taskId, agent, err: errMsg }, 'dispatcher: warm ACP error');
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
async function waitForCompletion(assistantId: string): Promise<string> { async function waitForCompletion(assistantId: string): Promise<string> {

View File

@@ -0,0 +1,170 @@
/**
* v2.6 Phase 3 (3.4) — orphan worktree reaper.
*
* Reclaims on-disk session worktree dirs under WORKTREE_BASE that have NO live
* (`status='active'`) row in the `worktrees` table — leaks from a crash between
* `git worktree add` and the DB insert, a missed chat-close hook, or a manual rm
* of the DB row. Extends the periodic-sweeper pattern (apps/server's truncation +
* stale-streaming reaper).
*
* SAFETY (Paseo worktree-archive cascade + superset destroy-saga lift): before
* removing ANY dir, run `checkWorktreeWorkAtRisk` — a dirty / unpushed / unmerged
* worktree is SKIPPED (logged), never force-removed. The pure orphan-target
* selection (which dirs are candidates) lives in
* `backends/lifecycle-decisions.ts:selectOrphanWorktreeTargets` and is unit-tested;
* this module does the DB read + fs stat + git preflight + removal side-effects.
*
* The mtime grace (default 1h) means a dir mid-`ensureSessionWorktree` (created on
* disk, row not yet committed) is never swept — the grace window covers the gap.
*/
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import { WORKTREE_BASE, checkWorktreeWorkAtRisk } from './worktrees.js';
import { hostExec } from './host-exec.js';
import {
selectOrphanWorktreeTargets,
DEFAULT_ORPHAN_WORKTREE_GRACE_MS,
} from './backends/lifecycle-decisions.js';
export interface OrphanWorktreeReaperDeps {
sql: Sql;
log: FastifyBaseLogger;
intervalMs: number;
graceMs?: number;
}
export interface OrphanReaperResult {
scanned: number;
candidates: number;
reaped: string[];
skippedAtRisk: string[];
}
/** Single-pass reap: select orphan candidates, preflight at-risk, remove the safe. */
export async function reapOrphanWorktrees(
sql: Sql,
log: FastifyBaseLogger,
graceMs: number = DEFAULT_ORPHAN_WORKTREE_GRACE_MS,
now: number = Date.now(),
): Promise<OrphanReaperResult> {
// Enumerate on-disk session worktree dirs (`sess-*`). Per-task worktrees
// (arena/new_task/MCP) are cleaned up inline by the one-shot path, so we only
// own the persistent session dirs the warm paths leave behind.
let dirents: string[];
try {
dirents = await readdir(WORKTREE_BASE);
} catch {
return { scanned: 0, candidates: 0, reaped: [], skippedAtRisk: [] }; // base absent → nothing to do
}
const onDisk: { path: string; mtimeMs: number }[] = [];
for (const name of dirents) {
if (!name.startsWith('sess-')) continue; // only persistent session worktrees
const path = join(WORKTREE_BASE, name);
try {
const s = await stat(path);
if (!s.isDirectory()) continue;
onDisk.push({ path, mtimeMs: s.mtimeMs });
} catch {
// vanished between readdir and stat — skip
}
}
// Live worktree paths from the DB (active rows only — archived/removed rows are
// not "live", so their leftover dirs are reapable orphans).
const liveRows = await sql<{ path: string }[]>`
SELECT path FROM worktrees WHERE status = 'active'
`;
const live = new Set(liveRows.map((r) => r.path));
const candidates = selectOrphanWorktreeTargets(onDisk, live, now, graceMs);
const reaped: string[] = [];
const skippedAtRisk: string[] = [];
for (const path of candidates) {
// Preflight: never reap work at risk. A git error forces atRisk=true (fail
// closed), so a half-broken worktree is kept, not silently destroyed.
const risk = await checkWorktreeWorkAtRisk(path);
if (risk.atRisk) {
skippedAtRisk.push(path);
log.warn({ path, dirty: risk.dirty, unmerged: risk.unmerged, error: risk.error }, 'orphan-reaper: skipping at-risk orphan worktree');
continue;
}
const removed = await removeOrphanDir(path);
if (removed) reaped.push(path);
}
if (reaped.length > 0 || skippedAtRisk.length > 0) {
log.info({ scanned: onDisk.length, candidates: candidates.length, reaped, skippedAtRisk }, 'orphan-reaper: pass complete');
}
return { scanned: onDisk.length, candidates: candidates.length, reaped, skippedAtRisk };
}
/**
* Remove a single orphan worktree dir. Resolve its main repo via the git
* common-dir, run `worktree remove --force` from there + prune, then rm the dir as
* a backstop. Best-effort: every step is independently fault-tolerant so a partial
* state (dir present, git untracked) still gets reclaimed.
*/
async function removeOrphanDir(path: string): Promise<boolean> {
// Find the owning repo (the common git dir's parent). When the dir isn't a valid
// worktree anymore, this fails and we fall back to a plain rm.
const common = await hostExec(
`git -C ${shellEscape(path)} rev-parse --path-format=absolute --git-common-dir`,
{ timeoutMs: 10_000 },
).catch(() => null);
const commonDir = common && common.exitCode === 0 ? common.stdout.trim() : '';
// The repo worktree root is the parent of the .git common dir (strip trailing /.git).
const repoRoot = commonDir.replace(/\/\.git\/?$/, '').replace(/\/\.git$/, '');
if (repoRoot && repoRoot !== commonDir) {
await hostExec(
`git -C ${shellEscape(repoRoot)} worktree remove ${shellEscape(path)} --force`,
{ timeoutMs: 15_000 },
).catch(() => {});
await hostExec(
`git -C ${shellEscape(repoRoot)} worktree prune`,
{ timeoutMs: 10_000 },
).catch(() => {});
}
// Backstop: ensure the dir is gone even if the git remove no-op'd.
const rm = await hostExec(`rm -rf ${shellEscape(path)}`, { timeoutMs: 15_000 }).catch(() => null);
return rm != null && rm.exitCode === 0;
}
/** Minimal single-quote shell escape (mirrors worktrees.ts). */
function shellEscape(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
/** Periodic orphan-worktree reaper, started/stopped by the bootstrap. Unref'd. */
export function createOrphanWorktreeReaper(deps: OrphanWorktreeReaperDeps): { start(): void; stop(): void } {
const { sql, log, intervalMs } = deps;
const graceMs = deps.graceMs ?? DEFAULT_ORPHAN_WORKTREE_GRACE_MS;
let timer: ReturnType<typeof setInterval> | null = null;
let running = false;
return {
start() {
if (timer) return;
timer = setInterval(() => {
if (running) return; // a slow pass must not overlap the next tick
running = true;
void reapOrphanWorktrees(sql, log, graceMs)
.catch((err) => log.warn({ err: err instanceof Error ? err.message : String(err) }, 'orphan-reaper: pass error'))
.finally(() => {
running = false;
});
}, intervalMs);
timer.unref?.();
log.info({ intervalMs, graceMs }, 'orphan-reaper: started');
},
stop() {
if (timer) {
clearInterval(timer);
timer = null;
}
},
};
}

View File

@@ -13,6 +13,10 @@ export interface PendingChange {
operation: 'create' | 'edit' | 'delete'; operation: 'create' | 'edit' | 'delete';
diff: string; diff: string;
status: 'pending' | 'applied' | 'rejected' | 'reverted'; status: 'pending' | 'applied' | 'rejected' | 'reverted';
// v2.6 Phase 1-UX: which agent staged this change (DiffPanel attribution).
// Native boocode write tools stamp 'boocode'; the manual RightRail create path
// passes null (renders as "manual"). NULL on legacy rows queued pre-v2.6.
agent: string | null;
created_at: string; created_at: string;
} }
@@ -34,13 +38,17 @@ export async function queueEdit(
oldString: string, oldString: string,
newString: string, newString: string,
projectRoot: string, projectRoot: string,
// v2.6 Phase 1-UX: attribution. Defaults to 'boocode' because the only callers
// that omit it are the native write tools (edit_file/create_file/delete_file).
// Pass null explicitly for the manual RightRail create path.
agent: string | null = 'boocode',
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const diff = JSON.stringify({ old: oldString, new: newString }); const diff = JSON.stringify({ old: oldString, new: newString });
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
RETURNING * RETURNING *
`; `;
return row!; return row!;
@@ -53,12 +61,15 @@ export async function queueCreate(
filePath: string, filePath: string,
content: string, content: string,
projectRoot: string, projectRoot: string,
// See queueEdit: defaults to 'boocode' for the native write tools; the manual
// RightRail create route passes null.
agent: string | null = 'boocode',
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
RETURNING * RETURNING *
`; `;
return row!; return row!;
@@ -70,12 +81,14 @@ export async function queueDelete(
taskId: string | null, taskId: string | null,
filePath: string, filePath: string,
projectRoot: string, projectRoot: string,
// See queueEdit: defaults to 'boocode' for the native write tools.
agent: string | null = 'boocode',
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '') VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
RETURNING * RETURNING *
`; `;
return row!; return row!;

View File

@@ -9,7 +9,7 @@
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import { hostExec } from './host-exec.js'; import { hostExec } from './host-exec.js';
const WORKTREE_BASE = '/tmp/booworktrees'; export const WORKTREE_BASE = '/tmp/booworktrees';
/** /**
* Create a git worktree for a task on the host. * Create a git worktree for a task on the host.
@@ -197,6 +197,187 @@ export async function ensureSessionWorktree(
}; };
} }
/**
* v2.6 Phase 3 (3.3 / 3.4): physically remove a session's persistent worktree —
* the git worktree dir + its branch — and archive its `worktrees` row. Used by the
* chat/session-close hook (when the last chat in a session closes) and the orphan
* reaper. Best-effort on the git side (a dir already gone is not an error); the DB
* row is flipped to 'archived' (soft-delete, Paseo's worktree-archive pattern) so
* history/attribution survives and a re-run is idempotent.
*
* SAFETY: callers MUST run `checkWorktreeWorkAtRisk` first and skip at-risk
* worktrees — this function force-removes (`--force`), so it never silently drops
* uncommitted/unmerged work unless the caller already cleared/accepted the risk.
*/
export async function removeSessionWorktree(
sql: Sql,
projectPath: string,
worktree: { id: string; path: string; branch?: string | null },
opts?: { signal?: AbortSignal },
): Promise<void> {
await hostExec(
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktree.path)} --force`,
{ signal: opts?.signal, timeoutMs: 15_000 },
).catch(() => {});
const branch = worktree.branch ?? null;
if (branch) {
await hostExec(
`git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branch)}`,
{ signal: opts?.signal, timeoutMs: 10_000 },
).catch(() => {});
}
// Prune any stale worktree administrative entries left behind by a partial remove.
await hostExec(
`git -C ${shellEscape(projectPath)} worktree prune`,
{ signal: opts?.signal, timeoutMs: 10_000 },
).catch(() => {});
await sql`UPDATE worktrees SET status = 'archived' WHERE id = ${worktree.id}`.catch(() => {});
}
/**
* v2.6 Phase 3 (3.3): the chat-close cleanup. Mark every `agent_sessions` row for
* the chat 'closed', then — only if this was the session's LAST open chat — remove
* the shared session worktree (a worktree is one-per-session, shared across the
* session's chat tabs, so closing one tab must not pull the rug from sibling tabs).
*
* Returns what it did so the route can report it. The actual backend (process /
* server-session) teardown is the pool's job (`agentPool.closeChat` +
* `backend.closeSession`); this owns the DB + git truth.
*
* `worktreeRemoved` is false when other open chats remain (worktree kept) OR when
* the worktree held work at risk (preflight blocked it — never silently dropped).
*/
export interface ChatCloseResult {
agentRowsClosed: number;
worktreeRemoved: boolean;
worktreeAtRisk: boolean;
}
export async function closeChatBackendState(
sql: Sql,
chatId: string,
opts?: { signal?: AbortSignal; force?: boolean },
): Promise<ChatCloseResult> {
// Resolve the chat's session (and that session's project path) before we touch
// anything — a deleted chat row leaves agent_sessions/worktrees pointing nowhere.
const [chatRow] = await sql<{ session_id: string | null }[]>`
SELECT session_id FROM chats WHERE id = ${chatId}
`;
// chat row may already be gone (delete fired first); fall back to agent_sessions'
// session_id link, which SET NULLs only on session delete, not chat delete.
let sessionId = chatRow?.session_id ?? null;
if (!sessionId) {
const [as] = await sql<{ session_id: string | null }[]>`
SELECT session_id FROM agent_sessions WHERE chat_id = ${chatId} AND session_id IS NOT NULL LIMIT 1
`;
sessionId = as?.session_id ?? null;
}
// Mark this chat's (chat,agent) backend rows closed (idempotent).
const closedRows = await sql<{ agent: string }[]>`
UPDATE agent_sessions SET status = 'closed'
WHERE chat_id = ${chatId} AND status <> 'closed'
RETURNING agent
`;
let worktreeRemoved = false;
let worktreeAtRisk = false;
if (sessionId) {
// Other open chats still sharing the session worktree? If so, keep it.
const openRows = await sql<{ open_count: number }[]>`
SELECT COUNT(*)::int AS open_count FROM chats
WHERE session_id = ${sessionId} AND status = 'open' AND id <> ${chatId}
`;
const openCount = openRows[0]?.open_count ?? 0;
if (openCount === 0) {
const [wt] = await sql<{ id: string; path: string; branch: string | null }[]>`
SELECT id, path, branch FROM worktrees
WHERE session_id = ${sessionId} AND status = 'active' LIMIT 1
`;
if (wt) {
const projRows = await sql<{ path: string | null }[]>`
SELECT p.path FROM sessions s JOIN projects p ON p.id = s.project_id WHERE s.id = ${sessionId}
`;
const projectPath = projRows[0]?.path ?? null;
// Preflight (close-hook semantics): a DELIBERATE chat/session close — the
// server's session-delete already ran the full work-at-risk gate
// (dirty/unpushed/unmerged) before calling us, and chat-close discards the
// tab's staged review intentionally. So here we only block on UNCOMMITTED
// working-tree changes (`dirty`) — work the user never even staged into the
// review diff. The session branch's own commits (the diff-staging
// mechanism) are NOT a block; treating them as "unmerged risk" would make
// the worktree un-removable on every real session (the orphan reaper keeps
// the full at-risk gate because it runs unattended). `force` skips this.
if (!opts?.force) {
const risk = await checkWorktreeWorkAtRisk(wt.path, opts);
worktreeAtRisk = risk.dirty || risk.error != null;
}
if (projectPath && (opts?.force || !worktreeAtRisk)) {
await removeSessionWorktree(sql, projectPath, wt, opts);
worktreeRemoved = true;
}
}
}
}
return { agentRowsClosed: closedRows.length, worktreeRemoved, worktreeAtRisk };
}
/**
* v2.6 Phase 3 (3.5): re-baseline a session's worktree diff after a successful
* `apply_pending`. The applied changes were written to the PROJECT ROOT; the
* worktree branch still holds the same delta against the ORIGINAL `base_commit`,
* so the next turn's `diffWorktree(base_commit...worktree-HEAD)` would re-surface
* the already-applied changes as "pending" — a confusing double-count.
*
* Fix: advance the stored `base_commit` to the worktree's CURRENT HEAD (the
* `diffWorktree` path commits the worktree's accumulated changes before diffing,
* so HEAD already encodes the applied state). The next turn then diffs against
* that, surfacing only edits made AFTER the apply. Idempotent: if the worktree has
* no new commits, the base is unchanged.
*
* Diff-baseline-correctness note (design §7): we re-baseline to the worktree's own
* HEAD, NOT to a moving project HEAD — so an out-of-band edit to the project root
* after apply doesn't corrupt the baseline. The trade-off is that a manual project
* edit isn't reflected as "already there"; acceptable, and matches the stored-base
* (not moving-target) decision in §7.
*/
export async function rebaselineWorktreeAfterApply(
sql: Sql,
sessionId: string,
opts?: { signal?: AbortSignal },
): Promise<{ rebaselined: boolean; newBaseCommit: string | null }> {
const [wt] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
SELECT id, path, base_commit FROM worktrees
WHERE session_id = ${sessionId} AND status = 'active' LIMIT 1
`;
if (!wt) return { rebaselined: false, newBaseCommit: null };
// Make sure the worktree's accumulated edits are committed so HEAD encodes the
// just-applied state (the diff path normally does this, but apply may run with no
// prior diff this turn). Commit ONLY when something is staged — NO --allow-empty,
// so a re-baseline with no new edits doesn't advance HEAD and stays idempotent.
await hostExec(
`cd ${shellEscape(wt.path)} && git add -A && ` +
`git diff --cached --quiet || ` +
`git -c user.email=boocoder@local -c user.name=BooCoder commit -q -m "rebaseline after apply"`,
{ signal: opts?.signal, timeoutMs: 15_000 },
).catch(() => {});
const headRes = await hostExec(
`git -C ${shellEscape(wt.path)} rev-parse HEAD`,
{ signal: opts?.signal, timeoutMs: 10_000 },
).catch(() => null);
const newBase = headRes && headRes.exitCode === 0 ? headRes.stdout.trim() || null : null;
if (!newBase || newBase === wt.base_commit) {
return { rebaselined: false, newBaseCommit: wt.base_commit };
}
await sql`UPDATE worktrees SET base_commit = ${newBase} WHERE id = ${wt.id}`;
return { rebaselined: true, newBaseCommit: newBase };
}
// ─── Session-delete work-loss guard ───────────────────────────────────────── // ─── Session-delete work-loss guard ─────────────────────────────────────────
/** /**

View File

@@ -87,7 +87,7 @@
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"ai": "^6.0.190", "ai": "^6.0.190",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"parse5": "^8.0.1", "node-html-markdown": "^1.3.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"ws": "^8.18.0", "ws": "^8.18.0",
"zod": "^3.23.8" "zod": "^3.23.8"
@@ -99,5 +99,5 @@
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"license": "AGPL-3.0-only" "license": "MIT"
} }

View File

@@ -4,6 +4,7 @@ import type { Sql } from '../db.js';
import type { Broker } from '../services/broker.js'; import type { Broker } from '../services/broker.js';
import type { Chat, Message } from '../types/api.js'; import type { Chat, Message } from '../types/api.js';
import { getModelContext } from '../services/model-context.js'; import { getModelContext } from '../services/model-context.js';
import { notifyCoderClose } from '../services/coder-notify.js';
const CreateBody = z.object({ const CreateBody = z.object({
name: z.string().min(1).max(200).optional(), name: z.string().min(1).max(200).optional(),
@@ -167,6 +168,9 @@ export function registerChatRoutes(
chat_id: id, chat_id: id,
session_id: req.params.id, session_id: req.params.id,
}); });
// Fire-and-forget per archived chat: tear down its warm agent backends
// on the coder. Best-effort — never blocks/fails the bulk archive.
void notifyCoderClose('chat', id, req.log);
} }
return { archived: ids.length, ids }; return { archived: ids.length, ids };
} }
@@ -208,6 +212,9 @@ export function registerChatRoutes(
chat_id: row.id, chat_id: row.id,
session_id: row.session_id, session_id: row.session_id,
}); });
// Fire-and-forget: tear down this chat's warm agent backends + (last-chat)
// worktree on the coder. Best-effort — never blocks/fails the archive.
void notifyCoderClose('chat', row.id, req.log);
reply.code(204); reply.code(204);
return null; return null;
} }
@@ -248,6 +255,9 @@ export function registerChatRoutes(
chat_id: row.id, chat_id: row.id,
session_id: row.session_id, session_id: row.session_id,
}); });
// Fire-and-forget: tear down this chat's warm agent backends + (last-chat)
// worktree on the coder. Best-effort — never blocks/fails the delete.
void notifyCoderClose('chat', row.id, req.log);
reply.code(204); reply.code(204);
return null; return null;
} }

View File

@@ -5,6 +5,7 @@ import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js'; import type { Broker } from '../services/broker.js';
import type { Session, WorktreeRiskReport } from '../types/api.js'; import type { Session, WorktreeRiskReport } from '../types/api.js';
import { getSetting } from './settings.js'; import { getSetting } from './settings.js';
import { notifyCoderClose } from '../services/coder-notify.js';
const CreateBody = z.object({ const CreateBody = z.object({
name: z.string().min(1).max(200).optional(), name: z.string().min(1).max(200).optional(),
@@ -513,6 +514,10 @@ export function registerSessionRoutes(
} }
const project_id = deleted[0]!.project_id; const project_id = deleted[0]!.project_id;
broker.publishUserFrame('default', { type: 'session_deleted', session_id: id, project_id }); broker.publishUserFrame('default', { type: 'session_deleted', session_id: id, project_id });
// Fire-and-forget: ask BooCoder to tear down this session's warm agent
// backends + worktree immediately. Best-effort — never blocks/fails the
// delete; the coder's idle-evict + orphan reaper backstop a missed call.
void notifyCoderClose('session', id, req.log);
reply.code(204); reply.code(204);
return null; return null;
} }

View File

@@ -0,0 +1,67 @@
// v2.6.10 Phase 3 (server wiring) — notifyCoderClose fire-and-forget helper.
//
// The guarantee under test: the helper NEVER throws (so it can't break the
// user's delete/archive path), targets the correct coder URL shape, and folds
// every failure mode (non-2xx, network error) into a `false` result.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { notifyCoderClose } from '../coder-notify.js';
const ORIGINAL_BOOCODER_URL = process.env.BOOCODER_URL;
describe('notifyCoderClose', () => {
beforeEach(() => {
delete process.env.BOOCODER_URL;
});
afterEach(() => {
if (ORIGINAL_BOOCODER_URL === undefined) delete process.env.BOOCODER_URL;
else process.env.BOOCODER_URL = ORIGINAL_BOOCODER_URL;
});
it('POSTs the chat close hook at the default coder origin and resolves true on 2xx', async () => {
const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
const ok = await notifyCoderClose('chat', 'chat-123', undefined, fetcher as unknown as typeof fetch);
expect(ok).toBe(true);
expect(fetcher).toHaveBeenCalledTimes(1);
const [url, init] = fetcher.mock.calls[0]!;
expect(url).toBe('http://boocoder:3000/api/chats/chat-123/close');
expect(init).toEqual({ method: 'POST' });
});
it('POSTs the session close hook with the sessions segment', async () => {
const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
const ok = await notifyCoderClose('session', 'sess-abc', undefined, fetcher as unknown as typeof fetch);
expect(ok).toBe(true);
expect(fetcher.mock.calls[0]![0]).toBe('http://boocoder:3000/api/sessions/sess-abc/close');
});
it('honors BOOCODER_URL for the origin', async () => {
process.env.BOOCODER_URL = 'http://100.114.205.53:9502';
const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
await notifyCoderClose('chat', 'c1', undefined, fetcher as unknown as typeof fetch);
expect(fetcher.mock.calls[0]![0]).toBe('http://100.114.205.53:9502/api/chats/c1/close');
});
it('resolves false on a non-2xx response (does not throw)', async () => {
const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 500 }));
const log = { debug: vi.fn() };
const ok = await notifyCoderClose('chat', 'c1', log, fetcher as unknown as typeof fetch);
expect(ok).toBe(false);
expect(log.debug).toHaveBeenCalledTimes(1);
});
it('resolves false on a network error (coder unreachable) — never rejects', async () => {
const fetcher = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
const log = { debug: vi.fn() };
const ok = await notifyCoderClose('session', 's1', log, fetcher as unknown as typeof fetch);
expect(ok).toBe(false);
expect(log.debug).toHaveBeenCalledTimes(1);
});
it('does not require a logger', async () => {
const fetcher = vi.fn().mockRejectedValue(new Error('boom'));
await expect(
notifyCoderClose('chat', 'c1', undefined, fetcher as unknown as typeof fetch),
).resolves.toBe(false);
});
});

View File

@@ -70,10 +70,16 @@ describe('htmlToMarkdown', () => {
</tbody> </tbody>
</table>`; </table>`;
const md = htmlToMarkdown(html); const md = htmlToMarkdown(html);
expect(md).toContain('| Name | Age | City |'); // node-html-markdown pads columns to align them; assert structure rather
expect(md).toContain('| --- | --- | --- |'); // than exact spacing. Each cell value and a GFM separator row are present.
expect(md).toContain('| Alice | 30 | NYC |'); expect(md).toContain('| Name ');
expect(md).toContain('| Bob | 25 | LA |'); expect(md).toContain('| Age ');
expect(md).toContain('| City |');
expect(md).toMatch(/\| -+ \| -+ \| -+ \|/); // separator row
expect(md).toContain('| Alice ');
expect(md).toContain('| NYC |');
expect(md).toContain('| Bob ');
expect(md).toContain('| LA |');
}); });
it('escapes pipe characters in table cells', () => { it('escapes pipe characters in table cells', () => {
@@ -162,14 +168,17 @@ describe('htmlToMarkdown', () => {
it('converts br to newline', () => { it('converts br to newline', () => {
const md = htmlToMarkdown('line one<br>line two'); const md = htmlToMarkdown('line one<br>line two');
// node-html-markdown emits a GFM hard line break (trailing two spaces).
expect(md).toContain('line one \nline two'); expect(md).toContain('line one \nline two');
}); });
it('handles ol with start attribute', () => { it('handles ol with start attribute', () => {
const html = '<ol start="5"><li>five</li><li>six</li></ol>'; const html = '<ol start="5"><li>five</li><li>six</li></ol>';
const md = htmlToMarkdown(html); const md = htmlToMarkdown(html);
expect(md).toContain('5. five'); // node-html-markdown does not honor the `start` attribute; it always
expect(md).toContain('6. six'); // renumbers ordered lists from 1. (Old parse5 renderer honored start=.)
expect(md).toContain('1. five');
expect(md).toContain('2. six');
}); });
it('collapses excessive blank lines', () => { it('collapses excessive blank lines', () => {
@@ -212,9 +221,12 @@ describe('htmlToMarkdown', () => {
expect(md).toContain('[a link](https://example.com)'); expect(md).toContain('[a link](https://example.com)');
expect(md).toContain('## Features'); expect(md).toContain('## Features');
expect(md).toContain('* Fast'); expect(md).toContain('* Fast');
expect(md).toContain('| Metric | Value |'); // Table columns are padded to align (node-html-markdown behavior).
expect(md).toContain('| --- | --- |'); expect(md).toContain('| Metric ');
expect(md).toContain('| Uptime | 99.9% |'); expect(md).toContain('| Value |');
expect(md).toMatch(/\| -+ \| -+ \|/); // separator row
expect(md).toContain('| Uptime ');
expect(md).toContain('| 99.9% |');
expect(md).toContain('> This tool is amazing.'); expect(md).toContain('> This tool is amazing.');
expect(md).toContain('```js\nconsole.log("hello");\n```'); expect(md).toContain('```js\nconsole.log("hello");\n```');
expect(md).not.toContain('evil'); expect(md).not.toContain('evil');

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
// Guards the AGPL-3.0 -> MIT relicense (openspec license-debt-mit). If any of
// these fail, AGPL-derived provenance has crept back in.
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..');
describe('license: MIT relicense guard', () => {
it('LICENSE is MIT (no Affero/AGPL text)', () => {
const license = readFileSync(resolve(ROOT, 'LICENSE'), 'utf8');
expect(license).toMatch(/^MIT License/);
expect(license).not.toMatch(/AFFERO|AGPL/i);
});
const PACKAGE_JSONS = [
'package.json',
'apps/server/package.json',
'apps/web/package.json',
'apps/coder/package.json',
'apps/booterm/package.json',
];
for (const rel of PACKAGE_JSONS) {
it(`${rel} declares "license": "MIT"`, () => {
const pkg = JSON.parse(readFileSync(resolve(ROOT, rel), 'utf8')) as { license?: string };
expect(pkg.license).toBe('MIT');
});
}
// The three files that were ported from Unsloth Studio (AGPL-3.0-only) and
// cleared in this batch — they must carry no AGPL/Unsloth provenance.
const FORMERLY_AGPL = [
'apps/server/src/services/inference/tool-call-parser.ts',
'apps/server/src/services/web/html-to-md.ts',
'apps/server/src/services/inference/llama-args-validator.ts',
];
for (const rel of FORMERLY_AGPL) {
it(`${rel} carries no AGPL / Unsloth provenance`, () => {
const src = readFileSync(resolve(ROOT, rel), 'utf8');
expect(src).not.toMatch(/AGPL/);
expect(src).not.toMatch(/SPDX-License-Identifier:\s*AGPL/);
expect(src).not.toMatch(/Unsloth/i);
});
}
});

View File

@@ -4,18 +4,11 @@ import {
parseInvokeToolCall, parseInvokeToolCall,
partialXmlOpenerStart, partialXmlOpenerStart,
extractToolCallBlocks, extractToolCallBlocks,
parseToolCallsFromText,
stripToolMarkup, stripToolMarkup,
hasToolSignal,
XML_TOOL_OPEN, XML_TOOL_OPEN,
XML_TOOL_CLOSE, XML_TOOL_CLOSE,
INVOKE_TOOL_OPEN, INVOKE_TOOL_OPEN,
INVOKE_TOOL_CLOSE, INVOKE_TOOL_CLOSE,
TOOL_XML_SIGNALS,
BUDGET_EXHAUSTED_NUDGE,
DUPLICATE_CALL_NUDGE,
TOOL_ERROR_NUDGE,
TOOL_ERROR_PREFIXES,
} from '../inference/tool-call-parser.js'; } from '../inference/tool-call-parser.js';
// ── Ported from xml-parser.test.ts ─────────────────────────────────────── // ── Ported from xml-parser.test.ts ───────────────────────────────────────
@@ -301,38 +294,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
}); });
}); });
// ── New tests: Unsloth-ported functions ──────────────────────────────────
describe('hasToolSignal', () => {
it('returns true for <tool_call>', () => {
expect(hasToolSignal('prefix <tool_call> suffix')).toBe(true);
});
it('returns true for <function=', () => {
expect(hasToolSignal('prefix <function=view_file> suffix')).toBe(true);
});
it('returns true for <invoke', () => {
expect(hasToolSignal('prefix <invoke name="x"> suffix')).toBe(true);
});
it('returns false for near-miss <tool>', () => {
expect(hasToolSignal('prefix <tool> suffix')).toBe(false);
});
it('returns false for near-miss <function>', () => {
expect(hasToolSignal('prefix <function> suffix')).toBe(false);
});
it('returns false for near-miss <tool_call_thing>', () => {
expect(hasToolSignal('<tool_call_thing>')).toBe(false);
});
it('returns false for plain text', () => {
expect(hasToolSignal('just some text')).toBe(false);
});
});
describe('stripToolMarkup', () => { describe('stripToolMarkup', () => {
it('strips closed <tool_call> blocks', () => { it('strips closed <tool_call> blocks', () => {
const input = 'before <tool_call>{"name":"x"}</tool_call> after'; const input = 'before <tool_call>{"name":"x"}</tool_call> after';
@@ -380,166 +341,11 @@ describe('stripToolMarkup', () => {
}); });
}); });
describe('parseToolCallsFromText', () => { describe('delimiter constants', () => {
describe('pattern 1: <tool_call>{json}</tool_call>', () => { it('exports the expected delimiters', () => {
it('parses a well-formed JSON tool call', () => { expect(INVOKE_TOOL_OPEN).toBe('<invoke');
const input = '<tool_call>{"name":"web_search","arguments":{"query":"hello"}}</tool_call>'; expect(INVOKE_TOOL_CLOSE).toBe('</invoke>');
const calls = parseToolCallsFromText(input); expect(XML_TOOL_OPEN).toBe('<tool_call>');
expect(calls).toHaveLength(1); expect(XML_TOOL_CLOSE).toBe('</tool_call>');
expect(calls[0]!.id).toBe('call_0');
expect(calls[0]!.type).toBe('function');
expect(calls[0]!.function.name).toBe('web_search');
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ query: 'hello' });
});
it('handles string arguments field', () => {
const input = '<tool_call>{"name":"x","arguments":"already a string"}</tool_call>';
const calls = parseToolCallsFromText(input);
expect(calls[0]!.function.arguments).toBe('already a string');
});
it('handles balanced braces inside JSON strings', () => {
const input = '<tool_call>{"name":"x","arguments":{"q":"} { extra "}}</tool_call>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
const parsed = JSON.parse(calls[0]!.function.arguments);
expect(parsed.q).toBe('} { extra ');
});
it('respects idOffset', () => {
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call>';
const calls = parseToolCallsFromText(input, { idOffset: 5 });
expect(calls[0]!.id).toBe('call_5');
});
it('parses multiple JSON tool calls', () => {
const input =
'<tool_call>{"name":"a","arguments":{}}</tool_call>' +
'<tool_call>{"name":"b","arguments":{}}</tool_call>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(2);
expect(calls[0]!.id).toBe('call_0');
expect(calls[1]!.id).toBe('call_1');
});
it('skips malformed JSON', () => {
const input = '<tool_call>{not json}</tool_call>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(0);
});
it('handles missing closing tag', () => {
const input = '<tool_call>{"name":"x","arguments":{"q":"hello"}}';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('x');
});
});
describe('pattern 2: <function=name><parameter=key>value', () => {
it('parses a single-parameter function call', () => {
const input = '<function=view_file><parameter=path>/tmp/foo</parameter></function>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('view_file');
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
});
it('single-param fast path preserves embedded </parameter>', () => {
const input = '<function=run_bash><parameter=command>echo "</parameter>"</parameter></function>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(JSON.parse(calls[0]!.function.arguments).command).toBe('echo "</parameter>"');
});
it('multi-param: value of first stops at start of second', () => {
const input = '<function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
const args = JSON.parse(calls[0]!.function.arguments);
expect(args.pattern).toBe('foo');
expect(args.path).toBe('src/');
});
it('tolerates missing closing tags', () => {
const input = '<function=view_file><parameter=path>/tmp/foo';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('view_file');
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
});
it('does not fire when pattern 1 found results', () => {
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><function=b><parameter=x>y</parameter></function>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('a');
});
});
describe('pattern 3: <invoke name="..."><parameter name="...">value (Anthropic)', () => {
it('parses a single-parameter invoke call', () => {
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('view_file');
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
});
it('parses multi-parameter invoke call', () => {
const input = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
const args = JSON.parse(calls[0]!.function.arguments);
expect(args.pattern).toBe('foo');
expect(args.path).toBe('src/');
});
it('does not fire when pattern 1 found results', () => {
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><invoke name="b"><parameter name="x">y</parameter></invoke>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('a');
});
it('does not fire when pattern 2 found results', () => {
const input = '<function=a><parameter=x>y</parameter></function><invoke name="b"><parameter name="x">y</parameter></invoke>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('a');
});
it('tolerates missing closing tags', () => {
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
});
it('supports single-quoted attributes', () => {
const input = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('view_file');
});
});
});
describe('constants', () => {
it('TOOL_XML_SIGNALS includes all three signal prefixes', () => {
expect(TOOL_XML_SIGNALS).toContain('<tool_call>');
expect(TOOL_XML_SIGNALS).toContain('<function=');
expect(TOOL_XML_SIGNALS).toContain('<invoke');
});
it('nudge constants are non-empty strings', () => {
expect(BUDGET_EXHAUSTED_NUDGE.length).toBeGreaterThan(0);
expect(DUPLICATE_CALL_NUDGE.length).toBeGreaterThan(0);
expect(TOOL_ERROR_NUDGE.length).toBeGreaterThan(0);
});
it('TOOL_ERROR_PREFIXES is a non-empty tuple', () => {
expect(TOOL_ERROR_PREFIXES.length).toBeGreaterThan(0);
expect(TOOL_ERROR_PREFIXES).toContain('Error');
}); });
}); });

View File

@@ -0,0 +1,64 @@
// v2.6.10 Phase 3 (server wiring) — fire-and-forget BooCoder close hooks.
//
// BooCoder (apps/coder, host systemd) added close hooks in
// apps/coder/src/routes/lifecycle.ts:
// POST /api/chats/:chatId/close — evict the chat's warm (chat,agent)
// backends, close its opencode session,
// mark agent_sessions closed, and remove
// the shared worktree on the last chat.
// POST /api/sessions/:sessionId/close — loop the chat-close path for every
// chat in the session.
//
// apps/server (Docker) can't see the host worktree dirs or reach the warm agent
// processes, so — exactly like the existing `worktree-risk` guard in
// routes/sessions.ts — it signals the coder over HTTP and the coder does the
// real teardown. This call is BEST-EFFORT: the coder's idle-pool eviction and
// the orphan-worktree reaper backstop a missed/failed call. It MUST NEVER block
// or fail the user's delete/archive — hence fire-and-forget with a swallowed
// catch. We do not await the returned promise at the call sites.
import type { FastifyBaseLogger } from 'fastify';
export type CoderCloseKind = 'chat' | 'session';
function coderOrigin(): string {
// Same env + default as routes/sessions.ts' worktree-risk fetch.
return process.env.BOOCODER_URL ?? 'http://boocoder:3000';
}
/**
* Fire-and-forget POST to the BooCoder close hook for a chat or session.
*
* Resolves to `true` if the coder acknowledged (HTTP 2xx), `false` otherwise
* (non-2xx or network error). Callers SHOULD NOT await this — invoke it and
* move on. The returned promise never rejects: every failure path is caught,
* logged at debug, and folded into a `false` result so an unreachable or
* erroring coder can't surface to the user's delete/archive request.
*/
export async function notifyCoderClose(
kind: CoderCloseKind,
id: string,
log?: Pick<FastifyBaseLogger, 'debug'>,
fetcher: typeof fetch = fetch,
): Promise<boolean> {
const segment = kind === 'chat' ? 'chats' : 'sessions';
const url = `${coderOrigin()}/api/${segment}/${id}/close`;
try {
const res = await fetcher(url, { method: 'POST' });
if (!res.ok) {
log?.debug(
{ kind, id, status: res.status },
'coder close hook returned non-2xx (best-effort; reaper backstops)',
);
return false;
}
log?.debug({ kind, id }, 'coder close hook acknowledged');
return true;
} catch (err) {
log?.debug(
{ kind, id, err: err instanceof Error ? err.message : String(err) },
'coder close hook unreachable (best-effort; reaper backstops)',
);
return false;
}
}

View File

@@ -1,80 +1,139 @@
// SPDX-License-Identifier: AGPL-3.0-only // Guards against agent-supplied llama-server CLI flags that would clash with
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. // values BooCode sets itself. Two concerns live here:
// Ported from studio/backend/core/inference/llama_server_args.py. //
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/llama_server_args.py // 1. A hard denylist of flags that BooCode owns outright (model selection,
// the listening socket, credentials, the bundled web UI). Passing any of
// these is a configuration error and is rejected loudly.
//
// 2. A "shadowing" set of flags that are legal to pass but, because of
// llama.cpp's last-wins argument parsing, would override a first-class
// BooCode setting. These are silently removed from the auto-generated
// argv so the agent's explicit choice takes precedence without leaving a
// duplicate flag behind.
//
// All flag spellings below are the public llama-server option names (short and
// long aliases) documented in its --help output.
// Each group is the full set of aliases (short + long) for one hard-denied // --- Hard denylist -------------------------------------------------------
// flag, taken from the llama-server README. Flags NOT in this list pass
// through and override auto-set values via llama.cpp's last-wins CLI parsing. // Authored as named buckets purely for readability; every alias is folded
const DENYLIST_GROUPS: ReadonlyArray<ReadonlySet<string>> = [ // into one flat lookup set at module load. Each inner array enumerates the
// Model identity // short + long spellings that select the same underlying option.
new Set(['-m', '--model']), const MODEL_SOURCE_FLAGS = [
new Set(['-mu', '--model-url']), ['-m', '--model'],
new Set(['-dr', '--docker-repo']), ['-mu', '--model-url'],
new Set(['-hf', '-hfr', '--hf-repo']), ['-dr', '--docker-repo'],
new Set(['-hff', '--hf-file']), ['-hf', '-hfr', '--hf-repo'],
new Set(['-hfv', '-hfrv', '--hf-repo-v']), ['-hff', '--hf-file'],
new Set(['-hffv', '--hf-file-v']), ['-hfv', '-hfrv', '--hf-repo-v'],
new Set(['-hft', '--hf-token']), ['-hffv', '--hf-file-v'],
new Set(['-mm', '--mmproj']), ['-hft', '--hf-token'],
new Set(['-mmu', '--mmproj-url']), ['-mm', '--mmproj'],
// Networking ['-mmu', '--mmproj-url'],
new Set(['--host']),
new Set(['--port']),
new Set(['--path']),
new Set(['--api-prefix']),
new Set(['--reuse-port']),
// Auth / TLS
new Set(['--api-key']),
new Set(['--api-key-file']),
new Set(['--ssl-key-file']),
new Set(['--ssl-cert-file']),
// Single-model server / UI
new Set(['--webui', '--no-webui']),
new Set(['--ui', '--no-ui']),
new Set(['--ui-config']),
new Set(['--ui-config-file']),
new Set(['--ui-mcp-proxy', '--no-ui-mcp-proxy']),
new Set(['--models-dir']),
new Set(['--models-preset']),
new Set(['--models-max']),
new Set(['--models-autoload', '--no-models-autoload']),
]; ];
const DENYLIST: ReadonlySet<string> = new Set( const LISTEN_FLAGS = [
DENYLIST_GROUPS.flatMap((g) => [...g]), ['--host'],
['--port'],
['--path'],
['--api-prefix'],
['--reuse-port'],
];
const CREDENTIAL_FLAGS = [
['--api-key'],
['--api-key-file'],
['--ssl-key-file'],
['--ssl-cert-file'],
];
const WEBUI_FLAGS = [
['--webui', '--no-webui'],
['--ui', '--no-ui'],
['--ui-config'],
['--ui-config-file'],
['--ui-mcp-proxy', '--no-ui-mcp-proxy'],
['--models-dir'],
['--models-preset'],
['--models-max'],
['--models-autoload', '--no-models-autoload'],
];
const MANAGED_FLAGS: ReadonlySet<string> = new Set(
[
...MODEL_SOURCE_FLAGS,
...LISTEN_FLAGS,
...CREDENTIAL_FLAGS,
...WEBUI_FLAGS,
].flat(),
); );
function flagName(token: string): string | null { // --- Token parsing -------------------------------------------------------
if (!token.startsWith('-') || token === '-' || token === '--') return null;
if (token.length >= 2 && (token[1]!.match(/\d/) || token[1] === '.')) return null; const DIGIT = /^[0-9]$/;
return token.split('=', 1)[0]!;
/**
* Extract the flag name from a single argv token, or `null` when the token is
* not a flag.
*
* A token is treated as a flag only when it begins with `-` and the character
* after the leading dash is neither a digit nor a decimal point — that rule
* keeps negative numeric values such as `-1` or `-0.5` from being mistaken for
* options. A bare `-` or `--` is not a flag either. The returned name is the
* portion before any `=`, so `--ctx-size=4096` yields `--ctx-size`.
*/
function parseFlag(token: string): string | null {
if (!token.startsWith('-')) return null;
if (token === '-' || token === '--') return null;
const second = token[1]!;
if (DIGIT.test(second) || second === '.') return null;
const eq = token.indexOf('=');
return eq === -1 ? token : token.slice(0, eq);
} }
// --- Public API ----------------------------------------------------------
/**
* Validate a sequence of extra llama-server args, rejecting any that name a
* BooCode-managed flag. Returns the args materialised as a string[] when they
* all pass.
*/
export function validateExtraArgs(args?: Iterable<string>): string[] { export function validateExtraArgs(args?: Iterable<string>): string[] {
if (!args) return []; const result: string[] = [];
const out: string[] = []; if (!args) return result;
for (const raw of args) {
const token = String(raw); for (const entry of args) {
const flag = flagName(token); const token = String(entry);
if (flag !== null && DENYLIST.has(flag)) { const flag = parseFlag(token);
if (flag !== null && MANAGED_FLAGS.has(flag)) {
throw new Error( throw new Error(
`llama-server flag '${flag}' is managed and cannot be passed as an extra arg`, `llama-server flag '${flag}' is managed and cannot be passed as an extra arg`,
); );
} }
out.push(token); result.push(token);
}
return out;
} }
return result;
}
/** True when `flag` is a BooCode-managed flag that callers may not override. */
export function isManagedFlag(flag: string): boolean { export function isManagedFlag(flag: string): boolean {
return DENYLIST.has(flag); return MANAGED_FLAGS.has(flag);
} }
// Shadowing flag groups: pass-through flags that shadow first-class settings. // --- Shadowing flags -----------------------------------------------------
const CONTEXT_FLAGS = new Set(['-c', '--ctx-size']);
const CACHE_FLAGS = new Set(['-ctk', '--cache-type-k', '-ctv', '--cache-type-v']); // Flags below are legal for an agent to pass, but each shadows a setting
const SPEC_FLAGS = new Set([ // BooCode applies itself. They are categorised so a caller can opt out of
// stripping any one category.
const SHADOW_CONTEXT = ['-c', '--ctx-size'];
const SHADOW_CACHE = ['-ctk', '--cache-type-k', '-ctv', '--cache-type-v'];
const SHADOW_SPEC = [
'--spec-default', '--spec-default',
'--spec-type', '--spec-type',
'--spec-ngram-size-n', '--spec-ngram-size-n',
@@ -88,17 +147,22 @@ const SPEC_FLAGS = new Set([
'--spec-ngram-mod-n-match', '--spec-ngram-mod-n-match',
'--spec-ngram-mod-n-min', '--spec-ngram-mod-n-min',
'--spec-ngram-mod-n-max', '--spec-ngram-mod-n-max',
]); ];
const TEMPLATE_FLAGS = new Set([
const SHADOW_TEMPLATE = [
'--chat-template', '--chat-template',
'--chat-template-file', '--chat-template-file',
'--chat-template-kwargs', '--chat-template-kwargs',
'--jinja', '--jinja',
'--no-jinja', '--no-jinja',
]); ];
const BOOLEAN_SHADOWING_FLAGS = new Set([ // Shadowing flags that take no value — a boolean switch — so the stripper must
'--spec-default', '--jinja', '--no-jinja', // not also drop the following token.
const VALUELESS_SHADOW_FLAGS: ReadonlySet<string> = new Set([
'--spec-default',
'--jinja',
'--no-jinja',
]); ]);
export interface StripOptions { export interface StripOptions {
@@ -108,35 +172,49 @@ export interface StripOptions {
stripTemplate?: boolean; stripTemplate?: boolean;
} }
/**
* Remove shadowing flags (and their values) from an argv sequence.
*
* Each category is stripped by default; pass the matching `strip*: false`
* option to retain that category. When a stripped flag carries its value as a
* separate following token (e.g. `-c 4096`), that token is removed too; the
* `--flag=value` and boolean-switch forms consume only the single token.
*/
export function stripShadowingFlags( export function stripShadowingFlags(
args: Iterable<string>, args: Iterable<string>,
opts?: StripOptions, opts?: StripOptions,
): string[] { ): string[] {
const shadowing = new Set<string>(); const targets = new Set<string>();
if (opts?.stripContext !== false) for (const f of CONTEXT_FLAGS) shadowing.add(f); if (opts?.stripContext !== false) for (const f of SHADOW_CONTEXT) targets.add(f);
if (opts?.stripCache !== false) for (const f of CACHE_FLAGS) shadowing.add(f); if (opts?.stripCache !== false) for (const f of SHADOW_CACHE) targets.add(f);
if (opts?.stripSpec !== false) for (const f of SPEC_FLAGS) shadowing.add(f); if (opts?.stripSpec !== false) for (const f of SHADOW_SPEC) targets.add(f);
if (opts?.stripTemplate !== false) for (const f of TEMPLATE_FLAGS) shadowing.add(f); if (opts?.stripTemplate !== false) for (const f of SHADOW_TEMPLATE) targets.add(f);
const tokens = [...args].map(String); const tokens = Array.from(args, String);
const out: string[] = []; const kept: string[] = [];
let i = 0;
const n = tokens.length; for (let i = 0; i < tokens.length; i++) {
while (i < n) { const token = tokens[i]!;
const tok = tokens[i]!; const flag = parseFlag(token);
const flag = flagName(tok);
if (flag === null || !shadowing.has(flag)) { // Not a targeted shadow flag — keep it verbatim.
out.push(tok); if (flag === null || !targets.has(flag)) {
i++; kept.push(token);
continue; continue;
} }
if (BOOLEAN_SHADOWING_FLAGS.has(flag) || tok.includes('=')) {
i++; // Targeted: drop it. Decide whether the next token is its value and should
} else if (i + 1 < n && flagName(tokens[i + 1]!) === null) { // be dropped along with it. Boolean switches and the inline `=value` form
i += 2; // carry no separate value token.
} else { const carriesInlineValue = token.includes('=');
i++; const isBoolean = VALUELESS_SHADOW_FLAGS.has(flag);
const next = tokens[i + 1];
const nextIsValue = next !== undefined && parseFlag(next) === null;
if (!isBoolean && !carriesInlineValue && nextIsValue) {
i++; // also skip the value token
} }
} }
return out;
return kept;
} }

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // Streaming tool-call extraction for the qwen3.6 XML fallback path.
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. // `extractToolCallBlocks` is the incremental streaming scanner used by
// Ported from studio/backend/core/inference/tool_call_parser.py. // stream-phase.ts; `stripToolMarkup` removes tool-call wire markup from
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/tool_call_parser.py // assistant prose (used by tool-phase.ts and error-handler.ts).
// ── Constants ──────────────────────────────────────────────────────────── // ── Constants ────────────────────────────────────────────────────────────
@@ -10,34 +10,6 @@ export const XML_TOOL_CLOSE = '</tool_call>';
export const INVOKE_TOOL_OPEN = '<invoke'; export const INVOKE_TOOL_OPEN = '<invoke';
export const INVOKE_TOOL_CLOSE = '</invoke>'; export const INVOKE_TOOL_CLOSE = '</invoke>';
export const TOOL_XML_SIGNALS = [XML_TOOL_OPEN, '<function=', INVOKE_TOOL_OPEN] as const;
export const TOOL_ERROR_PREFIXES = [
'Error',
'Search failed',
'Execution error',
'Blocked:',
'Exit code',
'Failed to fetch',
'Failed to resolve',
'No query provided',
] as const;
export const DUPLICATE_CALL_NUDGE =
'You already made this exact call. Do not repeat the same tool ' +
'call. Try a different approach: fetch a URL from previous ' +
'results, use Python to process data you already have, or ' +
'provide your final answer now.';
export const TOOL_ERROR_NUDGE =
'\n\nThe tool call encountered an issue. Please try a different ' +
'approach or rephrase your request.';
export const BUDGET_EXHAUSTED_NUDGE =
'You have used all available tool calls. Based on everything you ' +
'have found so far, provide your final answer now. Do not call ' +
'any more tools.';
// ── Strip patterns ─────────────────────────────────────────────────────── // ── Strip patterns ───────────────────────────────────────────────────────
const TOOL_CLOSED_PATS = [ const TOOL_CLOSED_PATS = [
@@ -53,7 +25,7 @@ const TOOL_ALL_PATS = [
/<invoke\s[^>]*>.*$/gs, /<invoke\s[^>]*>.*$/gs,
]; ];
// ── Strip / signal ─────────────────────────────────────────────────────── // ── Strip ────────────────────────────────────────────────────────────────
export function stripToolMarkup(text: string, opts?: { final?: boolean }): string { export function stripToolMarkup(text: string, opts?: { final?: boolean }): string {
const pats = opts?.final ? TOOL_ALL_PATS : TOOL_CLOSED_PATS; const pats = opts?.final ? TOOL_ALL_PATS : TOOL_CLOSED_PATS;
@@ -63,206 +35,6 @@ export function stripToolMarkup(text: string, opts?: { final?: boolean }): strin
return opts?.final ? text.trim() : text; return opts?.final ? text.trim() : text;
} }
export function hasToolSignal(text: string): boolean {
return TOOL_XML_SIGNALS.some((s) => text.includes(s));
}
// ── parseToolCallsFromText (Unsloth port + Anthropic extension) ──────────
export interface OpenAiToolCall {
id: string;
type: 'function';
function: { name: string; arguments: string };
}
const TC_JSON_START_RE = /<tool_call>\s*\{/g;
const TC_FUNC_START_RE = /<function=(\w+)>\s*/g;
const TC_END_TAG_RE = /<\/tool_call>/;
const TC_FUNC_CLOSE_RE = /\s*<\/function>\s*$/;
const TC_PARAM_START_RE = /<parameter=(\w+)>\s*/g;
const TC_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
const TC_INVOKE_START_RE = /<invoke\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
const TC_INVOKE_CLOSE_RE = /\s*<\/invoke>\s*$/;
const TC_INVOKE_PARAM_RE = /<parameter\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
const TC_INVOKE_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
function scanBalancedBraces(content: string, start: number): number {
let depth = 0;
let i = start;
let inString = false;
while (i < content.length) {
const ch = content[i]!;
if (inString) {
if (ch === '\\' && i + 1 < content.length) {
i += 2;
continue;
}
if (ch === '"') inString = false;
} else if (ch === '"') {
inString = true;
} else if (ch === '{') {
depth++;
} else if (ch === '}') {
depth--;
if (depth === 0) return i;
}
i++;
}
return -1;
}
export function parseToolCallsFromText(
content: string,
opts?: { idOffset?: number },
): OpenAiToolCall[] {
const toolCalls: OpenAiToolCall[] = [];
const idOffset = opts?.idOffset ?? 0;
// Pattern 1: <tool_call>{json}</tool_call> -- balanced-brace JSON scanner.
// Skips braces inside JSON strings so nested objects parse correctly.
TC_JSON_START_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TC_JSON_START_RE.exec(content)) !== null) {
const braceStart = m.index + m[0].length - 1;
const braceEnd = scanBalancedBraces(content, braceStart);
if (braceEnd === -1) continue;
const jsonStr = content.slice(braceStart, braceEnd + 1);
try {
const obj = JSON.parse(jsonStr) as Record<string, unknown>;
const name = typeof obj.name === 'string' ? obj.name : '';
let args: string;
const rawArgs = obj.arguments ?? {};
if (typeof rawArgs === 'string') {
args = rawArgs;
} else {
args = JSON.stringify(rawArgs);
}
toolCalls.push({
id: `call_${idOffset + toolCalls.length}`,
type: 'function',
function: { name, arguments: args },
});
} catch {
// malformed JSON -- skip
}
}
// Pattern 2: <function=name><parameter=key>value -- closing tags optional.
// Body boundary uses </tool_call> or next <function= (not </function>,
// because code parameter values can contain that literal).
if (toolCalls.length === 0) {
TC_FUNC_START_RE.lastIndex = 0;
const funcStarts: Array<{ match: RegExpExecArray; name: string }> = [];
while ((m = TC_FUNC_START_RE.exec(content)) !== null) {
funcStarts.push({ match: m, name: m[1]! });
}
for (let idx = 0; idx < funcStarts.length; idx++) {
const { match: fm, name: funcName } = funcStarts[idx]!;
const bodyStart = fm.index + fm[0].length;
const nextFunc = idx + 1 < funcStarts.length
? funcStarts[idx + 1]!.match.index
: content.length;
const endTag = TC_END_TAG_RE.exec(content.slice(bodyStart));
let bodyEnd = endTag ? bodyStart + endTag.index : content.length;
bodyEnd = Math.min(bodyEnd, nextFunc);
let body = content.slice(bodyStart, bodyEnd);
body = body.replace(TC_FUNC_CLOSE_RE, '');
const args: Record<string, string> = {};
TC_PARAM_START_RE.lastIndex = 0;
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
let pm: RegExpExecArray | null;
while ((pm = TC_PARAM_START_RE.exec(body)) !== null) {
paramStarts.push({ match: pm, name: pm[1]! });
}
if (paramStarts.length === 1) {
// Single param: take everything to body end so embedded
// </parameter> in code strings is preserved.
const p = paramStarts[0]!;
let val = body.slice(p.match.index + p.match[0].length);
val = val.replace(TC_PARAM_CLOSE_RE, '');
args[p.name] = val.trim();
} else {
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
const p = paramStarts[pidx]!;
const valStart = p.match.index + p.match[0].length;
const nextParam = pidx + 1 < paramStarts.length
? paramStarts[pidx + 1]!.match.index
: body.length;
let val = body.slice(valStart, nextParam);
val = val.replace(TC_PARAM_CLOSE_RE, '');
args[p.name] = val.trim();
}
}
toolCalls.push({
id: `call_${idOffset + toolCalls.length}`,
type: 'function',
function: { name: funcName, arguments: JSON.stringify(args) },
});
}
}
// Pattern 3: <invoke name="..."><parameter name="...">value -- Anthropic
// shape that qwen3.6 drifts to from Claude Code documentation residue.
// Closing tags optional; same single-param fast path as pattern 2.
if (toolCalls.length === 0) {
TC_INVOKE_START_RE.lastIndex = 0;
const invokeStarts: Array<{ match: RegExpExecArray; name: string }> = [];
while ((m = TC_INVOKE_START_RE.exec(content)) !== null) {
const name = (m[1] ?? m[2] ?? '').trim();
if (name) invokeStarts.push({ match: m, name });
}
for (let idx = 0; idx < invokeStarts.length; idx++) {
const { match: im, name: invokeName } = invokeStarts[idx]!;
const bodyStart = im.index + im[0].length;
const nextInvoke = idx + 1 < invokeStarts.length
? invokeStarts[idx + 1]!.match.index
: content.length;
const closeTag = content.slice(bodyStart).match(/<\/invoke>/);
let bodyEnd = closeTag ? bodyStart + (closeTag.index ?? 0) : content.length;
bodyEnd = Math.min(bodyEnd, nextInvoke);
let body = content.slice(bodyStart, bodyEnd);
body = body.replace(TC_INVOKE_CLOSE_RE, '');
const args: Record<string, string> = {};
TC_INVOKE_PARAM_RE.lastIndex = 0;
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
let pm: RegExpExecArray | null;
while ((pm = TC_INVOKE_PARAM_RE.exec(body)) !== null) {
const pname = (pm[1] ?? pm[2] ?? '').trim();
if (pname) paramStarts.push({ match: pm, name: pname });
}
if (paramStarts.length === 1) {
const p = paramStarts[0]!;
let val = body.slice(p.match.index + p.match[0].length);
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
args[p.name] = val.trim();
} else {
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
const p = paramStarts[pidx]!;
const valStart = p.match.index + p.match[0].length;
const nextParam = pidx + 1 < paramStarts.length
? paramStarts[pidx + 1]!.match.index
: body.length;
let val = body.slice(valStart, nextParam);
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
args[p.name] = val.trim();
}
}
toolCalls.push({
id: `call_${idOffset + toolCalls.length}`,
type: 'function',
function: { name: invokeName, arguments: JSON.stringify(args) },
});
}
}
return toolCalls;
}
// ── BooCode streaming helpers ──────────────────────────────────────────── // ── BooCode streaming helpers ────────────────────────────────────────────
export interface ParsedCall { export interface ParsedCall {

View File

@@ -1,347 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-only import { NodeHtmlMarkdown } from 'node-html-markdown';
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
// Ported from studio/backend/core/inference/_html_to_md.py.
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/_html_to_md.py
import { parse, type DefaultTreeAdapterTypes } from 'parse5'; // MIT-licensed HTML→Markdown rendering for the web_fetch tool. Output feeds an
// LLM, so structural fidelity matters more than exact whitespace.
type Document = DefaultTreeAdapterTypes.Document; const OPTIONS = {
type ChildNode = DefaultTreeAdapterTypes.ChildNode; // GFM-style emphasis markers (matches what most models expect).
type Element = DefaultTreeAdapterTypes.Element; emDelimiter: '*',
type TextNode = DefaultTreeAdapterTypes.TextNode; strongDelimiter: '**',
bulletMarker: '*',
const SKIP_TAGS = new Set([ codeFence: '```',
'script', 'style', 'head', 'noscript', 'svg', 'math', 'nav', 'footer', codeBlockStyle: 'fenced' as const,
]); // Always use []() syntax for links rather than <url> autolinks.
useInlineLinks: false,
const BLOCK_TAGS = new Set([ // Collapse runs of blank lines to a single separator.
'p', 'div', 'section', 'article', 'main', 'aside', 'figure', maxConsecutiveNewlines: 1,
'figcaption', 'details', 'summary', 'dl', 'dt', 'dd', // Strip non-content elements entirely (script/style are skipped by default,
]); // but listing them here is explicit; head/nav/footer/etc. drop their text).
ignore: ['script', 'style', 'head', 'noscript', 'svg', 'math', 'nav', 'footer'],
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
const INLINE_EMPHASIS: Record<string, string> = {
strong: '**', b: '**', em: '*', i: '*',
}; };
function isElement(node: ChildNode): node is Element {
return 'tagName' in node;
}
function isText(node: ChildNode): node is TextNode {
return node.nodeName === '#text';
}
class MarkdownRenderer {
private out: string[] = [];
private inLink = false;
private linkHref: string | null = null;
private linkTextParts: string[] = [];
private listStack: string[] = [];
private olCounter: number[] = [];
private inTable = false;
private currentRow: string[] = [];
private cellParts: string[] = [];
private inCell = false;
private headerRowDone = false;
private rowHasTh = false;
private isFirstRow = false;
private inPre = false;
private preParts: string[] = [];
private preLanguage: string | null = null;
private inInlineCode = false;
private bqStack: string[][] = [];
private emit(text: string): void {
if (this.inLink) {
this.linkTextParts.push(text);
} else if (this.inCell) {
this.cellParts.push(text);
} else if (this.inPre) {
this.preParts.push(text);
} else if (this.bqStack.length > 0) {
this.bqStack[this.bqStack.length - 1]!.push(text);
} else {
this.out.push(text);
}
}
private prefixBlockquote(content: string): string {
content = content.replace(/[ \t]+$/gm, '');
content = content.replace(/\n{3,}/g, '\n\n').trim();
if (!content) return '';
return content.split('\n').map(line =>
line.trim() ? '> ' + line : '>'
).join('\n');
}
private finishCell(): void {
if (!this.inCell) return;
this.inCell = false;
let cellText = this.cellParts.join('').trim().replace(/\n/g, ' ');
cellText = cellText.replace(/\|/g, '\\|');
this.currentRow.push(cellText);
this.cellParts = [];
}
private finishRow(): void {
if (this.currentRow.length === 0) return;
const line = '| ' + this.currentRow.join(' | ') + ' |';
this.emit(line + '\n');
if (!this.headerRowDone && (this.rowHasTh || this.isFirstRow)) {
const sep = '| ' + this.currentRow.map(() => '---').join(' | ') + ' |';
this.emit(sep + '\n');
this.headerRowDone = true;
}
this.isFirstRow = false;
this.currentRow = [];
this.rowHasTh = false;
}
private finishLink(): void {
const text = this.linkTextParts.join('').replace(/\s+/g, ' ').trim();
const href = this.linkHref ?? '';
this.inLink = false;
if (href && text) {
this.emit(`[${text}](${href})`);
} else if (text) {
this.emit(text);
}
}
private getAttr(el: Element, name: string): string | undefined {
return el.attrs.find(a => a.name === name)?.value;
}
private handleOpen(el: Element): void {
const tag = el.tagName.toLowerCase();
if (HEADING_TAGS.has(tag)) {
const level = parseInt(tag[1]!, 10);
this.emit('\n\n' + '#'.repeat(level) + ' ');
} else if (tag === 'a') {
this.linkHref = this.getAttr(el, 'href') ?? null;
this.linkTextParts = [];
this.inLink = true;
} else if (tag in INLINE_EMPHASIS) {
this.emit(INLINE_EMPHASIS[tag]!);
} else if (tag === 'br') {
this.emit('\n');
} else if (BLOCK_TAGS.has(tag)) {
this.emit('\n\n');
} else if (tag === 'hr') {
this.emit('\n\n---\n\n');
} else if (tag === 'blockquote') {
this.emit('\n\n');
this.bqStack.push([]);
} else if (tag === 'ul') {
this.listStack.push('ul');
this.emit('\n');
} else if (tag === 'ol') {
this.listStack.push('ol');
const startAttr = this.getAttr(el, 'start');
let start = 1;
if (startAttr != null) {
const parsed = parseInt(startAttr, 10);
if (!isNaN(parsed)) start = parsed;
}
this.olCounter.push(start - 1);
this.emit('\n');
} else if (tag === 'li') {
const indent = ' '.repeat(Math.max(0, this.listStack.length - 1));
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
if (this.olCounter.length > 0) {
this.olCounter[this.olCounter.length - 1]!++;
this.emit(`\n${indent}${this.olCounter[this.olCounter.length - 1]}. `);
} else {
this.emit(`\n${indent}1. `);
}
} else {
this.emit(`\n${indent}* `);
}
} else if (tag === 'pre') {
this.preParts = [];
this.inPre = true;
this.preLanguage = null;
const codeChild = el.childNodes.find(
(c): c is Element => isElement(c) && c.tagName === 'code'
);
if (codeChild) {
const cls = this.getAttr(codeChild, 'class') ?? '';
const langMatch = cls.match(/(?:^|\s)language-(\S+)/);
if (langMatch) this.preLanguage = langMatch[1]!;
}
} else if (tag === 'code' && !this.inPre) {
this.inInlineCode = true;
this.emit('`');
} else if (tag === 'table') {
this.inTable = true;
this.headerRowDone = false;
this.isFirstRow = true;
this.emit('\n\n');
} else if (tag === 'tr') {
this.finishCell();
this.finishRow();
} else if (tag === 'th' || tag === 'td') {
this.finishCell();
this.cellParts = [];
this.inCell = true;
if (tag === 'th') this.rowHasTh = true;
}
}
private handleClose(tag: string): void {
tag = tag.toLowerCase();
if (HEADING_TAGS.has(tag)) {
this.emit('\n\n');
} else if (tag === 'a') {
this.finishLink();
} else if (tag in INLINE_EMPHASIS) {
this.emit(INLINE_EMPHASIS[tag]!);
} else if (BLOCK_TAGS.has(tag)) {
this.emit('\n\n');
} else if (tag === 'blockquote') {
if (this.bqStack.length > 0) {
const content = this.bqStack.pop()!.join('');
const prefixed = this.prefixBlockquote(content);
if (prefixed) this.emit('\n\n' + prefixed + '\n\n');
}
} else if (tag === 'ul') {
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ul') {
this.listStack.pop();
}
this.emit('\n');
} else if (tag === 'ol') {
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
this.listStack.pop();
if (this.olCounter.length > 0) this.olCounter.pop();
}
this.emit('\n');
} else if (tag === 'pre') {
const raw = this.preParts.join('');
this.inPre = false;
const lang = this.preLanguage ?? '';
const block = '```' + lang + '\n' + raw + '\n```';
this.emit('\n\n' + block + '\n\n');
this.preLanguage = null;
} else if (tag === 'code' && !this.inPre) {
this.inInlineCode = false;
this.emit('`');
} else if (tag === 'th' || tag === 'td') {
this.finishCell();
} else if (tag === 'tr') {
this.finishCell();
this.finishRow();
} else if (tag === 'table') {
this.finishCell();
this.finishRow();
this.inTable = false;
this.emit('\n');
}
}
private handleText(data: string): void {
if (this.inPre) {
this.preParts.push(data);
return;
}
if (this.inInlineCode) {
this.emit(data);
return;
}
const text = data.replace(/\s+/g, ' ');
if (this.inTable && !this.inCell && !text.trim()) return;
this.emit(text);
}
walk(node: ChildNode | Document): void {
if (isText(node as ChildNode)) {
this.handleText((node as TextNode).value);
return;
}
if (node.nodeName === '#comment') return;
if (isElement(node as ChildNode)) {
const el = node as Element;
const tag = el.tagName.toLowerCase();
if (SKIP_TAGS.has(tag)) return;
if (tag === 'img') return;
this.handleOpen(el);
if (tag === 'pre') {
for (const child of el.childNodes) {
if (isElement(child) && child.tagName === 'code') {
for (const grandchild of child.childNodes) {
this.walk(grandchild);
}
} else {
this.walk(child);
}
}
} else {
for (const child of el.childNodes) {
this.walk(child);
}
}
this.handleClose(tag);
return;
}
if ('childNodes' in node) {
for (const child of (node as Document).childNodes) {
this.walk(child);
}
}
}
getOutput(): string {
return this.out.join('');
}
}
function cleanup(text: string): string {
const lines = text.split('\n');
const out: string[] = [];
let inFence = false;
let blankRun = 0;
for (const line of lines) {
const stripped = line.replace(/[ \t]+$/, '');
if (stripped.startsWith('```')) {
inFence = !inFence;
blankRun = 0;
out.push(stripped);
continue;
}
if (inFence) {
out.push(line);
continue;
}
if (!stripped) {
blankRun++;
if (blankRun <= 1) out.push('');
continue;
}
blankRun = 0;
out.push(stripped);
}
return out.join('\n').trim();
}
export function htmlToMarkdown(sourceHtml: string): string { export function htmlToMarkdown(sourceHtml: string): string {
sourceHtml = sourceHtml.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); if (!sourceHtml) return '';
const doc = parse(sourceHtml); return NodeHtmlMarkdown.translate(sourceHtml, OPTIONS).trim();
const renderer = new MarkdownRenderer();
renderer.walk(doc);
return cleanup(renderer.getOutput());
} }

View File

@@ -44,5 +44,5 @@
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.3.4" "vite": "^5.3.4"
}, },
"license": "AGPL-3.0-only" "license": "MIT"
} }

View File

@@ -25,6 +25,17 @@ import type {
WorkspaceState, WorkspaceState,
} from './types'; } from './types';
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
// GET /api/coder/sessions/:id/agent-sessions; drives the AgentComposerBar
// resumed/new-session chip via useAgentSessions. `has_session` is true when a
// resumable backend session id exists for that agent in the chat.
export interface AgentSessionInfo {
agent: string;
status: string;
has_session: boolean;
last_active_at: string | null;
}
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
public status: number, public status: number,
@@ -363,6 +374,11 @@ export const api = {
request<CoderMessageWire[]>( request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`, `/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
), ),
// v2.6 Phase 1-UX §9b: per-(chat,agent) backend-session state for the
// resumed/new-session chip. Chat-scoped (NOT foldable into the project-level
// provider snapshot). Proxied to boocoder at /api/sessions/:id/agent-sessions.
agentSessions: (sessionId: string) =>
request<AgentSessionInfo[]>(`/api/coder/sessions/${sessionId}/agent-sessions`),
skillInvoke: ( skillInvoke: (
sessionId: string, sessionId: string,
paneId: string, paneId: string,

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react'; import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types'; import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot'; import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
import { providerIcon } from '@/components/coder/providerIcons';
import { useAgentSessions } from '@/hooks/useAgentSessions';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -172,9 +173,36 @@ interface Props {
onChange: (next: AgentSessionConfig) => void; onChange: (next: AgentSessionConfig) => void;
onProviderCommandsChange?: (commands: AgentCommand[]) => void; onProviderCommandsChange?: (commands: AgentCommand[]) => void;
connected?: boolean; connected?: boolean;
// v2.6 Phase 1-UX §9b: chat id for the resumed/new-session chip. Optional so
// BooChat and any other AgentComposerBar caller renders no chip and is
// otherwise unaffected. When present + connected + the chat has ≥1 prior
// turn, a chip right of the Provider picker reports whether switching to the
// current provider resumes an agent session, replays history (boocode), or
// starts fresh.
sessionId?: string;
// True once the chat has at least one prior turn — gates the chip so it stays
// hidden on a brand-new chat. Defaults to false (no chip).
hasPriorTurn?: boolean;
} }
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) { // Relative-time formatter for the resumed-chip title (e.g. "3m ago").
function relativeTime(iso: string | null): string {
if (!iso) return 'unknown';
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return 'unknown';
const diffMs = Date.now() - then;
if (diffMs < 0) return 'just now';
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return 'just now';
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn }: Props) {
const allEntries = useProviderSnapshot(projectPath); const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or // 5.5 — the composer picker only offers ENABLED providers that are ready (or
// still loading). Disabled (enabled:false) and unavailable/error providers are // still loading). Disabled (enabled:false) and unavailable/error providers are
@@ -186,6 +214,13 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
); );
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows for the resumed/new
// chip. Hook is unconditional (hooks rule); it self-no-ops when sessionId is
// undefined or the chat has no prior turn, so BooChat callers cost nothing.
const { sessions: agentSessions } = useAgentSessions(
sessionId && hasPriorTurn ? sessionId : undefined,
);
const hydratedRef = useRef(false); const hydratedRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -294,21 +329,30 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
); );
} }
const providerIcon = (name: string) => {
switch (name) {
case 'claude': return <ClaudeIcon size={13} className="shrink-0" />;
case 'opencode': return <OpenCodeIcon size={13} className="shrink-0" />;
case 'goose': return <Bird size={13} className="shrink-0" />;
case 'qwen': return <TermIcon size={13} className="shrink-0" />;
default: return <Dog size={13} className="shrink-0" />;
}
};
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label })); const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label })); const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label })); const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label })); const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
// v2.6 Phase 1-UX §9b: resumed / history / new-session chip. Only meaningful
// when this is a real chat (sessionId), the WS is connected, and the chat has
// ≥1 prior turn — otherwise render nothing so fresh chats and non-coder
// callers stay clean.
const sessionRow = agentSessions.find((s) => s.agent === value.provider);
const sessionChip: { label: string; title: string } | null =
sessionId && hasPriorTurn && connected
? value.provider === 'boocode'
? // Native boocode never holds an agent_sessions row — it reconstructs
// the conversation from the chat transcript each turn.
{ label: 'history', title: 'BooCode replays the chat transcript each turn' }
: sessionRow?.has_session
? {
label: 'resumed',
title: `Resuming ${value.provider} · last active ${relativeTime(sessionRow.last_active_at)}`,
}
: { label: 'new session', title: `${value.provider} starts a fresh session this turn` }
: null;
return ( return (
<div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0"> <div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<CompactPicker <CompactPicker
@@ -322,6 +366,14 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
: providerIcon(value.provider) : providerIcon(value.provider)
} }
/> />
{sessionChip && (
<span
title={sessionChip.title}
className="inline-flex items-center rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shrink-0"
>
{sessionChip.label}
</span>
)}
<CompactPicker <CompactPicker
label="Mode" label="Mode"
value={value.modeId ?? ''} value={value.modeId ?? ''}

View File

@@ -0,0 +1,56 @@
// Shared provider icon + label helpers for BooCoder UI.
//
// Single source of truth for the per-provider glyph used in the
// AgentComposerBar picker and the CoderPane DiffPanel agent-attribution
// badges (v2.6 Phase 1-UX §9a/§9b). Extracted from AgentComposerBar's local
// `providerIcon` switch so both call sites stay in sync.
import type { ReactNode } from 'react';
import { Bird, Dog, Terminal as TermIcon } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
/**
* Glyph for a provider/agent name. Mirrors AgentComposerBar's original
* `providerIcon` switch verbatim — `boocode` (native) falls through to the
* neutral dog like any unmapped name, preserving the composer's prior look.
* Sized to match the picker (13px) by default; pass a different size for
* inline badges.
*/
export function providerIcon(name: string | null, size = 13): ReactNode {
switch (name) {
case 'claude':
return <ClaudeIcon size={size} className="shrink-0" />;
case 'opencode':
return <OpenCodeIcon size={size} className="shrink-0" />;
case 'goose':
return <Bird size={size} className="shrink-0" />;
case 'qwen':
return <TermIcon size={size} className="shrink-0" />;
default:
return <Dog size={size} className="shrink-0" />;
}
}
/**
* Human label for a provider/agent name. `null` → "manual" (a RightRail-staged
* change with no dispatching agent, per §9a). Unknown names pass through
* verbatim so a future provider still reads sensibly.
*/
export function providerLabel(name: string | null): string {
switch (name) {
case null:
return 'manual';
case 'boocode':
return 'BooCode';
case 'opencode':
return 'opencode';
case 'claude':
return 'Claude';
case 'goose':
return 'goose';
case 'qwen':
return 'Qwen';
default:
return name;
}
}

View File

@@ -16,6 +16,8 @@ import { toast } from 'sonner';
import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command'; import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { mergeWireToolCall } from '@/lib/coder-tools'; import { mergeWireToolCall } from '@/lib/coder-tools';
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList'; import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -56,6 +58,10 @@ interface PendingChange {
diff?: string; diff?: string;
new_content?: string; new_content?: string;
status: 'pending' | 'approved' | 'rejected'; status: 'pending' | 'approved' | 'rejected';
// v2.6 Phase 1-UX §9a: which agent staged this change. 'boocode' for native
// write tools, the dispatched agent for worktree edits, null for a manual
// RightRail-staged create (renders as a neutral "manual" badge).
agent: string | null;
} }
interface Props { interface Props {
@@ -382,18 +388,52 @@ function usePendingChanges(sessionId: string) {
function DiffPanel({ function DiffPanel({
changes, changes,
loading, loading,
currentProvider,
onRefresh, onRefresh,
onApprove, onApprove,
onReject, onReject,
}: { }: {
changes: PendingChange[]; changes: PendingChange[];
loading: boolean; loading: boolean;
currentProvider: string;
onRefresh: () => void; onRefresh: () => void;
onApprove: (id: string) => void; onApprove: (id: string) => void;
onReject: (id: string) => void; onReject: (id: string) => void;
}) { }) {
const pending = changes.filter((c) => c.status === 'pending'); const pending = changes.filter((c) => c.status === 'pending');
// v2.6 Phase 1-UX §9a: when pending changes span >1 distinct agent, surface a
// one-line "Changes from <a>, <b>" note so mixed provenance is obvious. Null
// (manual) counts as its own bucket and renders as "manual".
const distinctAgents = Array.from(new Set(pending.map((c) => c.agent)));
const mixedNote =
distinctAgents.length > 1
? `Changes from ${distinctAgents.map((a) => providerLabel(a)).join(', ')}`
: null;
// v2.6 §9c: staging-boundary caveat. External agents (opencode/goose/qwen/
// claude) edit *inside their worktree*; native boocode reads/writes the
// *project root* via pending_changes. Unapplied edits don't cross that
// boundary. When the currently-selected provider can't see another side's
// staged-but-unapplied edits, surface a muted one-liner. agent===null
// (manual) is boundary-neutral. Pure derivation — no new state/fetch.
const isNativeProvider = currentProvider === 'boocode';
const boundaryHint = (() => {
if (isNativeProvider) {
// Native boocode is selected: it won't see external-worktree edits.
const external = distinctAgents.filter((a) => a !== null && a !== 'boocode');
if (external.length === 0) return null;
const who =
external.length === 1
? providerLabel(external[0]!)
: external.map((a) => providerLabel(a)).join(', ');
return `${who}'s edits live in its worktree — BooCode won't see them until applied.`;
}
// An external agent is selected: it won't see boocode's project-root edits.
if (!distinctAgents.includes('boocode')) return null;
return `BooCode's edits live in the project root — ${providerLabel(currentProvider)} won't see them until applied.`;
})();
return ( return (
<div className="flex flex-col h-full border-t border-border"> <div className="flex flex-col h-full border-t border-border">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30"> <div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
@@ -410,6 +450,19 @@ function DiffPanel({
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} /> <RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button> </button>
</div> </div>
{mixedNote && (
<div className="px-3 py-1 border-b border-border bg-muted/10 text-[11px] text-muted-foreground truncate">
{mixedNote}
</div>
)}
{boundaryHint && (
<div
className="px-3 py-1 border-b border-border bg-muted/10 text-xs text-muted-foreground"
title={boundaryHint}
>
{boundaryHint}
</div>
)}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{pending.length === 0 ? ( {pending.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground"> <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
@@ -420,14 +473,25 @@ function DiffPanel({
{pending.map((change) => ( {pending.map((change) => (
<div key={change.id} className="px-3 py-2"> <div key={change.id} className="px-3 py-2">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-xs font-mono text-foreground truncate flex-1 mr-2"> <span className="text-xs font-mono text-foreground truncate flex-1 mr-2 inline-flex items-center min-w-0">
<span
className="inline-flex items-center gap-1 rounded border border-border bg-muted/40 px-1 py-px mr-1.5 text-[10px] font-medium text-muted-foreground shrink-0"
title={
change.agent === null
? 'Manually staged (no dispatching agent)'
: `Staged by ${providerLabel(change.agent)}`
}
>
{providerIcon(change.agent, 11)}
<span>{providerLabel(change.agent)}</span>
</span>
<span className={cn( <span className={cn(
'inline-block w-1.5 h-1.5 rounded-full mr-1.5', 'inline-block w-1.5 h-1.5 rounded-full mr-1.5 shrink-0',
change.operation === 'create' && 'bg-green-500', change.operation === 'create' && 'bg-green-500',
change.operation === 'modify' && 'bg-yellow-500', change.operation === 'modify' && 'bg-yellow-500',
change.operation === 'delete' && 'bg-red-500', change.operation === 'delete' && 'bg-red-500',
)} /> )} />
{change.file_path} <span className="truncate">{change.file_path}</span>
</span> </span>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <button
@@ -586,15 +650,24 @@ export function CoderPane({
// dispatch returns — so queueing/stop must key on this combined signal. // dispatch returns — so queueing/stop must key on this combined signal.
const generating = sending || activeTaskId !== null; const generating = sending || activeTaskId !== null;
// Refresh pending changes when a message_complete arrives // Refresh pending changes (and agent-session state for the §9b chip) when a
// message_complete arrives — same trigger usePendingChanges already uses.
useEffect(() => { useEffect(() => {
const lastAssistant = [...messages].reverse().find( const lastAssistant = [...messages].reverse().find(
(m): m is CoderMessage => m.role === 'assistant', (m): m is CoderMessage => m.role === 'assistant',
); );
if (lastAssistant?.status === 'complete') { if (lastAssistant?.status === 'complete') {
refresh(); refresh();
void refreshAgentSessions(sessionId);
} }
}, [messages, refresh]); }, [messages, refresh, sessionId]);
// The §9b chip only shows once the chat has ≥1 prior turn (a completed
// assistant message). Hidden on a brand-new chat.
const hasPriorTurn = useMemo(
() => messages.some((m) => m.role === 'assistant' && (m as CoderMessage).status === 'complete'),
[messages],
);
// Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth) // Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth)
useEffect(() => { useEffect(() => {
@@ -834,6 +907,8 @@ export function CoderPane({
onChange={setAgentConfig} onChange={setAgentConfig}
onProviderCommandsChange={handleProviderCommandsChange} onProviderCommandsChange={handleProviderCommandsChange}
connected={connected} connected={connected}
sessionId={sessionId}
hasPriorTurn={hasPriorTurn}
/> />
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */} {/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
@@ -872,6 +947,7 @@ export function CoderPane({
<DiffPanel <DiffPanel
changes={changes} changes={changes}
loading={loading} loading={loading}
currentProvider={agentConfig.provider}
onRefresh={refresh} onRefresh={refresh}
onApprove={approve} onApprove={approve}
onReject={reject} onReject={reject}

View File

@@ -0,0 +1,88 @@
// v2.6 Phase 1-UX §9b — chat-scoped agent-session state.
//
// Reads GET /api/coder/sessions/:id/agent-sessions (the per-(chat,agent)
// backend-session rows) and drives the AgentComposerBar resumed/new-session
// chip. Module-singleton external store keyed by sessionId — same shape as
// useProviderSnapshot — so the two consumers (CoderPane, which owns the
// message_complete WS signal, and AgentComposerBar, which renders the chip)
// share one cache and one fetch per chat. CoderPane calls
// refreshAgentSessions(sessionId) on each message_complete (the same trigger
// usePendingChanges already keys off); the chip then reflects the freshly
// resumed/created session.
import { useEffect, useSyncExternalStore } from 'react';
import { api, type AgentSessionInfo } from '@/api/client';
type Entry = {
data: AgentSessionInfo[];
inflight: Promise<AgentSessionInfo[]> | null;
};
const store = new Map<string, Entry>();
const listeners = new Set<() => void>();
const EMPTY: AgentSessionInfo[] = [];
function notify(): void {
for (const fn of listeners) fn();
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
function getEntry(sessionId: string): Entry {
let entry = store.get(sessionId);
if (!entry) {
entry = { data: EMPTY, inflight: null };
store.set(sessionId, entry);
}
return entry;
}
async function doFetch(sessionId: string): Promise<AgentSessionInfo[]> {
const data = await api.coder.agentSessions(sessionId);
const entry = getEntry(sessionId);
entry.data = data;
entry.inflight = null;
notify();
return data;
}
function ensureLoaded(sessionId: string): void {
const entry = getEntry(sessionId);
if (entry.data !== EMPTY || entry.inflight) return;
entry.inflight = doFetch(sessionId).catch(() => {
// boocoder may be down or the chat has no agent-session rows yet; treat as
// empty (the chip falls back to "new session" / hides).
const e = getEntry(sessionId);
e.inflight = null;
return EMPTY;
});
}
/** Force a refetch for one chat. Wired to message_complete by CoderPane. */
export function refreshAgentSessions(sessionId: string): Promise<AgentSessionInfo[]> {
const entry = getEntry(sessionId);
entry.inflight = null;
return doFetch(sessionId);
}
/**
* Chat-scoped agent-session rows. Pass `undefined` to opt out (no fetch, empty
* result) — AgentComposerBar does this for BooChat callers and fresh chats so
* the chip stays hidden. Fetches on mount (and on sessionId change); refetch on
* message_complete is driven externally via refreshAgentSessions.
*/
export function useAgentSessions(sessionId: string | undefined): {
sessions: AgentSessionInfo[];
} {
const sessions = useSyncExternalStore(
subscribe,
() => (sessionId ? getEntry(sessionId).data : EMPTY),
);
useEffect(() => {
if (sessionId) ensureLoaded(sessionId);
}, [sessionId]);
return { sessions: sessionId ? sessions : EMPTY };
}

View File

@@ -348,7 +348,7 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
----- -----
## Shipped (v2.2.2v2.6.7 — interactive ACP, provider lifecycle, persistent agent sessions, workspace UX) ## Shipped (v2.2.2v2.6.11 — interactive ACP, provider lifecycle, persistent agent sessions, workspace UX)
All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (planning slugs differ — see the numbering-discipline note below). `CHANGELOG.md` is the canonical per-tag record. **Note on numbering divergence:** the *planned-feature* "v2.3 — Provider lifecycle" actually shipped under the **v2.5.4v2.5.13** tags; the *planned-feature* "v2.4 — BooCoder as ACP agent" remains **unshipped** even though v2.4.0/v2.4.1 *tags* shipped unrelated content (Unsloth lifts, sidecar routing). The patch-tag thread and the conceptual-milestone thread have diverged — read tags as the ship record, the `## v2.x` feature sections below as the milestone plan. The v2.3.0v2.5.1 tags were never CHANGELOG-backfilled; summarized here from commit bodies. All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (planning slugs differ — see the numbering-discipline note below). `CHANGELOG.md` is the canonical per-tag record. **Note on numbering divergence:** the *planned-feature* "v2.3 — Provider lifecycle" actually shipped under the **v2.5.4v2.5.13** tags; the *planned-feature* "v2.4 — BooCoder as ACP agent" remains **unshipped** even though v2.4.0/v2.4.1 *tags* shipped unrelated content (Unsloth lifts, sidecar routing). The patch-tag thread and the conceptual-milestone thread have diverged — read tags as the ship record, the `## v2.x` feature sections below as the milestone plan. The v2.3.0v2.5.1 tags were never CHANGELOG-backfilled; summarized here from commit bodies.
@@ -382,6 +382,10 @@ All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (
- `v2.6.5-panes-tabs-composer`**workspace UX batch (BooChat panes/tabs/composer + the persistence that backs it).** *Panes/tabs:* open a chat in a fresh pane (ChatTabBar "Open in new pane" + fork-beside-original via a new `open_chat_in_new_pane` event), per-pane `[+]` → New BooChat/BooTerm/BooCode menu, closing a chat pane relocates its tabs (in order) to the oldest chat/empty pane (reopen strips restored chatIds from every live pane first → no dup), stable session-scoped tab numbers (assigned on open, retired on close, never reused, map-keyed render), and the empty/landing pane became a real session history (open + separately-fetched archived chats). Removed the per-message "Open in pane" artifact button. *Persistence:* `sessions.workspace_panes` widened from bare `WorkspacePane[]` → a `WorkspaceState` envelope (`panes` + `tabNumbers`/`nextTabNumber` + `closedPaneStack`); PATCH validator zod-unions legacy-array-or-envelope and migrates on write; `session_workspace_updated` WS frame widened (web+server byte-identical, parity test green). *Composer:* morphing **Send → Stop → Queue** button keyed on `sending || activeTaskId` (folds in the standalone Stop pill, adds `cancelTask`); pasted chips trail the typed text so a leading slash stays first. *Tooling:* new read-only `read_tab_by_number` tool + an optional `ToolExecCtx` (`{ sql, sessionId }`) 4th arg on `ToolDef.execute` - `v2.6.5-panes-tabs-composer`**workspace UX batch (BooChat panes/tabs/composer + the persistence that backs it).** *Panes/tabs:* open a chat in a fresh pane (ChatTabBar "Open in new pane" + fork-beside-original via a new `open_chat_in_new_pane` event), per-pane `[+]` → New BooChat/BooTerm/BooCode menu, closing a chat pane relocates its tabs (in order) to the oldest chat/empty pane (reopen strips restored chatIds from every live pane first → no dup), stable session-scoped tab numbers (assigned on open, retired on close, never reused, map-keyed render), and the empty/landing pane became a real session history (open + separately-fetched archived chats). Removed the per-message "Open in pane" artifact button. *Persistence:* `sessions.workspace_panes` widened from bare `WorkspacePane[]` → a `WorkspaceState` envelope (`panes` + `tabNumbers`/`nextTabNumber` + `closedPaneStack`); PATCH validator zod-unions legacy-array-or-envelope and migrates on write; `session_workspace_updated` WS frame widened (web+server byte-identical, parity test green). *Composer:* morphing **Send → Stop → Queue** button keyed on `sending || activeTaskId` (folds in the standalone Stop pill, adds `cancelTask`); pasted chips trail the typed text so a leading slash stays first. *Tooling:* new read-only `read_tab_by_number` tool + an optional `ToolExecCtx` (`{ sql, sessionId }`) 4th arg on `ToolDef.execute`
- `v2.6.6-claude-md` — docs-only CLAUDE.md session-learnings from the v2.6.5 batch: the `WorkspaceState` envelope migration, the `ToolExecCtx` plumb (`read_tab_by_number` as reference), the two-schema-files-one-DB ownership split + idempotent `confdeltype` FK-action-flip pattern, and React-StrictMode nested-`setState` idempotency - `v2.6.6-claude-md` — docs-only CLAUDE.md session-learnings from the v2.6.5 batch: the `WorkspaceState` envelope migration, the `ToolExecCtx` plumb (`read_tab_by_number` as reference), the two-schema-files-one-DB ownership split + idempotent `confdeltype` FK-action-flip pattern, and React-StrictMode nested-`setState` idempotency
- `v2.6.7-interrupt-guard`**F.1 fix:** post-interrupt stale-terminal bug in the opencode warm-server backend (one-click reachable since `v2.6.5`'s Stop button). opencode emits one trailing `session.idle`/`session.error` for a cancelled turn (sessionID only, no turn id) that settled the *next* turn early as success. Pure per-session guard (`backends/turn-guard.ts` — arm-on-abort / swallow-one-orphan / self-heal-on-activity) wired into `opencode-server.ts`; 3 regression tests (TDD). First item of the v2.6 openspec "remaining" plan; Phase 1-UX / 2 / 3 still open - `v2.6.7-interrupt-guard`**F.1 fix:** post-interrupt stale-terminal bug in the opencode warm-server backend (one-click reachable since `v2.6.5`'s Stop button). opencode emits one trailing `session.idle`/`session.error` for a cancelled turn (sessionID only, no turn id) that settled the *next* turn early as success. Pure per-session guard (`backends/turn-guard.ts` — arm-on-abort / swallow-one-orphan / self-heal-on-activity) wired into `opencode-server.ts`; 3 regression tests (TDD). First item of the v2.6 openspec "remaining" plan; Phase 1-UX / 2 / 3 still open
- `v2.6.8-agent-attribution`**v2.6 Phase 1-UX** (U.1U.6), built by 3 parallel subagents over disjoint files. Backend: `pending_changes.agent` stamped at every queue site + flows through `listPending`; new `GET /api/sessions/:id/agent-sessions` route; opencode warm-server consumes `session.next.step.ended` → accumulates `input_tokens`/`output_tokens`/`cost` on `agent_sessions`. Frontend: DiffPanel per-row agent badges + multi-agent note; AgentComposerBar resumed/history/new-session chip (gated on optional `sessionId`, BooChat unaffected); shared `providerIcons.tsx` + `useAgentSessions` hook. 9 new tests; web+coder tsc clean. Both surfaces deployed (boocoder restart + `boocode` Docker rebuild). Phase 2/3 remain
- `v2.6.9-warm-acp`**v2.6 Phase 2:** goose/qwen run as **warm ACP backends** (one persistent `goose acp`/`qwen --acp` child + `ClientSideConnection` + ACP session per `(chat,agent)`, `initialize`+`session/new` once, reused across turns) instead of one-shot. New `WarmAcpBackend` (same `AgentBackend` interface as opencode); abort = `session/cancel` the prompt only (never kills the child); dispatcher routes goose/qwen chat-tab tasks via pure `shouldUseWarmBackend` (one-shot fallback kept for arena/MCP/`new_task`); `handleSessionUpdate` extracted to a shared pure `acp-event-map.ts` (one-shot path byte-identical). SDK concern resolved (`@agentclientprotocol/sdk@^0.22.1` has stable resume; moot warm, deferred to Phase 3). 15 new tests, 180 coder tests pass. Backend-only deploy (boocoder restart). **Smoke 2/2b pending live.** Phase 3 (lifecycle hardening) is the last v2.6 phase
- `v2.6.10-lifecycle-hardening`**v2.6 Phase 3 (final phase — completes v2.6).** Idle TTL eviction (`AGENT_POOL_IDLE_TTL_MS`=30min) + LRU cap (`AGENT_POOL_MAX_LIVE`=10), busy backends never evicted; pure `lifecycle-decisions.ts`. Crash recovery via openchamber's health-monitor + busy-aware-restart + stale-grace state machine in `opencode-server.ts` (+ port reclaim) + `warm-acp.ts` (opencode → fresh sessions; ACP → re-`session/new`; F.1 guard + U.6 usage preserved). Orphan worktree reaper (1h grace, superset-style dirty/unpushed preflight, Paseo soft-delete) + close hooks + re-baseline after apply. 35 new tests + DB-opt-in reconnect test; 215 coder tests pass. Backend-only deploy. **Follow-ups (out of v2.6 scope): apps/server close-hook caller, 3.7 DiffPanel staging hint (frontend), live Smoke 2/2b/3.** With this, **v2.6 persistent agent sessions is complete** (Phase 03 + F.1 + Phase 1-UX)
- `v2.6.11-close-hooks-staging` — the two v2.6 follow-ups. **apps/server close-hook caller:** BooChat fire-and-forgets BooCoder's Phase-3 close hooks (new `coder-notify.ts`, never-rejects) on session-delete + chat archive/delete, so warm backends + worktrees tear down immediately (the idle-evict/reaper was the backstop). **Task 3.7 staging hint:** BooCoder DiffPanel shows a muted one-liner when the selected provider can't see another agent's unapplied worktree edits (pure derivation from per-change `agent` + current provider). 6 new server tests; web+server tsc/build clean; deploys via the `boocode` Docker container. **The v2.6 openspec is now fully closed** — only live Smoke 2/2b/3 remain (manual)
----- -----
@@ -443,24 +447,22 @@ All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (
----- -----
## License-debt — relicense AGPL-3.0 → MIT (planned) ## License-debt — relicense AGPL-3.0 → MIT (shipped 2026-06-01)
**Status: planned, not started.** Recorded 2026-05-31 from the v2 external review (`boocode_code_review_v2.md` §5k) + a direct tree audit. **Decision (Sam, 2026-05-31): relicense the project back to MIT.** **Status: SHIPPED 2026-06-01** (openspec `license-debt-mit`). Recorded 2026-05-31 from the v2 external review (`boocode_code_review_v2.md` §5k) + a direct tree audit. **Decision (Sam, 2026-05-31): relicense the project back to MIT.**
**Current state (the problem):** the tree is **currently AGPL-3.0** — root `LICENSE` is GNU Affero GPL v3 and all five `package.json` declare `"license": "AGPL-3.0-only"`. Cause: the `v2.4.0`/`v2.4.1` Unsloth-Studio lifts pulled in AGPL-3.0-only code, which makes the whole network-served work AGPL-encumbered. This batch clears that so the MIT flip is valid; **nothing else AGPL remains once these files are gone.** **What was the problem:** the tree was AGPL-3.0 — root `LICENSE` was GNU Affero GPL v3 and all five `package.json` declared `"license": "AGPL-3.0-only"`. Cause: the `v2.4.0`/`v2.4.1` Unsloth-Studio lifts pulled in three AGPL-3.0-only files, making the whole network-served work AGPL-encumbered (AGPL §13 network-copyleft). Clearing those three files made the MIT flip valid.
**The three AGPL-3.0-only files to clear** (each `SPDX-License-Identifier: AGPL-3.0-only`, ported from Unsloth Studio): **The three AGPL-3.0-only files (cleared):**
1. `apps/server/src/services/inference/tool-call-parser.ts` (← `tool_call_parser.py`) — remove by routing tool-call parsing to **native llama-server** template parsing + a **clean-room `<invoke>`-only fallback** (no Unsloth provenance). 1. `apps/server/src/services/inference/tool-call-parser.ts` (← `tool_call_parser.py`) — the Unsloth-ported algorithm (`parseToolCallsFromText`/`scanBalancedBraces` + unused nudge constants) was **dead code** (no production import; only the file + its test referenced it). Deleted it. The load-bearing parser (`extractToolCallBlocks` + the BooCode-authored streaming helpers) and `stripToolMarkup` were kept byte-identical and the AGPL header dropped. **No behavior change to the live tool-call path.**
2. `apps/server/src/services/web/html-to-md.ts` (← `_html_to_md.py`, used by `web_fetch`) — replace with a permissively-licensed library (`turndown` / `node-html-markdown`) or a clean-room walker. 2. `apps/server/src/services/web/html-to-md.ts` (← `_html_to_md.py`, used by `web_fetch`) — **swapped** to the MIT `node-html-markdown` library (a distinct third-party lib, not a rewrite-from-memory); `parse5` dropped. `htmlToMarkdown(html): string` signature preserved.
3. `apps/server/src/services/inference/llama-args-validator.ts` (← `llama_server_args.py`, the v2.4.1 sidecar flag-denylist) — clean-room rewrite from the llama-server README flag list (the denylist is facts, not copyrightable). 3. `apps/server/src/services/inference/llama-args-validator.ts` (← `llama_server_args.py`) — **clean-room rewrite** with independent structure; the managed-flag denylist re-derived from the public llama-server flag list (facts, not copyrightable).
**Steps:** **Key correction to the original plan:** the native-llama-server-parsing retirement (which would have needed a live qwen3.6 validation window "behind a flag for one release") was **decoupled** from the relicense and proved unnecessary — the ported parser code was already dead, so the relicense stripped *provenance, not capability*. The native-parsing retirement remains a separate, optional future optimization.
1. Confirm native llama-server tool-parsing on **live qwen3.6** (jinja gate already green — `--jinja` + qwen3.x template live; llama.cpp server-side template parser, v2 review §4a).
2. Run native parsing **behind a flag for one release** (qwen3.6 was historically unreliable — validate before deleting).
3. **Delete** the ~250 Unsloth-derived parser lines + clean-room the `<invoke>` fallback; replace `html-to-md.ts`; clean-room `llama-args-validator.ts`.
4. **Flip the license:** root `LICENSE` AGPL→MIT, the five `package.json` `license` fields `AGPL-3.0-only``MIT`, remove the per-file AGPL SPDX headers, and update roadmap/README prose. After this, **no AGPL remains in the tree** and the "BooCode is MIT" claim becomes true.
**Source:** `boocode_code_review_v2.md` §1 #1, §5k. **Prerequisite for the license flip — this batch is the blocker, not optional.** **License flip:** root `LICENSE` AGPL→MIT (`Copyright (c) 2026 indifferentketchup`); the five `package.json` `license` fields → `MIT`; AGPL SPDX headers removed from all three files; a `## License` section added to `README.md`; a guard test asserts no AGPL header / SPDX-AGPL survives. The `boocode_code_review*.md` point-in-time snapshots were left as-is. **No AGPL remains in the tree.**
**Source:** `boocode_code_review_v2.md` §1 #1, §5k; openspec `license-debt-mit`.
----- -----
@@ -704,7 +706,6 @@ Full per-tag detail in the **Shipped (v2.2.2v2.6.6)** section above and in `C
### In flight ### In flight
- **License-debt → relicense AGPL-3.0 → MIT** — see the planned batch above; the tree is currently AGPL-3.0 and three Unsloth-derived files must be cleared before the MIT flip. Prerequisite, blocker-status.
- **v2.6 persistent agent sessions — Phase 2/3** — warm ACP backend for goose/qwen (persistent process reused across turns) + lifecycle hardening (idle eviction, crash recovery, worktree cleanup/reaper, post-apply re-baseline) + the Phase-1 UX attribution work (DiffPanel agent badges, resumed/new-session chip). See openspec `v2-6-persistent-agent-sessions/tasks.md`. - **v2.6 persistent agent sessions — Phase 2/3** — warm ACP backend for goose/qwen (persistent process reused across turns) + lifecycle hardening (idle eviction, crash recovery, worktree cleanup/reaper, post-apply re-baseline) + the Phase-1 UX attribution work (DiffPanel agent badges, resumed/new-session chip). See openspec `v2-6-persistent-agent-sessions/tasks.md`.
### Numbering and scope-revision discipline during v1.13.x (2026-05-23) ### Numbering and scope-revision discipline during v1.13.x (2026-05-23)

View File

@@ -0,0 +1,51 @@
# License-debt — relicense AGPL-3.0 → MIT
**Status:** in progress (started 2026-06-01)
**Decision:** Sam, 2026-05-31 — relicense BooCode back to MIT.
**Source:** `boocode_code_review_v2.md` §1 #1, §5k; roadmap `## License-debt` batch.
## Why
The tree is **currently AGPL-3.0** — root `LICENSE` is GNU Affero GPL v3 and all five
`package.json` declare `"license": "AGPL-3.0-only"`. Cause: the `v2.4.0`/`v2.4.1`
Unsloth-Studio lifts pulled in three AGPL-3.0-only files. BooCode is network-served, so
AGPL §13 network-copyleft is a live liability. Clearing the three files makes the MIT flip
valid; nothing else AGPL remains once they are gone.
## Core insight (supersedes the roadmap's staged steps)
The roadmap entangled the relicense with retiring `tool-call-parser.ts` behind a live
qwen3.6 validation window. That is **not necessary**: the Unsloth-ported algorithm
(`parseToolCallsFromText` / `scanBalancedBraces` + unused constants) is **dead code**
no production consumer imports it (verified: only the file and its test reference it). The
load-bearing parser (`extractToolCallBlocks`, under the file's own "BooCode streaming
helpers" banner) and `stripToolMarkup` are BooCode-authored. So the relicense **strips
provenance, not capability** — zero behavior change, no validation gate. The
native-llama-server-parsing retirement remains a separate, optional future optimization.
## The three AGPL-3.0-only files to clear
1. `apps/server/src/services/web/html-to-md.ts` (← `_html_to_md.py`) — **swap** to
`node-html-markdown` (MIT). A different third-party library, not a rewrite-from-memory
(which would still be a derivative). Consumed by `web_fetch` via `web/index.ts`;
`htmlToMarkdown(html): string` signature preserved.
2. `apps/server/src/services/inference/llama-args-validator.ts` (← `llama_server_args.py`)
**clean-room** re-derive the flag denylist from the public llama-server README (CLI
flag names are facts, not copyrightable); the shadowing logic is already BooCode's own.
3. `apps/server/src/services/inference/tool-call-parser.ts` (← `tool_call_parser.py`) —
**delete** the dead Unsloth-ported code; keep BooCode's streaming helpers +
`stripToolMarkup` (re-derive its strip regexes from qwen's wire format); drop the header.
No change to the live tool-call path.
## Decisions (Sam, 2026-06-01)
- html-to-md library: **node-html-markdown** (single MIT dep, GFM tables built-in).
- tool-call-parser: **relicense-only** — defer native-parsing retirement.
- MIT copyright line: **`Copyright (c) 2026 indifferentketchup`**.
- Leave `boocode_code_review*.md` (point-in-time snapshots) untouched; update the roadmap
batch (planned → shipped) and add a README License section.
## Out of scope
- Retiring `tool-call-parser` patterns 1 & 2 in favour of native llama-server parsing.
- Bumping the stale README "Latest release" line / AGENTS.md pointer.

View File

@@ -0,0 +1,51 @@
# Tasks — relicense AGPL-3.0 → MIT
Four units. A/B/C are disjoint files (parallelizable); D is the join (runs after A/B/C).
The shared `node-html-markdown` dependency swap + `pnpm install` is done before A so the
parallel agents don't race on `apps/server/package.json`.
## Pre: dependency swap (done by coordinator)
- [ ] Add `node-html-markdown` to `apps/server/package.json` dependencies; remove `parse5`
(only html-to-md consumed it).
- [ ] `pnpm install`.
## A — html-to-md → node-html-markdown
- [ ] Replace `apps/server/src/services/web/html-to-md.ts` with a thin MIT wrapper exporting
`htmlToMarkdown(sourceHtml: string): string` over `NodeHtmlMarkdown.translate`.
- [ ] Drop the AGPL/Unsloth SPDX header.
- [ ] Update `html-to-md.test.ts` to the new library's output (structure-level `.toContain`
where whitespace differs; output feeds an LLM so exact format is not load-bearing).
- [ ] Keep `web/index.ts` re-export and `web_fetch.ts` untouched.
## B — llama-args-validator → clean-room
- [ ] Rewrite `apps/server/src/services/inference/llama-args-validator.ts`: re-derive the
managed-flag denylist from the public llama-server README; keep the BooCode
shadowing-flag logic. Same exports (`validateExtraArgs`, `isManagedFlag`,
`stripShadowingFlags`, `StripOptions`).
- [ ] Drop the AGPL/Unsloth SPDX header.
- [ ] Keep `llama-args-validator.test.ts` green (it pins the contract).
## C — tool-call-parser → minimal clean (relicense-only)
- [ ] Delete dead Unsloth-ported exports: `parseToolCallsFromText`, `scanBalancedBraces`,
`OpenAiToolCall`, `hasToolSignal`, and the unused nudge constants
(`DUPLICATE_CALL_NUDGE`, `TOOL_ERROR_NUDGE`, `TOOL_ERROR_PREFIXES`,
`BUDGET_EXHAUSTED_NUDGE`).
- [ ] Keep `extractToolCallBlocks` + streaming helpers + `stripToolMarkup` (re-derive its
strip regexes from qwen's wire format). Drop the AGPL/Unsloth SPDX header.
- [ ] Remove the now-dead tests from `tool-call-parser.test.ts`; keep streaming/strip tests.
- [ ] Verify `stream-phase.ts` (`extractToolCallBlocks`) + `tool-phase.ts` / `error-handler.ts`
(`stripToolMarkup`) still compile.
## D — license flip (join)
- [ ] `LICENSE`: replace AGPL-3.0 text with MIT, `Copyright (c) 2026 indifferentketchup`.
- [ ] Flip `"license"` to `"MIT"` in all 5 `package.json` (root, server, web, coder, booterm).
- [ ] Confirm no `SPDX-License-Identifier: AGPL` header survives in the 3 files.
- [ ] Roadmap `License-debt` batch: planned → shipped (note the decoupled-from-parser-retirement
approach). Add a `## License` section to `README.md` (MIT).
- [ ] Optional guard test: assert no `AGPL` SPDX header in `apps/**` and all 5 `package.json`
are MIT.
## Verify
- [ ] `pnpm -C apps/server test`
- [ ] `pnpm -C apps/server build`
- [ ] root `npx tsc --noEmit`

View File

@@ -29,51 +29,42 @@ ACP follows; hardening last.
- [x] P1.5-a **Per-session SSE** (`v2.6.2-delete-guard-and-sse`): one `event.subscribe({directory})` per live opencode session, each with an `AbortController`; `sessionID` demux guard + zombie-loop fix — replaces task 1.3's single global loop. Bundled: session-delete work-loss guard (`/worktree-risk`). - [x] P1.5-a **Per-session SSE** (`v2.6.2-delete-guard-and-sse`): one `event.subscribe({directory})` per live opencode session, each with an `AbortController`; `sessionID` demux guard + zombie-loop fix — replaces task 1.3's single global loop. Bundled: session-delete work-loss guard (`/worktree-risk`).
- [x] P1.5-b **Re-key `agent_sessions` → `(chat_id, agent)`** + first-class `worktrees` table (`v2.6.3-chatkey-and-skills`); `tasks.chat_id` threaded; `runOpenCodeServerTask` resolve-or-creates a chat for session-less creators; cross-chunk dcp-strip. FK convergence to `SET NULL` (`v2.6.4-agent-sessions-fk`). - [x] P1.5-b **Re-key `agent_sessions` → `(chat_id, agent)`** + first-class `worktrees` table (`v2.6.3-chatkey-and-skills`); `tasks.chat_id` threaded; `runOpenCodeServerTask` resolve-or-creates a chat for session-less creators; cross-chunk dcp-strip. FK convergence to `SET NULL` (`v2.6.4-agent-sessions-fk`).
## Phase 1 (UX) — Attribution & switch affordances (design §9) — ⬜ REMAINING (pure read+display over already-shipped `pending_changes.agent` + `agent_sessions`) ## Phase 1 (UX) — Attribution & switch affordances (design §9) — ✅ SHIPPED `v2.6.8-agent-attribution` (Smoke U pending live frontend deploy)
- [ ] U.1 Stamp `pending_changes.agent` at queue time (worktree path → task agent; - [x] U.1 Stamp `pending_changes.agent` at queue time — native tools default `'boocode'`, dispatched external`task.agent`, manual RightRail → `NULL` (`pending_changes.ts`, `dispatcher.ts`).
native write tools → `'boocode'`; manual RightRail create → NULL). - [x] U.2 `agent` flows through `listPending` + backend & frontend `PendingChange` types.
- [ ] U.2 Add `agent` to `listPending` response + frontend `PendingChange` type. - [x] U.3 Shared `components/coder/providerIcons.tsx`; DiffPanel per-row agent badge + "Changes from X, Y" multi-agent note (§9a).
- [ ] U.3 Extract `providerIcon()` to a shared helper; DiffPanel renders an agent badge - [x] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` + `useAgentSessions` hook (refetch on message-complete) (§9b).
per row + a "Changes from X, Y" note when the pending set spans >1 agent (§9a). - [x] U.5 `AgentComposerBar` optional `sessionId` prop → resumed/history/new-session chip; hidden on fresh chats + other callers (§9b).
- [ ] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` + - [x] U.6 Consume opencode `session.next.step.ended` → accumulate `input_tokens`/`output_tokens`/`cost` on `agent_sessions` (new cols). Backend persist only; UI surfacing deferred.
`useAgentSessions(sessionId)` (refetch on `message_complete`) (§9b).
- [ ] U.5 `AgentComposerBar` optional `sessionId` prop → resumed / history / new-session
chip beside the Provider picker; hidden on fresh chats and other callers (§9b).
- [ ] U.6 Consume opencode `session.next.step.ended` `{tokens, cost}` → fill ctx/token usage for opencode sessions (SDK already installed; closes the "no usage for external agents" gap; surface beside the §9b chip). Source: `boocode_code_review_v2.md` §1 #8, design §10.
- [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the - [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the
right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose. right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose. *(pending live frontend deploy — Docker container rebuild)*
## Phase 2 — Warm ACP backend (goose, qwen) — ⬜ REMAINING ## Phase 2 — Warm ACP backend (goose, qwen) — ✅ SHIPPED `v2.6.9-warm-acp` (Smoke 2/2b pending live)
> **Lift (design §10):** `qwen --acp` is a validated reference (real stdio multi-session, `loadSession`/resume) — wire qwen into the existing `acp-dispatch.ts` stack. **goose ACP has no `loadSession`/resume** → cross-restart resume needs a different design (re-`session/new` + accept memory loss, or replay). Cross-check qwen `@agentclientprotocol/sdk@^0.14` vs BooCode `^0.22` before relying on `unstable_resumeSession`. Do **qwen first** to de-risk. > **Lift (design §10):** `qwen --acp` is a validated reference (real stdio multi-session, `loadSession`/resume) — wire qwen into the existing `acp-dispatch.ts` stack. **goose ACP has no `loadSession`/resume** → cross-restart resume needs a different design (re-`session/new` + accept memory loss, or replay). Cross-check qwen `@agentclientprotocol/sdk@^0.14` vs BooCode `^0.22` before relying on `unstable_resumeSession`. Do **qwen first** to de-risk.
- [ ] 2.1 `backends/warm-acp.ts`: persistent spawn + `ClientSideConnection`; `initialize` + - [x] 2.1 `backends/warm-acp.ts` `WarmAcpBackend` persistent spawn + `ClientSideConnection`; `initialize` + `session/new` once per `(chat,agent)`. `handleSessionUpdate` extracted to a shared pure `acp-event-map.ts` (one-shot path byte-identical).
`session/new` once; reuse `acp-dispatch.ts` `handleSessionUpdate`. - [x] 2.2 `prompt`: `session/prompt` on the warm connection per turn; abort = `session/cancel` the prompt only (never kills the child).
- [ ] 2.2 `prompt`: `session/prompt` on the warm connection per turn; per-turn abort signal only. - [x] 2.3 Child supervision: pool-owned lifetime; `exit` marks `agent_sessions.status='crashed'` → re-spawn next turn.
- [ ] 2.3 Child supervision: detached lifetime, exit handler marks `status='crashed'`. - [x] 2.4 Dispatcher routes `goose`/`qwen` chat-tab tasks to the warm backend via pure `shouldUseWarmBackend(task)` (needs `session_id`+`chat_id`); one-shot `runExternalAgent` fallback kept for arena/MCP/`new_task`. *(SDK note resolved: installed `@agentclientprotocol/sdk@^0.22.1` has stable `resumeSession`/`loadSession`; resume moot in the warm hot path, deferred to Phase 3.)*
- [ ] 2.4 Dispatcher routes `goose`/`qwen` to warm backend; keep one-shot fallback for arena/MCP
(or opt those into pool too — decide in review).
- [ ] **Smoke 2:** two messages in a goose chat reuse the same process + ACP session + worktree; - [ ] **Smoke 2:** two messages in a goose chat reuse the same process + ACP session + worktree;
reasoning still renders; no per-turn respawn. reasoning still renders; no per-turn respawn.
- [ ] **Smoke 2b (switch round-trip):** opencode → boocode → opencode in one chat — opencode - [ ] **Smoke 2b (switch round-trip):** opencode → boocode → opencode in one chat — opencode
resumes the SAME `agent_session_id` (memory intact), boocode saw opencode's turns as resumes the SAME `agent_session_id` (memory intact), boocode saw opencode's turns as
history, all three shared the one worktree, and no agent was locked to the chat. history, all three shared the one worktree, and no agent was locked to the chat.
## Phase 3 — Lifecycle hardening — ⬜ REMAINING ## Phase 3 — Lifecycle hardening — ✅ COMPLETE (`v2.6.10` 3.13.6; `v2.6.11` closed 3.7 + the apps/server close-hook caller)
> **Lift (design §10):** hardening from **openchamber** (MIT, same warm-opencode-server architecture) — health-monitor + crash auto-restart + busy-aware restart + port reclaim (`killProcessOnPort`/`waitForPortRelease`) + stall-SSE = a concrete state machine for 3.1/3.2/3.6. Reaper (3.3/3.4): Paseo worktree-archive cascade + superset destroy-saga (preflight dirty/unpushed inspect) + LRU cap on warm-server Maps. Do crash-recovery + reaper together (shared supervision loop). > **Lift (design §10):** hardening from **openchamber** (MIT, same warm-opencode-server architecture) — health-monitor + crash auto-restart + busy-aware restart + port reclaim (`killProcessOnPort`/`waitForPortRelease`) + stall-SSE = a concrete state machine for 3.1/3.2/3.6. Reaper (3.3/3.4): Paseo worktree-archive cascade + superset destroy-saga (preflight dirty/unpushed inspect) + LRU cap on warm-server Maps. Do crash-recovery + reaper together (shared supervision loop).
- [ ] 3.1 Idle TTL eviction keyed per `(chat, agent)`; reattach-on-next-turn from `agent_sessions`. - [x] 3.1 Idle TTL eviction per `(chat, agent)` (`AGENT_POOL_IDLE_TTL_MS`=30min) + LRU cap (`AGENT_POOL_MAX_LIVE`=10), busy never evicted; reattach next turn. Pure `lifecycle-decisions.ts` (TDD).
- [ ] 3.2 Crash recovery: opencode server restart recreates sessions; ACP re-`session/new`. - [x] 3.2 Crash recovery: openchamber health-monitor + busy-aware-restart + stale-grace state machine in `opencode-server.ts` (+ port reclaim) + `warm-acp.ts`. opencode → fresh sessions; ACP re-`session/new`. F.1 guard + U.6 usage preserved.
- [ ] 3.3 Chat close/archive hook → `closeSession` for every `(chat, agent)` + remove the - [x] 3.3 Close hooks (`/api/chats/:id/close`, `/api/sessions/:id/close`) → `closeChat` evicts backends + archives the `worktrees` row + removes the worktree. **apps/server caller wired in `v2.6.11`** (`coder-notify.ts`, fire-and-forget on session-delete + chat archive/delete).
chat's **`worktrees`** row + worktree (NOT `session_worktrees` — superseded P1.5-b); mark agent rows `status='closed'`. - [x] 3.4 Orphan worktree reaper (periodic, 1h grace, superset-style dirty/unpushed preflight, Paseo soft-delete) + LRU cap on the pool.
- [ ] 3.4 Orphan worktree reaper (extend periodic sweeper) + max-live-worktrees LRU cap. - [x] 3.5 Re-baseline `worktrees.base_commit` after a successful `apply_pending` (both apply routes).
- [ ] 3.5 Re-baseline worktree diff after `apply_pending`. - [x] 3.6 Reconnect integration test (DB-opt-in): restart mid-session → next turn reattaches/recreates from `agent_sessions`/`worktrees`.
- [ ] 3.6 Reconnect test: restart BooCoder mid-session → next turn reattaches/recreates cleanly. - [x] 3.7 Staging-boundary hint in DiffPanel (§9c) — `v2.6.11`: muted one-liner when the selected provider can't see another agent's unapplied worktree edits (derived from per-change `agent` + current provider; no new state).
- [ ] 3.7 Staging-boundary hint in DiffPanel (§9c): muted one-liner when the selected
provider can't see another agent's unapplied worktree edits (derived from per-change
`agent` + current provider; no new state).
## Tests — ⬜ REMAINING (none of T.1T.3 exist yet) ## Tests — ⬜ REMAINING (none of T.1T.3 exist yet)
@@ -102,8 +93,8 @@ ACP follows; hardening last.
## Remaining — recommended order (implementation plan, 2026-05-31) ## Remaining — recommended order (implementation plan, 2026-05-31)
1. ~~**F.1 interrupt-bug fix**~~ — ✅ shipped `v2.6.7-interrupt-guard` (3 regression tests, TDD). 1. ~~**F.1 interrupt-bug fix**~~ — ✅ shipped `v2.6.7-interrupt-guard` (3 regression tests, TDD).
2. **Phase 1-UX** (U.1U.6) — pure read+display over already-shipped `pending_changes.agent` + `agent_sessions`; no dispatch-logic or backend change, so it ships value on data that already exists. U.6 (token/ctx usage) rides the same opencode SSE. 2. ~~**Phase 1-UX** (U.1U.6)~~ — ✅ shipped `v2.6.8-agent-attribution` (3 parallel agents, disjoint files; 9 new tests). Smoke U pending the frontend Docker rebuild.
3. **Phase 2 — warm ACP, qwen first then goose** — qwen has a validated `--acp` reference; goose's missing resume is the open design question, so qwen de-risks the pattern. Smoke 2 + 2b (the switch round-trip success criterion). 3. ~~**Phase 2 — warm ACP, qwen first then goose**~~ — ✅ shipped `v2.6.9-warm-acp` (15 new tests; one-shot path preserved). Smoke 2 + 2b pending live exercise post-deploy.
4. **Phase 3 — lifecycle hardening** — lift openchamber's state machine; do crash-recovery (3.1/3.2/3.6) + worktree reaper (3.3/3.4 + LRU) together (shared supervision loop). Closes the two ⬜ success criteria (server-crash recovery, close→cleanup). 4. **Phase 3 — lifecycle hardening** — lift openchamber's state machine; do crash-recovery (3.1/3.2/3.6) + worktree reaper (3.3/3.4 + LRU) together (shared supervision loop). Closes the two ⬜ success criteria (server-crash recovery, close→cleanup).
5. **Tests T.1T.3 + `BOOCODER.md` (D.1 remainder)** — backfill alongside each phase, not at the end. 5. **Tests T.1T.3 + `BOOCODER.md` (D.1 remainder)** — backfill alongside each phase, not at the end.

View File

@@ -11,5 +11,5 @@
"devDependencies": { "devDependencies": {
"typescript": "^5.5.0" "typescript": "^5.5.0"
}, },
"license": "AGPL-3.0-only" "license": "MIT"
} }

103
pnpm-lock.yaml generated
View File

@@ -158,9 +158,9 @@ importers:
fastify: fastify:
specifier: ^4.28.1 specifier: ^4.28.1
version: 4.29.1 version: 4.29.1
parse5: node-html-markdown:
specifier: ^8.0.1 specifier: ^1.3.0
version: 8.0.1 version: 1.3.0
postgres: postgres:
specifier: ^3.4.4 specifier: ^3.4.4
version: 3.4.9 version: 3.4.9
@@ -2108,6 +2108,9 @@ packages:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@2.1.0: brace-expansion@2.1.0:
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
@@ -2270,6 +2273,13 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2344,6 +2354,19 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dotenv@17.4.2: dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2391,9 +2414,9 @@ packages:
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
entities@8.0.0: entities@4.5.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=20.19.0'} engines: {node: '>=0.12'}
env-paths@2.2.1: env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
@@ -2662,6 +2685,10 @@ packages:
hast-util-whitespace@3.0.0: hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
headers-polyfill@5.0.1: headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
@@ -3210,6 +3237,13 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-html-markdown@1.3.0:
resolution: {integrity: sha512-OeFi3QwC/cPjvVKZ114tzzu+YoR+v9UXW5RwSXGUqGb0qCl0DvP406tzdL7SFn8pZrMyzXoisfG2zcuF9+zw4g==}
engines: {node: '>=10.0.0'}
node-html-parser@6.1.13:
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
node-pty@1.1.0: node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
@@ -3224,6 +3258,9 @@ packages:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'} engines: {node: '>=18'}
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3287,9 +3324,6 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'} engines: {node: '>=18'}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -5904,6 +5938,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
boolbase@1.0.0: {}
brace-expansion@2.1.0: brace-expansion@2.1.0:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
@@ -6040,6 +6076,16 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
css-what: 6.2.2
domhandler: 5.0.3
domutils: 3.2.2
nth-check: 2.1.1
css-what@6.2.2: {}
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@@ -6083,6 +6129,24 @@ snapshots:
diff@8.0.4: {} diff@8.0.4: {}
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dotenv@17.4.2: {} dotenv@17.4.2: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
@@ -6130,7 +6194,7 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.3 tapable: 2.3.3
entities@8.0.0: {} entities@4.5.0: {}
env-paths@2.2.1: {} env-paths@2.2.1: {}
@@ -6519,6 +6583,8 @@ snapshots:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
he@1.2.0: {}
headers-polyfill@5.0.1: headers-polyfill@5.0.1:
dependencies: dependencies:
'@types/set-cookie-parser': 2.4.10 '@types/set-cookie-parser': 2.4.10
@@ -7196,6 +7262,15 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
node-html-markdown@1.3.0:
dependencies:
node-html-parser: 6.1.13
node-html-parser@6.1.13:
dependencies:
css-select: 5.2.2
he: 1.2.0
node-pty@1.1.0: node-pty@1.1.0:
dependencies: dependencies:
node-addon-api: 7.1.1 node-addon-api: 7.1.1
@@ -7211,6 +7286,10 @@ snapshots:
path-key: 4.0.0 path-key: 4.0.0
unicorn-magic: 0.3.0 unicorn-magic: 0.3.0
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -7289,10 +7368,6 @@ snapshots:
parse-ms@4.0.0: {} parse-ms@4.0.0: {}
parse5@8.0.1:
dependencies:
entities: 8.0.0
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-browserify@1.0.1: {} path-browserify@1.0.1: {}