Compare commits

...

303 Commits

Author SHA1 Message Date
Patrick Buckley 019d13d930 chore: bump version to 1.6.2 2026-06-11 20:53:13 -07:00
Patrick Buckley 8fbcbff566 test: zero out the suite's warning noise
121 warnings -> 0. Two upstream deprecations get narrowly-scoped
filterwarnings entries (the mcp streamablehttp_client rename — adoption
deliberately rides the v2 migration since the new entry point's call
shape changes again there; the starlette httpx TestClient notice). The
one real RuntimeWarning is fixed at the source: tests that mock
asyncio.run_coroutine_threadsafe handed real coroutines to a stub that
never awaited them, GC-firing 'coroutine was never awaited' inside
whatever unrelated test ran later (the same cross-test bleed mechanism
as the CI closed-stream spew — per-test filterwarnings markers cannot
catch it, which is why two such markers existed and still leaked). A
shared _dispatch_stub now closes real coroutines before returning the
canned future; the obsolete markers are removed.
2026-06-11 20:44:10 -07:00
Patrick Buckley a27738867f chore: cap mcp <2 ahead of the v2 breaking rewrite
mcp 2.0.0a1 shipped 2026-06-11 (stable targeted ~2026-07-27). v2 removes
streamablehttp_client, changes the transport tuple arity, and renames
mcp.types fields to snake_case — all of which our client imports. The
maintainers' release note asks downstream packages to add an upper
bound now (their worked example is this exact constraint). Floor stays
at 1.27: nothing newer adds anything our surface needs, and the #2147
shutdown busy-loop we wrap remains unfixed at every released version.
Resolution is unchanged (1.27.2); lockfile re-pinned metadata only.
2026-06-11 20:44:10 -07:00
renovate[bot] 1d0be9773f chore(deps): update docker images to v0.11.21 2026-06-11 20:44:10 -07:00
Patrick Buckley ad56e1ec96 fix(providers): require base_url for anthropic-compatible
Copilot review on #661: empty base_url let the SDK fall back to
https://api.anthropic.com, sending compat-shaped requests to the
commercial API. The lane is local-only by definition, and the /v1-strip
edge case already established fail-loudly-over-silent-prod-retarget;
apply the same principle to the empty case. create_client raises an
actionable ValueError; the admin Detect path surfaces it as a clean
error string via probe_model_endpoint's existing handler.
2026-06-11 20:44:10 -07:00
Patrick Buckley 8f0115ee2e feat(providers): anthropic-compatible lane for local /v1/messages servers
Add provider id "anthropic-compatible": the existing AnthropicProvider
pointed at Anthropic-compatible local servers (vLLM /v1/messages),
mirroring the openai/openai-compatible split. Registry-only — configured
via the admin Models tab or [models.*] toml, not exposed on the bare
--provider flag, so the CLI/server prod-URL defaults are unreachable for
the lane and real-Anthropic behavior is untouched.

Lane behavior (live-verified against vLLM 0.22.1rc1 + DeepSeek-V4-Flash):
- Capability defaults replace the Claude static table: token_param
  max_tokens, thinking_mode none, web_search/tool_search/vision off,
  reasoning replay on. vLLM rejects Anthropic server-side tool types
  (tools require input_schema) and ignores the thinking request param,
  so neither is sent; thinking blocks still stream back and round-trip
  through the native lane verbatim.
- Reasoning toggles via server_compat extra_body chat_template_kwargs
  (first-class vLLM request field; request-level keys beat server
  defaults). _build_thinking_and_kwargs forwards non-internal
  extra_params as SDK extra_body; thinking_budget_tokens stays internal.
- No temperature force: thinking_mode none skips the Claude-only
  temperature=1.0 requirement.

Admin UI: provider option + URL placeholder (base_url without /v1 — the
SDK appends /v1/messages); the server-compat section shows only the
extra-body field for the lane. thinking_mode round-trips through the
form dropdown for every provider except anthropic-compatible, where it
stays in the raw capabilities JSON — the edit-load lift and save restore
use the same predicate so stored overrides are never silently dropped.

Docs: architecture.md gains the lane subsection incl. verified quirks
(thinking param dropped by vLLM; stop_sequences cut inside thinking and
report end_turn; usage has no cache fields; images need a multimodal
model; mid-conversation system turns are per-model opt-in).

Negative-tested: removing the _INTERNAL_EXTRA_PARAMS exclusion fails
test_internal_keys_not_leaked; the live test drives a streamed turn with
the chat_template_kwargs toggle and asserts no reasoning deltas.
2026-06-11 20:44:10 -07:00
Patrick Buckley 7aba631201 fix(mcp): close the shutdown drain race + close the owned loop
Review feedback: (1) gating the drain on a main-thread truthiness check
of _background_tasks could skip cancellation when a spawn queued via
call_soon_threadsafe had not reached the set yet — submit whenever the
loop is RUNNING and snapshot on the loop, where FIFO callback order
guarantees earlier-queued spawns have landed; (2) shutdown stopped the
loop thread but never closed the loop or cleared _loop/_thread, leaking
selector resources for embedders that cycle managers — close + clear
when we own the thread and it actually stopped (loud warning when it
does not); unowned loops (tests wiring _loop directly) stay untouched;
(3) the bare await-in-suppress drain loops become
asyncio.gather(return_exceptions=True) in both the shutdown drain and
the test fixture.
2026-06-11 20:44:10 -07:00
Patrick Buckley f3f5e84f2d fix(mcp): track fire-and-forget background tasks; harden loop teardown
The post-reconnect catalog refresh was scheduled as a bare
asyncio.create_task: no strong reference (the task could be GC'd
mid-flight, so the refresh might silently never run) and no exception
retrieval (failures surfaced as "Task exception was never retrieved"
at GC time — in CI, onto an already-closed pytest capture stream, the
"I/O operation on closed file" spew; a suspected contributor to the
flaky 60-minute CI hangs via cross-test loop/task state bleed).

- _spawn_background(coro, label): tracked-task set + done-callback
  that retrieves and logs failures at warning; discard runs LAST so
  set-emptiness means "done AND reported"
- shutdown() drains tracked tasks FIRST, so stack teardown can't race
  an in-flight refresh; same run_coroutine_threadsafe idiom and
  timeouts as the existing close steps
- running_loop_mgr fixture: cancel-pending -> drain -> stop ->
  join(5) with a loud assert -> loop.close() (was stop + silent
  join(2), never closed)
- the false-property test ("swallows refresh failure" — nothing
  swallowed it) now waits for completion and asserts the logged
  warning via the patched module logger (structlog; caplog cannot
  observe it), polling inside the patch context
2026-06-11 20:44:10 -07:00
Patrick Buckley ff1e3e5c1c fix(storage): enforce orphan-ness inside the purge DELETE + chunk IN-lists
Review feedback on the purge's race window: the pre-SELECT re-verify
left a statement-to-statement gap where a concurrent registration could
still lose rows — and the pre-counted refcount release could underflow
when it didn't. Orphan-ness now rides the DELETE itself (correlated
NOT EXISTS) with refcounts released from its RETURNING, so refs are
released for exactly the rows that were deleted. Input is de-duplicated,
IN-lists chunk at the storage layer's 500 convention, and the scan's
per-workstream ref-count loop is now one anti-join pass.
2026-06-11 14:13:13 -07:00
Patrick Buckley f0d7305b28 feat(admin): orphan-conversations maintenance verb — scan + purge
Conversation rows whose workstreams row is gone (historical unregistered
writers; the delete-during-inflight race re-creating rows after
delete_workstream) are invisible cruft that also pins attachment
refcounts. Add a turnstone-admin verb: default = read-only scan report
(ws_id, rows, attachment refs, first/last); --delete [--yes] purges.

- shared find/purge logic in storage/_utils; protocol + both backends
  in lockstep (thin wrappers)
- purge re-verifies orphan-ness in-transaction: a ws_id re-registered
  between scan and purge is skipped, never deleted
- releases the deleted rows' attachment refcounts through the
  delete_workstream GC path and sweeps workstream_config/overrides
- summary reports actual purge results, including the skipped clause
2026-06-11 14:13:13 -07:00
Patrick Buckley 84a545cb21 fix(ui): re-home MCP consent badge on the Manage Connections row (#657)
* fix(ui): re-home MCP consent badge on the Manage Connections row

The L-shell renovation retired the standalone settings gear (#settings-btn).
The MCP pending-consent badge anchored to that gear via _refreshConsentBadge,
which null-guarded silently — so since the renovation pending consent requests
had no indicator (the badge was invisible).

Re-home the badge on the rail's Manage row where the MCP/connections surface
lives in both deployments:

- rail.js gains a generic setRowBadge(tabKey, count, label?) hook + a `badge`
  builder: a small ⚠-glyph + count chip (never colour alone) using the DS warn
  tokens. mountManage registers row + owning-group-head refs and re-applies live
  counts across a (re)mount. When the owning group is collapsed, the count also
  mirrors onto the group head so a hidden row never hides the signal. rail.js
  stays agnostic — it owns the mechanism, the caller owns the meaning.
- shell.js (the ESM bridge) re-exports setRowBadge on window.TS_SHELL so the
  classic ui/static/app.js subsystem can drive it without importing the module.
- The standalone consent subsystem keeps its shell-level ownership: _refresh-
  ConsentBadge now drives setRowBadge on the Connections tab, fed by both the
  loadPendingConsents hydrate/poll load and live onConsentDetected notifications.
- The shared interactive pane host bridges onConsentDetected to the new
  window.TS_APP.onConsentDetected seam (undefined on the console, so the console
  pane stays a no-op there); panes only notify.
- The dead colour-only gear badge CSS (.settings-consent-badge, red dot) is
  removed; the new chip lives in shell.css as token-only .rail-badge so it
  flips themes by construction.

Console MCP tab (Extensions > mcp) and standalone Connections tab
(Extensions > connections) both badge correctly. Pins extended in
test_shell_js.py + test_app_js.py.

* fix(ui): drop the unused head ref from the rail badge row map

Review feedback: _rowEls stored each row's group-head element but every
head consumer resolves it through _groupEls; keeping the duplicate DOM
ref made the remount state shape harder to reason about.
2026-06-11 14:13:13 -07:00
Patrick Buckley 8e11929ba0 chore: bump version to 1.6.1 2026-06-11 14:09:31 -07:00
Patrick Buckley d9e9a41b17 test(console): make dedupe-pin slice bounds reformat-tolerant
Review feedback: the next-case end markers were exact-indentation
string finds that raised a bare ValueError when unmatched. Use
whitespace-tolerant regexes with actionable assertion messages, and
bound the history-replay window structurally (next role branch, with
a generous fallback) instead of a fixed 600 chars.
2026-06-11 14:05:19 -07:00
Patrick Buckley 848b2cc1fb fix(ui): single-path Enter activation + hls.js teardown on player error
Review feedback: (1) the Enter keydown re-dispatched through btn.click(),
relying on the disabled-guard to suppress the browser's own
Enter-to-click — preventDefault + direct activation makes the keyboard
path provably single-fire; (2) the branch-scoped Hls instance was
unreachable from the media error handler, leaking its listeners and
loader timers when the player node was replaced with the retry UI —
hoist the ref and destroy it before replacement.
2026-06-11 14:05:19 -07:00
Patrick Buckley 1946002618 fix(ui): lift media player activation into the shared interactive pane
The interactive Pane renders media embeds (buildMediaEmbed / buildPlayButton),
but the Play activation — _loadHls / _isHlsUrl / _activatePlayer and the
click/keydown delegate — stayed behind in the standalone ui/static/app.js as
DOCUMENT-level listeners. The console L-shell mounts the same interactive.js
module but never loads ui/static/app.js, so the Play button was dead in
console-hosted interactive panes.

Lift the activation into shared_static/interactive.js (alongside the existing
buildMediaEmbed/buildPlayButton — media embeds are interactive-pane-only; the
coordinator pane renders none) and wire it as a pane-owned, root-scoped
this.el click/keydown listener, mirroring the approval-keydown pattern the
fork collapse established. The standalone copy is deleted so no duplicate
implementation remains; both deployments now activate through the one shared
handler.

The hls.js vendor is fetched lazily by absolute /shared/ URL (the same
mechanism renderer.js uses for mermaid), and /shared is mounted at the root in
both turnstone/server.py and turnstone/console/server.py, so the vendor —
which ships in shared_static/hls-1.6.16/ — resolves in both deployments with
no HTML change.

Pins: assert the lift + pane-ownership in test_interactive_pane_js.py and the
standalone-stays-clean guard in test_app_js.py.
2026-06-11 14:05:19 -07:00
Patrick Buckley 4bce6abc7c test(console): pin system-turn dedupe wiring on both read paths
The live-SSE/history system-turn dedupe (renderedSystemEventIds /
_renderedSystemEventIds) was already in place on both panes and merged
to main (21af6c4 aligned the persisted row event_id with its SSE event;
09e41d1 added the belt-and-braces Set on the coordinator). The existing
pin tests only assert the Set's .has()/.add()/.clear() symbols appear
somewhere in the file, so a refactor that keeps the Set but short-circuits
the live-handler consultation (guard -> false) re-opens the double-render
while the pins stay green.

Scope the new assertions to their blocks: the live system_turn case must
CONSULT and RECORD against the Set, and the history render path
(replayHistory / refetchHistory's system-role branch) must record each
replayed row's event_id. Bounded at the next switch case rather than the
first break; the dedup-skip path itself breaks before the .add(), so a
break-bounded slice would drop the record half.

Verified the new slice checks fail on a dedupe-neutered factory (a
headless-Chrome harness driving the real createCoordinatorPane confirms
that neutering produces two rendered nodes for one event id; intact code
renders one, and the no-event-id legacy path still renders both).
2026-06-11 14:05:19 -07:00
Patrick Buckley c19432f12a fix(memory): touch access metadata on composition and tool reads
The touch_structured_memories facade and both storage backends were
implemented but had zero call sites, so access_count never moved and
last_accessed never advanced past write time on any deployment.

Wire two touch points:
- proactive composition touches the injected top-k (post-rerank) set,
  deduped per turn since _init_system_messages recomposes many times
  within a single turn;
- the memory tool's search and get reads touch their returned rows,
  counted per call. save/delete/list do not touch.

Touches are best-effort through the facade, which already swallows
storage errors, so a failed touch never breaks composition or a tool
call.
2026-06-11 14:05:18 -07:00
Patrick Buckley 23007e3ac5 chore: bump version to 1.6.0 2026-06-10 22:21:03 -07:00
Patrick Buckley f44886a55f docs: 1.6.0 changelog + release-track policy (#653)
* docs: 1.6.0 changelog — roll up the 1.5→1.6 line for stable

Replaces [Unreleased] with the 1.6.0 section: 320 main-only commits
since the stable/1.5 divergence grouped into theme bullets (license,
trajectory/migration-060, web search, rerank/memory, approvals/judge,
L-shell, shelf, SSE, providers, cluster ops, security). Breaking
changes aggregated up top; migration-060 backup callout reshaped from
discussion #631 for the stable audience.

* docs: add the stable/1.6 track to the changelog preamble

* docs: retire the stable/1.4 track — current + one prior policy

Changelog preamble down to three tracks with the policy stated;
1.4 retirement noted in the 1.6.0 Removed section (final release
v1.4.0; tags/artifacts remain, BUSL-1.1 as shipped). releasing.md
track table, policy bullet, and examples brought up to the 1.6.0
promote cycle — the doc was still describing the 1.4-stable era.
2026-06-10 22:19:58 -07:00
Patrick Buckley 3803feb008 fix(console): uniform not_found wait-entry keys + ws_ids param precision
PR #652 review follow-ups:
- not_found snapshot entries now carry the full key set (updated/name
  empty) so results[ws_id] is shape-uniform across states; pinned by a
  key-set assertion in the sentinel test
- ws_ids param text now distinguishes malformed (fails before any
  waiting) from well-formed-but-unobservable (first-tick abort) at
  unchanged length — per-param descriptions stay lean by policy
2026-06-10 22:03:08 -07:00
Patrick Buckley d0e9aa3dbe fix(console): coordinator ws-ref validation, did-you-mean recovery, wait fail-fast
Field incident: the coordinator LLM hand-copied a child ws_id and
collapsed its aaa run to a, producing a 30-char id. inspect said "not
found", wait called it "denied", neither offered recovery, and the model
concluded the child was dead and dropped the lane — silent report
degradation while the child kept working.

- validate model-supplied ws_id args at the tool boundary
  (send/close/cancel/delete/inspect/wait): full 32-hex ids pass through
  at unchanged storage cost; a child's exact legacy id still resolves;
  anything else fails fast with a did-you-mean (capped Levenshtein <=3
  over the coord's own children) plus a child roster. Near-misses never
  auto-resolve; display names are not addresses (mutable, non-unique) —
  a name ref errors with a pointer at the right id
- wait_for_workstream: rename per-entry state "denied" -> "not_found"
  with an honest sentinel; malformed refs error before any waiting
  (invalid_ws_ids); a well-formed id that is foreign, missing, or
  hard-deleted mid-wait aborts the wait on the tick that observes it
  instead of burning the timeout (mode=all was unsatisfiable) or riding
  along to complete=True (silent lane loss); mode=all completes only
  when every id is real-terminal; entries carry the child display name
- one not-found payload across all verbs: foreign and nonexistent stay
  byte-identical (no existence oracle), hints reference only the coord's
  own children, echoed refs clipped in error strings; invalid_ws_ids and
  not_found share one per-ref shape with the roster hoisted top-level
- inspect ownership now requires user_id parity via _row_in_own_subtree,
  matching the wait/mutating gates (#506) — closes the forged-parent
  cross-tenant read
- session exec serializes the structured recovery payload (results +
  did_you_mean + children) on unresolvable-id wait errors instead of
  collapsing to the bare error string
- tool JSON descriptions + coordinator docs updated to the new contract;
  incident regression test pins the captured aaa-collapse ids
2026-06-10 22:03:08 -07:00
Patrick Buckley 81a3eaecce chore: relicense BUSL-1.1 → Apache 2.0 for 1.6.0 (#651)
* chore: relicense BUSL-1.1 -> Apache 2.0 for 1.6.0

Flips every license artifact in the tree; 1.5.x and earlier remain
BUSL-1.1 per their release-time LICENSE files. Contributor consent
record: #548 (rationale: #546).

- LICENSE: canonical Apache 2.0 text
- NOTICE: new; copyright line + pointer to THIRD-PARTY-NOTICES
- pyproject.toml: SPDX expression + explicit license-files trio
- Dockerfile: COPY the license trio (hatchling needs them at build)
- THIRD-PARTY-NOTICES: BUSL line reworded; bundled-version drift
  fixed (KaTeX 0.17.0, Mermaid 11.15.0, hls.js 1.6.16)
- README badge + License section, CONTRIBUTING inbound-license line,
  TS SDK package(+lock), example pyproject
- docs/pgbouncer.md: drop stray ':' introduced in #353

* docs: add CONTRIBUTORS.md

* chore: drop LICENSE leading blank line

The apache.org LICENSE-2.0.txt begins with a newline; the SPDX
canonical text and GitHub license templates do not. Use the
conventional form — detection is whitespace-normalized either way.
2026-06-10 21:03:16 -07:00
Patrick Buckley 9b75a12848 fix(server): re-author --skip-permissions CLI flag wiring
Independent re-implementation of the --skip-permissions argparse flag,
OR-ed with the tools.skip_permissions config-store setting at both
consumption sites. Written from the flag's pre-existing spec (the
--help epilog and compose.yaml, which referenced it before #450
existed).

Replaces reverted #450 so that 1.6.0 ships no non-consented
contributions under Apache 2.0. Provenance record in #548.
2026-06-10 20:37:44 -07:00
Patrick Buckley 695a335744 Revert "fix(server): accept --skip-permissions CLI flag (#450)"
This reverts commit 2cdf87b115.
2026-06-10 20:37:44 -07:00
Patrick Buckley 9e0c86e78a fix(console): persona-btn radius onto the r-sm token
Copilot round: the 3px literal (carried from the hatch .seg segments)
disagreed with the shared :focus-visible rule, which restates
border-radius as var(--r-sm) — so the corner radius popped on keyboard
focus. One token, no jump.
2026-06-10 20:11:45 -07:00
Patrick Buckley c15dcee1f5 ui(console): launcher persona toggle wears its kind — amber coord / cyan int
The dashboard launcher's Coordinator|Interactive radiogroup styled its
active option as a neutral panel highlight — two faint text links that
said nothing about WHAT was being chosen. The active option now takes
the kind vocabulary the rest of the shell already speaks (.ptag.coord
amber / .ptag.int cyan): 15% kind tint, kind-colored label, and a kind
LED dot, in a recessed .seg-style track. Colour is never alone — the
LED + label weight carry the state, and the JS contract (classList
toggle on .active, aria-checked, roving tabindex) is untouched.
2026-06-10 20:11:45 -07:00
Patrick Buckley b3c3acc5d1 fix(scripts): livepass dialog-tier riders + loud open-failure + dock-displacement probe
Designer-review round on the scroll fix found the harness's dialog-tier
gate silently green: confirm-dialog (and install/coord-delete) markup
lives OUTSIDE #admin-layout, so the fragment extraction never embedded
it — ?open=confirm threw at showConfirmModal and screenshot a normal,
dialog-less page. build() now injects every hatch dialog the fragment
does not already contain, and a driven ?open= that ends with no open
dialog stamps OPEN-FAILED-<state> into the title instead of passing.

Also upstreams the review's probe states: &focuslast=1 focuses the last
shelf-body control (the displaced-dock regression class — only .sh-body
may scroll; head/foot must stay pinned) and &scrolled=bottom shows the
24px scroll tail.
2026-06-10 18:51:31 -07:00
Patrick Buckley 6bb47cad2d fix(console): manage-pane scroll regressions — interior scroller, clip the hatch-host, anchor hidden inputs
The L-shell height-pins the admin chain and .hatch-host clipped it, so no
box below the pane could scroll: tabs taller than the pane were cut dead,
and the overflow:hidden host doubled as a hidden scroll container that
focus-into-view silently scrolled — visually-hidden toggle/cap/radio
inputs escape the .sh-body scroller (abspos under an unpositioned label),
overhang the shelf, and a Tab keypress shoved the docked hatch off its
head with no scrollbar to recover by.

- .admin-content becomes the manage pane's interior scroller (the #main
  precedent); switchAdminTab resets it on real tab changes only
- .hatch-host: overflow hidden -> clip — paint clipping without a scroll
  container, so focus can never displace the dock
- position:relative anchors on the three hidden-input labels
  (toggle-switch, .sh-body .cap, segmented-option); .settings-toggle
  already carried one
- livepass: the console harness wraps the fragment in the REAL L-shell
  chain (its bespoke height pin is exactly how this bug class stayed
  invisible to the screenshot gates) and gains a ?tall=1/&scrolled=1
  scroll state
2026-06-10 18:51:31 -07:00
Patrick Buckley 160062235a chore: bump version to 1.6.0rc2 2026-06-10 13:35:47 -07:00
Patrick Buckley b991dc2e83 fix: CI lint pin + copilot-thread hardening
The wiring-lint test used percent-formatted regex patterns — UP031 under
the ruff 0.15.6 the CI pre-commit pins (the older venv binary let it
through; checked repo-wide against the exact pin now). f-strings with
doubled quantifier braces, plus one over-long fixture line in the
livepass generator split.

Copilot threads, both validated rather than blindly applied:
- closeShelf's scrim-ownership scan now skips detached entries. The
  thread's throw scenario doesn't occur on the real removal path (a pane
  close detaches an ANCESTOR, so _hostOf still resolves inside the
  detached subtree) — but a detached shelf is genuinely not a scrim
  owner, so the guard is correct beyond being defensive.
- toast.js drops the popover attribute via removeAttribute instead of
  the null assignment. The claim that null leaves popover="null" is
  refuted — the IDL is nullable and null removes the attribute (verified
  empirically in headless Chrome) — but removeAttribute reads correct
  without requiring that spec knowledge.
2026-06-10 13:31:53 -07:00
Patrick Buckley 9102f858a4 chore(scripts): commit the livepass harness generator
The livepass harness — the headless-render rig that verified every
converted modal surface and click-drives submits (the dead-Save bug
class) — lived as ad hoc files in /tmp and got wiped once already.
The durable piece is the GENERATOR: the markup is extracted fresh from
the index files at build time (a committed snapshot would drift) and
the stylesheets/scripts are symlinked so edits are live on refresh.

scripts/livepass.py builds both harnesses into /tmp/livepass/ (ui:
all six dialog-tier surfaces incl. the real cards.js batch controller
drive; console: the admin-pane fragment hosting the shelves, with
schedule/model/policy/confirm/token fixtures and the model-save click
drive that flips document.title to PUT-OK-<n>). --serve included;
the chrome screenshot incantation and the ?open= registry are in the
module docstring. Governance fixtures (roles/HR/OGP/memory/skill) are
documented seams for when those surfaces need driving.
2026-06-10 13:31:53 -07:00
Patrick Buckley 8b5af71603 fix(console): wire the model shelf's Save button — and lint the whole class
The models conversion dropped the legacy onclick= from the submit button
and wired detect/recalibrate/capgrid/thinking in the boot IIFE — but never
the submit itself. submitCreateModel existed with nothing calling it: Save
clicked dead with no error, exactly what a live test surfaced. None of the
gates could see it — the markup lint checks anatomy not wiring, the review
finders verified the submit function's internals, and the livepass renders
never clicked Save.

Audited every id-bearing button inside every dialog.hatch across both apps
for click wiring: model-create-submit was the only true positive (the
batch confirm buttons wire through cards.js's $() prefix helper and
new-ws-submit wires 106 lines from its getElementById — audit false
alarms). The new test_every_hatch_button_is_wired pins the class: direct
getElementById wiring or wiring through the assigned variable, with the
two prefix-built cards.js ids allowlisted; verified it fails against the
pre-fix tree. Livepass now drives the actual click: Save → busy → one PUT
→ shelf closes + toast.
2026-06-10 13:31:53 -07:00
Patrick Buckley f40404bfb9 fix: review-gate round — croniter calendar guard, results teardown, hidden war
Sixteen-finder review (4 dimensions x 4 subsystem slices) + adversarial
verify: 16/16 findings confirmed, all fixed.

Majors:
- The schedule preview (and create/update via _compute_next_run) 500'd on
  syntactically-valid-but-impossible cron dates: croniter.is_valid passes
  '0 0 30 2 *' but get_next raises CroniterBadDateError. One _next_cron_runs
  helper now owns construction + the guard for both paths; the preview
  answers its 200/valid:false contract, and next[] is one shape (the cron
  branch now carries the UTC offset the 'at' branch always had).
- The batch-delete results view tore down (exit delete mode, refresh the
  stale list) only via the footer Close — header ✕ / Escape / backdrop left
  deleted rows on screen and the mode stuck. The teardown moved onto the
  dialog's onClose (gated by a resultsShown flag so a pre-delete cancel
  keeps the selection), and Close just closes.
- The model shelf's Server-compatibility section was permanently invisible:
  the one hidden-attr element still toggled via style.display, which cannot
  beat .hatch [hidden] !important — openai-compatible operators could never
  reach server type / API surface / extra-body. Now .hidden like its
  siblings.

Minors: shelves prune detached entries when a pane closes mid-edit (state
map + Escape-listener leak); the capabilities autofill gains the
_schPreviewSeq stale-response guard; the rename dialog focuses its input
before select() (select() does not move focus per spec — Enter landed on
the ✕); .mcp-install-source-label becomes the fourth protected label
component; nine write-only shelf-handle vars dropped; one alert region
gets one name; orphaned .modal-col-heading CSS, the stale toast z-index
rationale, a dangling divider comment, and a comment chasing the renamed
_submitRoleShelf all cleaned. Regression tests pin the Feb-30 preview,
the create-path guard, and the uniform next[] shape.
2026-06-10 13:31:53 -07:00
Patrick Buckley 472f12e14a fix(ui): phase-3 design-review round — wrong-target rename, error hygiene, focus
Dual review of the dialog-tier work (primed + cold), adjudicated:

- Menu-launched dialogs lost focus return: openPopupMenu's close() removed
  the focused item before the action ran, so dialogs captured <body> as
  their opener and the close-restore no-op'd. The menu now hands focus to
  its return target before invoking the action — repairs every
  menu→dialog flow in both apps.
- Rename targeted the wrong workstream: submitEditTitle re-read the
  ACTIVE pane's id, so renaming a background tab via its context menu
  renamed whichever pane was focused. The dialog now pins its target at
  open (pre-existing bug, carried from the legacy overlay).
- Batch-failure rows printed raw HTML error pages verbatim (proxy 502s,
  gateway timeouts): bodies are stripped of style/script content and
  markup before display, with an HTTP-status fallback.
- The two single confirms behaved differently in flight — delete-ws
  closed optimistically while revoke held under the busy lock. Unified on
  hold-open-with-busy: failures keep the user's context for retry.
- Batch results: failures announce through the live sh-alert (previously
  dead markup), a clean run flips the chrome to the success kind (red
  head over '3 deleted, 0 failed' disagreed with the de-dangered foot),
  and focus lands on Close after the state swap.
- Ghost-button boundaries measured ~1.2:1 on the foot strip (WCAG
  1.4.11): ink-mix borders routed through a variable so kind variants
  keep their own border colors; reduced-motion busy gains a static ' …'
  cue; dark label-hint opacity brought above the compound 4.5:1 line.
- Revoke voice unified ('Revoke connection', no '?'); batch alertdialogs
  gain aria-describedby; dead _settingsTrap machinery removed.
2026-06-10 13:31:53 -07:00
Patrick Buckley 1dda974b3e test(ui): align the app.js pins with the dialog-tier revoke confirm
The revoke confirm is no longer a role=dialog/aria-modal overlay with
page-local CSS — it's a hatch dialog-tier alertdialog whose chrome lives
in /shared/hatch.css.  The markup pin follows the new shape and the
stylesheet pin list drops the retired #revoke-mcp-overlay rule.
2026-06-10 13:31:53 -07:00
Patrick Buckley e2693764bf feat(ui): batch-delete confirms onto the dialog tier — both hosts, one builder
The shared cards.js multi-select controller serves the ui Saved
Workstreams AND the console Saved Coordinators, so the builder and its
two host markups convert as one unit: #ws-delete-dialog and
#coord-delete-dialog are md danger dialogs whose list renders inside the
sh-body (the only scroll region — the 200px inner list cap dies).

Foot grammar: [N selected meta] [Cancel data-close, autofocus] [red
filled "Delete N workstreams"] — the count moves out of the body prose
into the meta and the action label.  The fan-out brackets with setBusy
(LED pulse + action lock replace the disabled/'Deleting...' swap); the
results view swaps the action to a neutral Close and hides Cancel (a
Cancel beside a Close is the redundant dismissal pair the foot grammar
forbids).  The dormant error region becomes the sh-alert.

The controller's hand-rolled focus trap, prevFocus bookkeeping and
overlay display toggles die — hatch.js owns trap/Escape/backdrop/focus
restore; the post-results Close still hands focus to the section toggle
the bar collapse just rebuilt.  The ws-delete-modal-* CSS family leaves
cards.css (only the row treatment survives); the dead window wrappers
(cancelWsDelete/confirmWsDelete + coord twins) and the ui keydown
handler's last legacy-overlay branch go with it.
2026-06-10 13:31:53 -07:00
Patrick Buckley c592486046 feat(ui): standalone app onto the hatch dialog tier — new-ws, rename, confirms
The ui app has no admin pane host and new-ws is a launcher invokable from
anywhere, so every surface lands on the document-modal DIALOG tier (no
shelves).  hatch.css gains the one md dialog width (560) the v1 design
specified; the ui index links hatch.css/hatch.js alongside the other
shared assets.

- New workstream: md create dialog, WS-NEW plate; the fork path keeps its
  title/semantics (WS-FORK plate, skill + attach rows hidden via the
  hidden attribute).  Submit brackets with setBusy; errors land in the
  sh-alert with the scroll-into-view rule.
- Rename: a styled prompt() — single field, Enter submits, no plate.
- Delete-workstream + revoke-MCP mirror the console confirm exactly:
  danger chrome, prose body, Cancel autofocus, red filled action.

The four hand-rolled focus traps, the Escape/overlay-click dispatch and
the body-overflow lock die (native dialog + hatch.js own all of it); the
global-shortcut handler defers on dialog:modal instead of the overlay-ID
list.  The forked legacy modal CSS leaves style.css (~270 lines).  The
markup-shape lint now scans both index files, the asset assertion and the
parse-time-bridge guard extend to the ui app.
2026-06-10 13:31:53 -07:00
Patrick Buckley c1a0417f10 fix(console): design-review consistency round — plates, verbs, hints, focus
Cosmetic/consistency findings from the dual shelf review, adjudicated;
accepted items:

- One designation-plate grammar: 2-4 char domain code + closed suffix
  vocabulary. SKILL-GH → SKL-IMPORT, PP-* → PPO-*, ROLE-MAP → USR-ROLES,
  ROLE-* → ROL-*; HR-/OGP- stay (established judge domain terms).
- Create titles say "New <thing>" (Add model / Add MCP server renamed;
  "New prompt" was creating a prompt POLICY); "Edit model" gains the
  "— {alias}" suffix every other edit surface carries.
- Shelf primaries uniform single-word Create/Save — the skill shelf's
  "Create skill" / "Save config" / "Save changes" collapse.
- label-hint dialect normalized to bare lowercase (wrapping parens
  dropped); the em-dash unit form "— UTC" is a different species, kept.
- Placeholder-only format instructions promoted to label-hints (a11y
  3.3.2): HR confidence "0.0–1.0", intent "supports {arg_snippet}". The
  install dialog's dynamic required asterisk pairs with required on the
  control + aria-hidden on the glyph.
- The mcp and schedule adjacent toggle pairs wrap in toggle-stack like
  the model shelf; the sch-enabled-row hidden toggle rides inside.
- memory-detail foot grammar: Delete demoted from err-filled to a quiet
  destructive text action (.sh-btn--quiet-danger, doubled class so it
  beats the later-loaded hatch.css quiet color), Close stays rightmost.
- First-focus: user-roles lands on its first role toggle once rendered,
  channel on the type select, builtin-role edit on the first enabled
  control, mcp-install autofocuses its primary.
- The watch-cancel confirm's action reads "Stop watch" — no more
  Cancel-beside-Cancel.
- A locked skill opens as data-kind=inspect (cyan read-out chrome) and
  flips to edit on the in-place unlock re-render.
- mcp-detail healthy node dot pairs with a dim "connected" text token,
  mirroring the error-text sibling (state was color-alone).
- etm-scan section converts to the hidden attribute (last
  style.display straggler on the skill shelf).
2026-06-10 13:31:53 -07:00
Patrick Buckley 360ed48f62 fix(ui): design-review round — busy lock holes, broken auth cards, contrast
Dual design review of the shelf stack (one primed on intent, one cold)
adjudicated; accepted findings:

- The busy lock held the door for the mouse only: Enter on the focused
  primary re-fired submits, scrim clicks closed a shelf mid-flight, and
  the dialog tier's native Escape (cancel event) dismissed a busy confirm.
  One capture-phase guard + a scrim busy check + cancel interception close
  all three for every surface; setBusy now announces aria-busy. Contract
  assertions added to the busy test (it previously claimed scrim coverage
  it didn't assert).
- The MCP auth radio cards were destroyed by the .sh-body label cadence —
  the exact specificity war the toggle-switch/cap exceptions guard
  against, missed for .segmented-option. Restated at 0,2,1.
- Light-theme off-state toggle tracks measured ~1.2:1 (WCAG 1.4.11 wants
  3:1): real ink fill on light, recessed look kept on dark.
- Failed submits on tall shelves rendered out of view — _showModalError
  scrolls the alert into view. user-roles gains the missing busy bracket
  and in-shelf errors instead of toast-only.
- Toasts render under the dialog tier's top layer: promote the toast to a
  manual popover only while a modal dialog is open (popovers stack above
  later dialogs); attribute dropped after so the everyday fade survives.
- Ambient glow now follows the kind accent (cyan/red surfaces no longer
  bloom amber); skill editor's content column pins sticky so the SKILL.md
  pane stays visible while the meta column scrolls; MCP-detail stacking
  re-keyed from viewport to @container pane; dark-body micro-text and the
  origin badge brought up to the contrast floor; quiet foot actions
  (Validate regex, Detect) lifted from body-copy gray.
2026-06-10 13:31:53 -07:00
Patrick Buckley 69fb8c3140 fix(console): help popover consumes its Escape — layered shelf dismissal
With the popover and a shelf both open, Escape closed both at once
(admin.js's popover listener and hatch.js's shelf listener each fired).
The popover listener registers at parse time — always ahead of hatch.js's
first openShelf — so stopImmediatePropagation makes Escape peel one layer
at a time: popover first, shelf on the next press.
2026-06-10 13:31:53 -07:00
Patrick Buckley d896108ae0 chore(console): tear down the legacy modal machinery — the shelf sweep is total
With the skill editor converted, nothing renders through the legacy
overlay system any more. Grep-driven deletion:

- admin.js: _modalFocusTrap/_installTrap/_removeTrap, the govOverlays
  dispatch table, and the global Escape handler die. The handler's one
  still-live block — closing an open settings-help popover — is
  extracted into its own small keydown listener (the settings panels
  and the shelf form help buttons share that component).
- governance.js: the orphaned template trap/trigger let declarations.
- style.css: the .admin-modal box rules (incl. -wide/-skill), every
  .admin-modal-prefixed half of the doubled toggle/segmented/perm/
  user-roles selectors (the unscoped twins keep serving the settings
  panels and shelf bodies), .admin-details (the shelf uses
  details.rawhatch), .modal-buttons/.modal-cancel/.modal-submit,
  .modal-section-divider, hr.toggle-group-divider, the now-empty
  #…-template-overlay ID rule, and their reduced-motion entries.
  Stale comment pointers re-aim at the live rules (.sh-alert,
  .sh-body label).

Kept with reason: .modal-columns/.modal-col* — _openMcpDetail still
builds the MCP-detail shelf body with them (their narrow-viewport
stacking rules survive in a rebuilt media block).

Livepass re-verified after the teardown: zero console errors across
create/edit/locked, and the unlock-confirm-over-shelf capture is
pixel-identical to the pre-teardown one.
2026-06-10 13:31:53 -07:00
Patrick Buckley ecef600c0b feat(console): skill editor onto the xl shelf — create/edit merge, lock in the head
The last legacy modal pair (create-template + edit-template, ~430 lines
per mode) collapses into one pane-scoped 920px shelf: a hidden skl-id
decides POST vs PUT, the duplicated ctm-/etm- field sets merge into one
skl-/sklc- set, and the skill-spec two-column grid transplants whole into
the scrolling body (content-area rule re-scoped under .sh-body so the
shelf's font:inherit/min-height cadence doesn't flatten it; the mobile
breakpoint becomes a pane container query to match the shelf's own
bottom-sheet degradation).

Everything judgment-bearing carries over: paste-to-parse (now cancelled
via the shelf's onClose so Escape/scrim dismissals abort the inflight
parse too), live {{variable}} detection (re-dressed as match-strip chips
under the textarea, count mirrored into the foot meta when provenance
isn't occupying it), pending-resource rows in create, server-backed
resources + security scan + re-scan in edit, and the runtime-config
field set that stays editable on readonly skills (must keep matching
SKILL_RUNTIME_CONFIG_FIELDS). The readonly lock affordance moves into
the head strip left of the designation plate as a ghost icon button;
origin/locked provenance renders in the foot meta lane (installed/
customized chip + source URL + 'locked — unlock to edit'); the unlock
confirm stacks above the shelf via the native top layer and the
post-unlock re-render mutates the open shelf in place. Progressive
disclosure keeps its <details> semantics on rawhatch chrome, with the
shared-DOM state leaks (disabled spec fields, expanded details) re-armed
on every create open.

Mode-exclusive blocks keep their ctm-/etm- ids (pending vs server
resources, scan) — only true duplicates merged. Inline onclick wiring
moves to a one-shot _skillShelfWire; submits go busy via the shelf LED
lock. The legacy overlay markup and the .skill-lock-btn / .skill-vars-*
rules are deleted; trap machinery teardown follows separately.

Livepass-verified (stubbed authFetch, headless Chrome): create, edit
populated, locked view (disabled spec + editable runtime + scan +
readonly resources), unlock confirm stacked over the shelf, paste
auto-fill, variable chips, pending-resource add/remove.
2026-06-10 13:31:53 -07:00
Patrick Buckley 14f159ccdf chore(console): drop the policy pilot's orphaned trap declarations
_cpTrapHandler/_epTrapHandler lost their last readers when the policy
surface moved onto the shelf (f609911b) — declaration-only since.
2026-06-10 13:31:53 -07:00
Patrick Buckley ddcf337883 feat(console): memory detail onto the inspect shelf
The memory-detail read-out leaves its body-level overlay for a pane-scoped
inspect shelf (#memory-detail-shelf, cyan, lg) inside #admin-layout. The
detail-grid + content population carries over verbatim; the foot keeps the
Delete action (now a danger sh-btn) wired after the record loads, beside Close.
showMemoryDetailModal/hideMemoryDetailModal keep their names — the row renderer
calls them — and run through window.TurnstoneHatch; the post-delete close check
keys off the dialog's .open. Legacy overlay markup, its _installTrap/Escape
dispatch entries, the trap/trigger lets, and its style.css overlay ID-list row
are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley e54bdd1bc6 feat(console): prompt-policy + heuristic-rule + output-guard onto the shelf
The three governance create+edit pairs collapse onto pane-scoped lg shelves
inside #admin-layout. Each merges into ONE shelf with a hidden id (plus a
builtin flag on the judge surfaces) deciding the write: PUT for a DB row, an
override-POST for a built-in's first edit, a plain POST for a new row.
Title/tag/data-kind/submit-label flip between create and edit; edit-only chrome
(the prompt-policy Enabled toggle) sits in a hidden-toggled row.

The heuristic-rule and output-guard editors lift out of the Judge tab panel to
sit as direct children of the hatch-host (the shelf's inert containment needs
that); the two built-in-edit entry points share a single populate-and-open
helper. The settings-help-popover buttons carry over verbatim — their document-
delegated toggle is independent of the container. The output-guard "Validate
regex" button moves to the foot as a quiet action while its result strip stays
in the body. Submits go busy via the shelf LED lock; the regex flags input and
the is-credential toggle move onto the field grid. Legacy overlays, their
_installTrap/Escape dispatch entries, the trap-handler/trigger lets, and their
rows in the style.css overlay ID list are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley e6cf2c9346 feat(console): roles + user-roles + github-import onto the shelf
The governance role surfaces leave their legacy overlays. Create + edit role
collapse into ONE pane-scoped shelf (#role-shelf inside #admin-layout): a hidden
role-id decides POST vs PUT, and title/tag/data-kind/submit-label flip between
"New role"/ROLE-NEW/create/Create and "Edit role — name"/ROLE-EDIT/edit/Save.
The slug-name row is create-only (hidden attr on edit); the display name carries
over and is disabled for builtin rows. The permission checkbox grid renders with
one "role" prefix for both modes — the builtin baseline-vs-rendered diff that
produces {grant, revoke} (and round-trips unknown perms untouched) is preserved
verbatim. Submit goes busy via the shelf LED lock instead of the disable dance.

User-roles becomes an edit shelf carrying its toggle-list population; github-
import a create shelf with the URL as an sh-mono field and the hint folded into
a label-hint span. The public show/hide/submit names the toolbars and row
renderers call are kept — only the bodies are rewired through window.TurnstoneHatch
(handler-time, never at parse time). Legacy overlays, their _installTrap/Escape
dispatch entries, the trap-handler/trigger lets, and their rows in the style.css
overlay ID list are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley f2d76bbec6 feat(console): the MCP family onto the shelf — create/edit merge, detail, import, install
The four MCP surfaces leave their legacy overlays. The add/edit server editor
collapses into ONE lg pane-scoped shelf (#mcp-shelf): a hidden mcp-edit-id
decides POST vs PUT, and the title/tag/data-kind/submit-label flip between
"Add MCP server"/MCP-NEW/create/Create and "Edit MCP server — name"/MCP-EDIT/
edit/Save. The transport-conditional stdio/http field groups and the OAuth
subfield block toggle on the hidden attribute instead of style.display (which
.hatch [hidden] enforces); the multitenant-auth segmented radio control carries
over verbatim. The transport/auth onchange and submit move out of inline markup
into _mcpWire (which also installs the audience-autofill listener once).

mcp-import becomes a create shelf with the JSON paste as an sh-mono textarea;
mcp-detail an inspect shelf carrying its setSafeHtml two-column population. The
registry install flow moves onto the document-modal dialog tier per the confirm
precedent: a STATIC #mcp-install-dialog whose summary/source/fields containers
_showInstallMcpModal still populates — the dynamic overlay-shell construction is
gone. _doRegistryInstall is shared by the one-click card path and the dialog
submit, so its busy lock and inline-vs-toast error branch now key off the
dialog's .open. Submits go busy via the LED lock; legacy trap/Escape/ID-list
entries and the trap-handler lets are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley 4adf223dd9 feat(console): user/token/channel/runs onto the shelf — the admin.js simple forms
The four admin.js leaf surfaces follow the schedules pilot onto pane-scoped
shelves inside #admin-layout: create-user, create-token, link-channel and the
read-only schedule run history. Each keeps the public show/hide/submit names the
toolbars and row renderers already call (showCreateUserModal, showScheduleRuns,
…) — only the bodies are rewired. Open/close run through window.TurnstoneHatch
(handler-time, never at parse time); submit goes busy via the shelf LED lock
instead of the disable/relabel button dance; errors land in the sh-alert.

The token shelf hands its issued secret to the already-converted token-created
dialog unchanged. Schedule runs is an inspect shelf (cyan, lg width, Close-only
foot) and carries its setSafeHtml run-table population verbatim. The channel
type→placeholder onchange moves out of inline markup into _channelWire. Legacy
overlays, their _installTrap/Escape dispatch entries, the trap-handler lets, and
their rows in the style.css overlay ID list are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley 0e0532eee5 feat(console): policies pilot + the dialog tier — confirm/token-created go native
Tool policies join the shelf: one pane-scoped editor for create+edit with a
priority-neighbor read-out computed from the loaded policy list ('evaluates
after deny-rm (900) · before default-ask (0)' — policies run highest-first),
so where a priority lands answers itself while typing. Live tool-pattern
match chips are deferred until a cluster tool-registry endpoint exists.

The reusable confirm and the show-once token dialog move onto the
document-modal hatch tier (native showModal): the confirm keeps its
showConfirmModal(title, message, actionLabel, callback) contract for all
14 call sites, gains the danger chrome (red LED/hairline/title + err-filled
action), and deliberately moves autofocus from the action button to Cancel
— Enter on a fresh destructive confirm no longer fires the action. The
token dialog gets the success chrome + show-once callout, with copy wired
to the primary. Nested confirm-over-shelf now stacks via the top layer;
the z-index 650 special case and four more overlay-ID/dispatch entries die.

Also fixes a real war the livepass caught: display rules on form chrome
(label.toggle-switch's inline-flex) defeat the hidden attribute — hatch
containers now enforce [hidden] with display:none !important.
2026-06-10 13:31:53 -07:00
Patrick Buckley eba53edd36 feat(console): models pilot — capabilities JSON becomes an LED tile matrix
The model editor moves onto a lg shelf and the hand-written capabilities
JSON requirement dies. Nine LED tiles (tools/streaming/vision/web-search/
temperature/effort/STT/TTS/reranker) display merge(dataclass defaults,
known-model table, explicit overrides) with SPARSE-OVERRIDE persistence:
only keys saved in the row or toggled by the operator are written back, so
known models keep tracking future capability-table updates instead of
being pinned at save time. The known-model lookup that previously dumped
the whole table into the textarea becomes the tile BASELINE refresher with
a provenance banner ('Loaded from the built-in table for X'); the raw JSON
survives as a collapsed advanced hatch holding everything the tiles don't
manage (thinking_display, max_output_tokens, …). supports_rerank is now a
tile — no more hand-JSON to flag a reranker — and drives the Re-calibrate
button + calibration chip (now a foot read-out next to quiet Detect).

Everything regression-prone carries over: server_compat extraction,
write-only api_key sentinel, reranker calibration field re-merge (raw-typed
keys still win), thinking-mode representability guard, detect/calibrate
flows. Inline onclick/onchange wiring moves to the boot IIFE; busy locks
the shelf LED instead of disabling the button. Legacy overlay markup, the
trap/Escape dispatch entries, and the CSS ID-list entry are deleted.

Livepass-verified (stubbed authFetch, headless Chrome): create + edit,
baseline banner, tile extraction, toggle-switch inside .sh-body.
2026-06-10 13:31:53 -07:00
Patrick Buckley 425c38dc0e feat(console): schedules pilot — the cron DSL becomes a builder on the shelf
First production surface on the service-hatch shelf. Create + edit collapse
into ONE pane-scoped dialog (#schedule-shelf inside #admin-layout, now the
.hatch-host): a segmented Runs control (Daily/Weekly/Monthly/Interval/Once/
Cron) compiles to schedule_type/cron_expr/at_time — nobody types cron unless
they choose Cron mode, which keeps the raw input as the escape hatch with
the same live read-out. The NEXT RUNS read-out previews the next three
firings through the server's croniter (debounced POST /schedules/preview),
and the foot strip always shows the compiled expression for verification.

Storage is untouched: on edit the saved expression is reverse-parsed back
into the friendly mode when its shape matches (_cronToScheduleMode), else
the editor opens in Cron mode. Notify-row/select-populate helpers carry
over verbatim; submit goes busy via the shelf LED lock instead of button
text swapping. The legacy create/edit overlays, their show/hide/toggle
globals, hand-rolled trap wiring, and their entries in the overlay ID list
and _installTrap/Escape dispatch tables are deleted (-242 lines of markup).
Toggle-switch gets its .sh-body exception in hatch.css (the same
specificity war .admin-modal fights), + .sh-mono utility.

Verified against a stubbed-authFetch livepass harness in headless Chrome:
create/edit/light/busy, weekly reverse-parse round-trip, preview rendering.
2026-06-10 13:31:53 -07:00
Patrick Buckley 5b0255f468 feat(ui): service-hatch container system — pane-scoped shelf + modal dialog tier
One chrome vocabulary (machined head/foot strips on --code-bg, kind LED,
designation plate, center-fading hairline), two mounting points:

- .hatch--shelf: pane-scoped, NON-modal (dialog.show()). Mounts inside the
  pane's .hatch-host, docks right, dims only that pane via a lazy sibling
  .pane-scrim, and contains focus with inert on the pane's other children —
  rail/tabs/other panes stay live. Split panes work by construction; the
  bottom-sheet degradation is an @container query on the pane, not a
  viewport media query, so a narrow split degrades too. Controller-owned
  Escape defers to any document-modal dialog stacked above.
- .hatch--dialog: document-modal (showModal()) confirm/show-once tier;
  top layer stacks it above any shelf with no z-index ladder.

Smart-input primitives ship alongside (seg, chips, readout, capgrid,
match-strip, rawhatch, autofill) for the Phase-1 surfaces. data-busy locks
the container while a submit is in flight (LED pulses, dismissal refused).
Light-theme micro-text gets the .tab-menu-key one-step-up contrast pass.
Classic scripts reach the ESM controller via the window.TurnstoneHatch
bridge (toast.js pattern, handler-time only); invariants pinned in
tests/test_hatch_js.py incl. a markup-shape lint over dialog.hatch.
2026-06-10 13:31:53 -07:00
Patrick Buckley f086e38e37 feat(console): schedule preview endpoint — next-3-runs read-out backend
POST /v1/api/admin/schedules/preview validates {schedule_type, cron_expr,
at_time} with the same _validate_schedule_fields the CRUD path uses and
returns the next three croniter firings. Pure compute, no storage touch;
invalid input answers 200 {valid:false, error} because the schedule
editor renders it live while the user types. Registered ahead of the
{task_id} routes so the literal segment wins.
2026-06-10 13:31:53 -07:00
Patrick Buckley 8f415f9c68 docs(judge): say llm_fallback, not 'heuristic fallback', for cancelled items
Review feedback: the cancel-path docstrings described undone items as
degrading to 'heuristic fallback verdicts', but the emitted and
persisted tier is llm_fallback (heuristic content relabeled). Aligned
all eight occurrences — including the pre-existing _deliver_fallbacks
docstring — so docs, logs, and audit rows use one vocabulary.
2026-06-09 20:00:55 -07:00
Patrick Buckley b24b029c4d refactor(storage): single-source the bulk-insert race rationale
Review follow-up: the ON CONFLICT rationale lived verbatim in three
places (protocol docstring + both backend comments). Keep the prose in
the protocol — the contract's home — and point the backends at it.
Also recommend cancel_on_approval=true in docs for deployments where
the judge shares one local inference backend with the session model.
2026-06-09 20:00:55 -07:00
Patrick Buckley c75afd704b docs(judge): document verdict lifecycle — run-to-completion, superseded rows, replay parity 2026-06-09 20:00:55 -07:00
Patrick Buckley fb282fab73 fix(judge): honor the cancel_on_approval=False run-to-completion contract
judge.cancel_on_approval=False (the default) promises the daemon
evaluates every tool call to completion so all verdicts are available
for later review. Two sites conspired to break that: the approval
gate's finally set the cancel event unconditionally the moment a
decision landed, and _evaluate_single's poll loop honors the event
regardless of config — so every item the sequential judge hadn't
reached degraded to a heuristic llm_fallback row. On a 22-call
parallel batch, approving after the third verdict silently downgraded
the other 19; the elaborate late-verdict machinery in
on_intent_verdict was effectively dead code.

Make the event a pure abort signal whose firing policy lives with the
caller: the gate fires it only when cancel_on_approval is enabled,
while generation supersede (next batch) and close() keep firing it
unconditionally, bounding a stale daemon to one batch of real work.
_run_judge drops its own config second-guessing — a fired event always
fast-forwards the remainder to fallbacks (every call still gets
exactly one verdict), and the fallback reason no longer claims 'user
approval' for supersede/close aborts.
2026-06-09 20:00:55 -07:00
Patrick Buckley d0cede5e13 fix(storage): per-row conflict tolerance on bulk verdict insert
The async judge daemon can UPSERT a fallback row — reusing a heuristic
verdict_id from the batch approve_tools is about to bulk-insert —
before the bulk write runs. With a plain INSERT, that single PK
collision aborted the entire statement, and the caller's best-effort
try/except silently discarded every heuristic row in the batch.

Insert ON CONFLICT (verdict_id) DO NOTHING on both backends: siblings
survive a mid-batch collision, and the colliding row keeps the
daemon's llm_fallback tier upgrade instead of regressing to the
heuristic stamp (the documented preferred outcome). Regression test
runs against both storage backends via --storage-backend.
2026-06-09 20:00:55 -07:00
Patrick Buckley effdb8f365 fix(judge): persist superseded late verdicts for the audit trail
ChatSession._on_verdict guards on judge-generation identity so a stale
verdict can't ride a reused call_id into the Smart-Approvals cache —
but it dropped those verdicts entirely, before persistence. Every
ruling the sequential judge delivered after the next turn began left
intent_verdicts claiming the judge never answered.

Route superseded verdicts to a new persist-only hook
(SessionUIBase.on_superseded_intent_verdict): the row lands with
user_decision="superseded" while every live surface stays untouched
(no SSE, no replay cache, no pending-decision park). The hook is
duck-typed; display-only UIs (CLI/eval) don't define it and keep the
plain drop. upsert_intent_verdict already excludes user_decision from
its on-conflict SET, so a superseded fallback upgrading its heuristic
row in place cannot clobber a decision already stamped there.
2026-06-09 20:00:55 -07:00
Patrick Buckley 461d01f72a fix(ui): ship risk-none intent verdicts on history replay
The /history decoration layer suppressed intent-verdict rows with
risk_level="none" from the wire payload, on the assumption the client
filtered them anyway. It never did: buildConvVerdict renders a badge
for every verdict it receives, so the live SSE path painted all judge
verdicts while rehydration silently dropped the benign majority — a
22-call parallel batch came back from a restart showing only the 3
flagged calls.

Ship every stored row and let the client render replay exactly as it
rendered the live stream. The output-guard chip pair (showOutputWarning
+ merge-on-clean) already suppresses consistently on BOTH sides and is
unchanged.
2026-06-09 20:00:55 -07:00
Patrick Buckley 9fefe830d1 fix(tls): validate init() retry params
attempts < 1 made init() return successfully without fetching CA or
cert — a silent no-op leaving the client uninitialized. Fail fast with
ValueError instead; negative base_delay rejected on the same guard.
2026-06-09 18:37:46 -07:00
Patrick Buckley a3ff07a86d fix(tls): mTLS-aware container healthcheck + boot-time init retry
A whole-stack restart races every node against the console for the CA
fetch (compose re-enforces depends_on ordering only on `up`): losers
logged one warning and served plain HTTP for their lifetime, while
winners served mTLS that the plain-HTTP container healthcheck could
never probe — leaving "healthy" plaintext nodes and "unhealthy"
working ones.

- TLSClient.init() grows attempts/base_delay retry (server passes 6
  attempts, ~31 s backoff) absorbing the boot race; per-attempt CA-fetch
  failures log warning + debug traceback instead of error tracebacks.
- healthcheck.py falls back to HTTPS when the plain probe fails,
  presenting the node's own cert as the client cert with the cluster CA
  pinned; dials localhost because the internal CA issues DNS SANs only.
  Default plain-HTTP deployments are unchanged.
- The server writes boot PEMs under a fixed root (TURNSTONE_TLS_PEM_DIR,
  default <tmpdir>/turnstone-tls) so the probe can find them; boot
  clears stale dirs and refuses a symlinked/foreign-owned root; renewal
  rewrites the PEM dir so the probe's client cert never outlives the
  served cert.
- /health reports tls: "active"|"fallback" (absent when TLS is
  disabled) so a silently downgraded node is observable.
2026-06-09 18:37:46 -07:00
Patrick Buckley 319b73d046 chore: bump version to 1.6.0rc1 2026-06-09 13:39:19 -07:00
Patrick Buckley e1b99bcec6 fix(ui): popup-menu ArrowUp entry point + tabs-only tablist
Review feedback (Copilot), both confirmed against source:

- openPopupMenu: when no menu item has focus (a click on a separator or
  the menu surface moves focus off the items without closing), ArrowUp's
  unguarded modulo landed on the second-to-last item ((-1-1+n)%n == n-2).
  Guarded to enter at the bottom; ArrowDown's (-1+1)%n already entered at
  the top. Pre-existing in the tab dropdown this helper was extracted
  from — the shared chrome means one fix covers both menus.
- The burger and the [+] tail were focusable non-tab children inside the
  element PaneManager stamps role=tablist (the [+] violation pre-existed;
  the burger doubled it). The tabs now live in their own .tabstrip, which
  becomes the tablist PaneManager owns; burger and tail sit outside it in
  .tabbar. The strip is also the mobile horizontal scroller, so burger +
  [+] stay pinned while tabs scroll.

Verified: 289 static-suite tests, 28 harness self-tests, and both
real-page boot harnesses green; mobile render unchanged.
2026-06-09 13:37:35 -07:00
Patrick Buckley b1c526a170 style: ruff-format the appended drawer test guard 2026-06-09 13:37:35 -07:00
Patrick Buckley 221e804ef3 test(ui): de-modulize the renderer harness sources
test_renderer_js drives renderer.js behaviorally through node via
vm.runInThisContext — script semantics, which choke on the import/
export syntax renderer.js and utils.js now carry (all 68 tests failed
at harness setup). The harness now evaluates _demodulize()d source:
imports drop (the shared vm context resolves cross-file bindings as
globals, exactly like the pre-module classic scripts) and export
keywords peel off.

Deliberately NOT switched to dynamic import(): the mermaid harness
pokes renderer-internal state (_mermaidState = 'ready') that script
evaluation exposes but a real module would encapsulate. Module
semantics are covered by test_shell_js's .mjs parse sweep; these
tests pin renderer behavior.
2026-06-09 13:37:35 -07:00
Patrick Buckley 539ed3ccbf test(ui): pin the collapse/drawer/popup-menu behavior + breakpoint pair
Review follow-ups: the new rail collapse and mobile drawer had no
committed guards (the repo pattern is per-step string assertions in
test_shell_js.py) and openPopupMenu — now load-bearing for both the
tab dropdown and the footer user menu — was unpinned.

- test_rail_collapse_glyph_strip: persistence key, toggle +
  aria-controls, class-flip seam, cpill-label/manage-glyph companions,
  52px desktop-scoped CSS.
- test_mobile_drawer_off_canvas: burger, scrim, rail-open flip,
  pane-activation auto-close, off-canvas translateX + visibility:hidden.
- test_popup_menu_shared_helper: the export + both consumers (the user
  menu's prefer-up path included).
- shell.css: the 769/768 media blocks are a matched pair CSS cannot
  express as a shared token — both now carry a cross-referencing
  change-both comment.
2026-06-09 13:37:35 -07:00
Patrick Buckley 1d2046b5bb fix(ui): defer saved-table construction to boot — parse-time bridge use
Review finding (critical): the ESM migration made cards.js a deferred
module, but both classic app.js bundles built their saved-list tables at
TOP LEVEL — const COORD_COLUMNS = [SavedColumns.name(), ...] and
const _coordTable/_wsTable = createSavedTable({...}) execute at parse
time, before the window bridges exist. ReferenceError aborted each
bundle before it could define TS_APP.boot, so neither deployment booted.
(The earlier consumer audit caught bare top-level CALLS and IIFE bodies
but excluded declarations — missing initializers with side effects.)

Construction moves into _initSavedCoordTable()/_initSavedWsTable(),
called first from each boot path (substrate modules have evaluated by
then). The two typeof-undefined guards become null-checks (a let
binding passes typeof). Verified end-to-end with real-page load
harnesses: both index.html script chains (real app/admin/governance +
module substrate, network mocked) boot to a mounted shell with zero
uncaught errors — console over loopback HTTP (the coordinator dynamic
import needs real URL resolution), standalone over file://.
2026-06-09 13:37:35 -07:00
Patrick Buckley 3bfb40f60f refactor(ui): retire dead split-pane remnants + dedupe popup-menu chrome
Dead-code removal, all provable (no JS creator / no reachable caller):

- interactive.js drops the !this._embedded branches: focus tracking, the
  right-click context menu, and the header with split/close buttons all
  referenced shell globals (setFocusedPane, splitPane, splitRoot,
  showPaneContextMenu, countLeaves, closePane) that exist nowhere since
  the step-6 fork collapse — reaching them was a guaranteed
  ReferenceError. The embedded flag goes with them (every pane is
  L-shell-hosted; pane--embedded is now unconditional), as do the
  call-less updateWsName() and the host adapter's getWsName seam.
- ui/static/style.css drops the orphaned split-pane/tab-bar vocabulary:
  .ws-tab*, #new-tab-btn, #split-btn, .split-handle, the pane-header/
  action-button block, the unused dropdown-in keyframe, and the dead
  entries in the reduced-motion list.
- ui/static/app.js drops the retired settings-gear menu remnants (state
  vars for a builder that no longer exists + an always-false Escape
  guard) and fixes a real crash: hideNewWsModal() focused the removed
  #new-tab-btn unguarded, throwing a TypeError on every create/fork
  modal close; focus now returns to the shell's [+] new-session button.
- pane.js exports openPopupMenu — items, positioning (flip + clamp),
  dismissal, aria-expanded mirroring, and arrow-key roving in one place.
  The tab-action dropdown delegates to it, and shell.js's footer user
  menu replaces its hand-rolled duplicate (which lacked Tab-close and
  arrow roving — it inherits both).

test_interactive_pane_js.py: the embedded-gate pins flip to retired-
symbol pins (the gate is gone, not gated).
2026-06-09 13:37:35 -07:00
Patrick Buckley 2320c6d13c refactor(ui): migrate the shared_static substrate to ES modules
utils/toast/kb/cards/auth/renderer/composer/composer_attachments/
composer_queue/status_bar convert from classic scripts (implicit globals,
IIFE wrappers) to ES modules with explicit exports. Parse-time
cross-dependencies become real imports (auth/kb/cards -> utils,
auth/cards -> toast, cards -> auth, renderer -> utils), which deletes the
implicit script-order contract those files relied on. utils stays
import-free (bottom of the graph); its two upward calls (setMarkdown ->
renderer, export -> toast/auth) late-bind through window at call time to
avoid import cycles.

Each module installs a transitional window bridge for the still-classic
bundles (console app/admin/governance, ui app, inline onclick=), which
only touch the globals at boot/event time — verified by a column-0 /
IIFE-body audit of all four consumers, and including the audit-missed
initLogin() that both app.js boot paths call. theme.js stays classic:
deferring it would flash the wrong theme before first paint. Vendored
katex/hljs/mermaid stay classic and lazily typeof-guarded.

interactive.js and shell.js drop their bare-global reads for real imports
(authFetch, showToast, Composer, StatusBar, queue/attachment controllers,
streaming renderer, setMarkdown). The three HTML entries load the
substrate as module tags (same positions, same version_html stamping);
classic admin/governance/app still parse first, modules evaluate before
shell.js calls TS_APP.boot().

Tests: auth/kb/utils move from test_app_js's classic node-check sweep to
test_shell_js's module-semantics sweep, which now covers all 15 shared
modules (sink scan excludes renderer.js, the sanctioned HTML producer;
the no-var ratchet covers the var-free subset). The const-reassign guard
re-includes the converted files plus the shell modules.
2026-06-09 13:37:35 -07:00
Patrick Buckley f6f7d1fff7 feat(ui): collapsible glyph rail + mobile off-canvas drawer
The L-shell rail gains its two deferred responsive modes:

- Desktop collapse (user preference, localStorage turnstone_interface.rail):
  the rail shrinks to a 52px glyph-only strip — live Tier-1 state glyphs
  remain the navigation, cluster pills stack as glyph+count, Manage becomes
  one gear row opening the Admin pane, children flatten to peer glyphs.
  Title attrs (now set unconditionally) carry the names; aria-labels were
  already complete.
- Mobile drawer (max-width 768px): the rail leaves the grid and overlays
  off-canvas at full width behind a scrim. Burger in the tab bar opens it
  (focus moves into the rail); Escape (focus returns), scrim tap, or any
  pane activation closes it. Closed drawer is visibility:hidden so its
  buttons leave the Tab order. The collapse preference lies dormant here.
- Tab titles render in an ellipsizing span capped at 240px (48vw mobile)
  instead of growing the tab unbounded; tab bar scrolls horizontally on
  mobile.
2026-06-09 13:37:35 -07:00
Patrick Buckley 3fc65577f5 fix(ui): tab-menu verbs follow the live node when the controller is dead
A dead interactive controller's base goes stale once its node loses or
re-homes the ws, but menuBase() returned it first — so the close/delete
404-as-success lanes could silently drop a tab whose session is alive on
the node it re-homed to. Mirror the revive path: when isDead(), lead with
the live Tier-1 node and fall back to the stale base only when the ws is
gone cluster-wide (its 404 then correctly reads as "already closed").
2026-06-09 11:57:18 -07:00
Patrick Buckley 4b1536be2c fix(ui): ws lifecycle round 2 — dead-session revive + proxied tab-menu verbs
Two reported console bugs, one shared root: a pane can outlive its
session, and nothing brought the two back together.

Reconnect: an interactive pane whose stream died (ws closed/evicted
elsewhere, node restart, re-home) could never reconnect while its tab
existed — openPane() on an existing pane was focus-only, the
controller's connect() is one-shot, and its 5s recovery loop re-dialed
the SAME node forever (infinite 404 polling through the console proxy).
The only workaround was closing the tab before resuming.

- createInteractivePane now tracks terminal failure: 3 consecutive
  CLOSED recovery beats -> give up (stream closed, timers + any pending
  history load invalidated, status bar "Disconnected", opts.onDead
  fired once). host.onStreamOpen (new hook) resets the counter;
  isDead()/markDead()/base join the controller surface; onLogin
  ignores a dead controller — revive owns recovery, so a deliberately
  closed session is never resurrected by a timer.
- PaneManager.openPane fires pane.onReopen(extra) when it targets an
  ALREADY-OPEN pane — the explicit-intent signal (saved-list resume,
  rail row, child link) that activate() can't carry (hooks no-op on the
  active pane, and onActivate also fires on plain tab switches).
  getPane() added for cross-cutting lifecycle signals.
- The shell paints a click-to-reconnect banner on give-up — and
  immediately on Tier-1 ws_closed via the new
  TS_SHELL.notifySessionClosed seam (the console keeps the tab, unlike
  the standalone's auto-close, so the conversation stays readable).
  Reopen/banner-click revives: tear down the dead controller,
  re-resolve through the origin-first POST /open lane, rebuild.  The
  forced resolve skips BOTH beginConnect fast paths (a stale Tier-1 row
  must not bypass /open) while a live node leads the hint chain (an
  origin-first /open then reuses a genuinely-live session instead of
  loading a duplicate on the old meta node).  The standalone lane POSTs
  its local /open on revive too — /events 404s on an unloaded ws.
- Coordinator parity: the factory exposes reconnect() (acts only on a
  missing/CLOSED stream; OPEN is healthy, CONNECTING is already being
  worked) and the pane's onReopen drives it — the saved-list resume
  POSTs /open before openPane, so a fresh stream is all it needs.

Tab menu: a node-proxied interactive pane's dropdown gated every verb
on classic globals that only exist in ui/static/app.js, so the console
got a nearly-empty menu whose one surviving verb (Export) hit the
console origin and 404'd. convTabMenu gains a base-aware fallback lane:
verbs POST against the pane's OWN transport base (controller's exact
base -> persisted node hint -> live Tier-1 node; a verb is omitted
while no base is resolvable — never aimed at the wrong origin).
Close/Delete confirm first (window.confirm, the coordinator precedent)
and treat 404 as intent-satisfied (nothing left to stop/delete -> drop
the tab). exportWorkstreamDownload takes the base. The standalone
keeps its globals lane (incl. Fork) byte-identical, and an empty verb
section no longer renders a leading separator.

Verified: 189 JS-pin tests; two headless-Chrome live-DOM harnesses
driving the real modules — console 16/16 (connect -> ws_closed ->
banner -> reopen revives on a new node with the fresh hint -> give-up
stops retrying -> live-node-led resolve), standalone 10/10 (globals
menu intact, revive POSTs /open exactly once, no cluster resolve).
2026-06-09 11:57:18 -07:00
Patrick Buckley 91c4afb2d0 chore: sync uv.lock with anthropic>=0.108 floor + docstring nit
CI lock-check failed: the Fable 5 commit raised the anthropic floor to
>=0.108 in pyproject.toml but uv.lock still recorded >=0.39 / resolved
0.107.1. Regenerate the lock: anthropic 0.107.1 -> 0.108.0, specifier
0.39 -> 0.108 (no transitive changes).

Also address the Copilot review nit: the operator-instruction trust
declaration docstring wrote the fence marker as <system-reminder_<nonce>>;
align it to the emitted and project-standard <system-reminder_{nonce}>
notation.
2026-06-09 10:50:58 -07:00
Patrick Buckley 114ada791b feat(providers): add Claude Fable 5 to the Anthropic provider
- claude-fable-5 capability entry: 1M context / 128K output, adaptive
  thinking (summarized display), effort low..max incl. xhigh, no
  sampling params, web + tool search, vision, reasoning replay, native
  mid-conversation system messages
- document the Fable 5 wire quirk at the capability table: an explicit
  thinking={"type": "disabled"} is a 400 on this model; the adaptive
  branch never emits "disabled", so adaptive-or-omitted is preserved
- widen the native mid-conversation-system comments from opus-4-8-only
  to opus-4-8 + fable-5 (protocol, provider, tool_advisory, prompts,
  session)
- raise the anthropic SDK floor 0.39 -> 0.108: 0.39 predates every
  named kwarg the provider sends (output_config 0.77, top-level
  cache_control 0.83, mid-conversation system blocks 0.105); 0.108
  adds claude-fable-5
- tests: capability assertions for claude-fable-5 + dated-variant
  prefix match
2026-06-09 10:50:58 -07:00
Patrick Buckley ab6d95da24 fix(ui): clear browser-console warnings (invalid pattern regex + favicon 404)
The pattern attribute on the MCP server-name and model-alias inputs used an unescaped hyphen in its character class. Browsers compile the HTML pattern attribute with the RegExp `v` flag, under which a literal `-` must be escaped — the class failed to compile, so the browser silently dropped the constraint and disabled client-side validation (Firefox). Escaping the hyphen leaves the matched set unchanged and consistent with the server-side ^[a-zA-Z0-9._-]+$ validators.

Add an inline-SVG data: URI favicon to the console, coordinator, and ui entry points so page loads no longer 404 on /favicon.ico. A data URI needs no new static route and survives the /node/{id} proxy path rewrite.
2026-06-09 10:30:50 -07:00
Patrick Buckley a5bc37058e chore: bump version to 1.6.0a12 2026-06-08 10:12:29 -07:00
Patrick Buckley 473c61b55d fix(ui): address PR #640 automated review feedback (code-quality + Copilot)
All findings validated against source before fixing; behaviour-preserving:

- coordinator.js: drop the unused `stripAnsi` import; `updateStatusBar` early-returns
  on a null evt, so `(evt && evt.effort)` is simplified to `evt.effort` (no
  redundant guard).
- pane.js `_onTablistKeydown`: drop the dead `let j = i` initial value — every
  branch reassigns j before `tabs[j]` is read (the no-match case returns first).
- rail.js: collapse the redundant `ws.parent_ws_id ? "interactive" : "interactive"`
  ternary to `ws.kind || "interactive"`.  Tier-1 always stamps `kind`
  (console/static/app.js defaults it to "interactive"), so the fallback was dead
  and the parent-based arm would have mis-tagged a standalone interactive — the
  single default mirrors the snapshot's own and is behaviour-identical.
- status_bar.js: correct the stale JSDoc — it's a THREE-cell bar now (tokens /
  tools / turns); the model cell moved to the composer chip.
- ui/static/index.html: the tool-approval `a` shortcut help read "Always approve";
  align it to the button language "Approve all".
2026-06-08 10:08:30 -07:00
Patrick Buckley c4dec213de fix(ui): address /review of the workstream-lifecycle change
Multi-stage review (find → verify → sanity) of b8914854 found one critical
bug plus four minor + one nit; all confirmed against source and fixed:

- CRITICAL — the interactive launcher's "Specific node" pick was unusable:
  selecting a node fired the composer `change` event → onChange →
  _applyLauncherFields → _populateLauncherNodes → setOptionChoices, which
  rebuilds the <select> and reset it to the placeholder, wiping the selection
  the instant it was made (submit then failed "Choose a node…").  Fix:
  _populateLauncherNodes snapshots the current pick before the rebuild and
  restores it after (setOptionValue does not dispatch `change`, so no loop).

- perf — every interactive open blocked first paint on a POST /open round-trip,
  even on the hot rail / active-row paths where the ws is already live.  The
  pane now connects DIRECTLY when the Tier-1 snapshot already names the owning
  node; only the dormant / reload case (snapshot empty) resolves + opens.  This
  resolves the uniform-vs-gated /open question left open last change; refresh
  safety is unchanged (a reload activates before the snapshot lands → nodeForWs
  null → resolve path).

- bug — an errored resolve (capacity / no node free) had no in-place retry
  (re-clicking the active tab is a no-op); the error status line is now
  click-to-retry.

- quality — resolveInteractiveNode surfaced each failure twice (toast + in-pane
  line) with drifted wording; dropped the toasts, the in-pane status line is the
  single source of truth.

- quality — corrected a setOptionFieldVisible comment that cited a nonexistent
  "flex/grid rule" (the row is `display: contents`).

- nit — buildController skips the redundant sessionStorage re-persist when the
  resolved node already matches the persisted hint.

Guard tests extended (bug-1 capture/restore, the live-direct path); the
behavioral harnesses were strengthened to fire a real selection rebuild and to
exercise the live-direct vs reload-resolve split that the first round missed.
2026-06-08 10:08:30 -07:00
Patrick Buckley 71d3ed6abe fix(ui): repair interactive/proxied workstream lifecycle (reload, create, launcher)
Workstream-lifecycle bugfixes on the L-shell:

- Node-proxied interactive panes now SURVIVE a browser reload.  On first
  activate a pane resolves its owning node and (re)opens the session there
  before streaming — the node /events stream 404s on a ws not loaded on its
  node, so a rehydrated pane could not just connect blind.  Resolution is
  origin-first via the new TS_APP.resolveInteractiveNode seam (POST /open with
  a rendezvous /route fallback).  PaneManager now persists a pane's resolved
  nodeId as opaque meta and hands it back on rehydrate, so a reload restores
  the pane onto the SAME node even before the Tier-1 snapshot has populated —
  the exact timing that used to strand it on base="" (the console, not a node).

- Both launcher personas open the new session as a PANE, not a full-page nav
  (coordinator -> coordinator pane; interactive -> node-proxied pane); the
  full-page nav stays only as the shell-absent fallback.  Every interactive
  entry point (create, active row, rail, saved row, child link, reload) now
  funnels through one resolve-open-connect path, folding away the bespoke
  restoreInteractiveSession helper.

- The interactive launcher gains a node-selection strategy (Least loaded |
  Specific node, with a live node picker fed from the cluster snapshot) and a
  persona-aware task hint — the shared composer no longer shows
  "...coordinator orchestrate?" when the interactive persona is selected.

Guards updated to pin the new wiring; the stale console landing test (asserting
the renovation-retired bottom-bar node picker) is corrected to the rail.
2026-06-08 10:08:30 -07:00
Patrick Buckley 1d7fa33cd1 refactor(ui): extract batchKicker + indexLabel — enforce interactive/coordinator parity (q-1)
The parallel tool-batch kicker strings ("Parallel · N tools", "Evaluating ·
Parallel N", "Running · Parallel N", "⚠ Approval · Parallel N") and the "1/N"
index label were duplicated byte-for-byte across interactive's three render paths
and the coordinator's kicker state machine, with no shared source enforcing the
visual parity the two surfaces require.  Extract batchKicker(state, n) +
indexLabel(idx, n) into conversation.js (both files already import it) and route
all 13 sites through them, so a future label tweak can't silently diverge them.

Byte-identical, harness-verified: the rendered kicker + 1/N labels are unchanged.
2026-06-08 10:08:30 -07:00
Patrick Buckley b14cc93d37 chore(ui): apply /review findings — guard menu listener, fold tab repaint, drop dead status-bar code
From the multi-stage review of this session's changes (all minor):
- bug-1: the footer user-menu's deferred document-listener attach now bails if the
  menu was already closed (closeUserMenu nulls the cleanup ref), closing a latent
  listener-leak window.
- perf-1: fold paintConvTabGlyphs + paintConvTabTitles into one paintConvTabs — a
  single findWs per stateful tab per Tier-1 render instead of two scans.
- q-2: remove the dead StatusBar.paint modelEl branch + its modelInfo arg (both
  callers dropped it when the model moved to the composer chip) and the orphaned
  .ws-sb-model CSS rules.

(q-1, the parallel-head string-helper extraction, follows separately.)
2026-06-08 10:08:30 -07:00
Patrick Buckley 6f748c1601 fix(ui): mic (STT) button sits next to send, not the model chip
After the stacked composer box, the action row's margin-left:auto on the send
button pushed send to the right edge but left the mic stranded on the left next
to the model chip (the mic inserts before send).  When the STT role is confirmed
the action row now gets .has-mic, which puts margin-left:auto on the MIC instead
so mic + send form a right-aligned cluster (send flush after the mic); without
STT, send keeps its own auto margin and sits alone on the right.

Verified (headless): with .has-mic the mic moves from x=427 (by the model chip)
to x=906 (38px left of send at 944); css audit at baseline 10.
2026-06-08 10:08:30 -07:00
Patrick Buckley 1b9fee06b1 fix(ui): chat composer matches the mock — compact stacked "composer box"
Move the interactive + coordinator composers to the mock's layout: a compact
rounded composer box with the borderless textarea on top and the
[+] / model·effort chip / send row below (layout:"stacked").  The bordered inner
textarea and the boxed paperclip are gone — the box is the frame, and the attach
is a plain "+" glyph.  Scoped via a composer--chat class (the model-chip hosts)
so the home launcher's taller stacked composer is untouched.

Verified by headless render against the mock; css audit at baseline 10, guards
green.
2026-06-08 10:08:30 -07:00
Patrick Buckley 0ca4638411 fix(ui): interactive parallel tool batches match the coordinator (kicker + numbered rows)
After the collapse fix, parallel tool calls render but the head read
"TOOL web_fetch + 1 more", which implies the rest are hidden.  Match the
coordinator's presentation across all three interactive paths (announce /
inline / replay) + buildToolDiv:
  - "Parallel · N tools" kicker (renders "PARALLEL · N TOOLS") instead of "Tool"
  - the conv-batch--parallel class (the numbered-row connecting rail)
  - per-row "1/N" index labels
so the "+ N more" summary now reads as a label, not hidden calls.

Verified in the real console shell (headless): a 2-call batch shows kicker
"Parallel · 2 tools", rows "1/2"/"2/2", conv-batch--parallel, both calls named,
no JS errors.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9653b57eaf fix(ui): interactive tool cards no longer collapse to an empty stripe
The real regression behind "parallel tool calls don't show / tool cards get
overwritten, leaving a thin stripe with a coloured pixel on the left": the
.conv-* convergence put an `overflow:hidden` card (.conv-batch) into the
interactive pane's SCROLLING flex-column message list
(.pane--embedded .pane-messages, overflow-y:auto).  An overflow:hidden flex
item's `min-height:auto` resolves to 0, so flexbox squished the tool batch to
~2px (just its left border) once the column filled — while plain .msg blocks
(overflow visible) kept their height.  That asymmetry is why the COORDINATOR
(different container) and main's old `.ts-approval` block (a .msg, overflow
visible) were never hit, and why it impacted ALL models — it was never a
local-model id-collision (that earlier theory + fix were reverted).

Fix: pin every message child to flex-shrink:0 so the column scrolls instead of
collapsing cards.  Reproduced + verified in the real console shell (headless):
the parallel-bash tool batch went from 2px (collapsed) to 244px (full content)
once a multi-turn conversation fills the column.  Guarded in test_conversation_css.
2026-06-08 10:08:30 -07:00
Patrick Buckley 81dd9fc083 Revert "fix(ui): interactive tool cards no longer overwrite each other (call_id collision)"
This reverts commit 5b7fc97109.
2026-06-08 10:08:30 -07:00
Patrick Buckley 825480a107 fix(ui): model · effort moves into a live composer chip (sole model location)
Per the BRIEFING the composer is the sole model location, but the model still
rendered in the per-pane status bar.  Add a display-only "model · effort" chip to
the chat composer (next to the attach button) and remove the status-bar model
cell from both the interactive and coordinator panes; the status bar keeps
tokens/tools/turns.  The chip repaints from the same model + status events, with
effort silent on the implicit "medium"/none (mirroring the status bar's old
suffix rule).  A per-session model/effort PICKER is a separate deferred task
(needs a backend override path).

Verified in a real interactive pane (headless): the chip renders with its em-dash
placeholder, the status-bar model cell is gone (tokens/tools/turns remain), the
send glyph is intact, no JS errors; JS guards green, css audit at baseline 10.
2026-06-08 10:08:30 -07:00
Patrick Buckley 685b53b270 fix(ui): tighter spacing between interactive conversation segments
The embedded message list stacked a 5px flex gap ON TOP of each .msg turn box's
4px margin-bottom (~9px of dead space between segments — "too thick").  Trim the
container gap to 2px so segments land at a compact ~6px, matching the mock's
gap-only intent without overriding the shared .msg margin (no specificity-audit
flip).
2026-06-08 10:08:30 -07:00
Patrick Buckley 1cadc541d6 fix(ui): footer shows the username, not the internal user_id uuid
whoami returned only user_id (an opaque uuid), so the rail footer rendered the
uuid.  whoami now resolves the user record by id and returns the human
username/display_name (best-effort — a storage miss just omits it).  The client
stores data.username (no fallback to user_id: a uuid is worse than the generic
"account" placeholder).  Hardened against a malformed user record (isinstance
dict guard) so a bad row can't 500 whoami; test stubs get_user + asserts the
display name is surfaced.
2026-06-08 10:08:30 -07:00
Patrick Buckley b336e71061 fix(ui): interactive tool cards no longer overwrite each other (call_id collision)
Local OpenAI-compatible models (e.g. DeepSeek) reuse tool-call ids across turns
(call_0, call_1 each turn).  The interactive pane resolved a call's card via a
PANE-WIDE first-match messagesEl.querySelector('[data-call-id=...]'), so a later
turn's tool_result / verdict / warning / output-chunk landed on an EARLIER turn's
card — corrupting it and leaving the current batch's rows empty (a thin stripe).
It also made parallel calls look like they "didn't show" (the head summary sat
over emptied rows).

The pane is strictly serial, so a live result belongs to the MOST-RECENT matching
card.  Add a _lastMatch(root, selector) helper and resolve the five live-path
lookups (appendToolOutput x2, appendToolOutputChunk, showOutputWarning,
updateVerdictBadge) to the LAST match instead of the first.  The replay/history
path was already scoped to its block and is untouched.

Known limitation: if a model emits ALL parallel calls in ONE turn sharing the
same id, they still collide within the batch — a separate source-level issue.
2026-06-08 10:08:30 -07:00
Patrick Buckley d322e43678 fix(ui): composer send button is a glyph, not the word "Send"
The chat composers (coordinator + interactive) now render an up-arrow send glyph
instead of the text label, matching the mock.  Opt-in via opts.sendGlyph so
creation-form composers keep their text label; the visible glyph is constant
while the textual sendLabel stays the aria-label and drives setBusy's a11y
rotation (setBusy no longer overwrites the glyph).

Verified in a real interactive pane (headless): the send button is the up-arrow
glyph with aria-label "Send message", no JS errors.
2026-06-08 10:08:30 -07:00
Patrick Buckley 1bab26ab53 fix(ui): tabs track the live workstream name, not the ws_id
Conversational tabs froze at their open-time title — wsTitle(id), which is the
id-slice when the session isn't in the Tier-1 snapshot yet (e.g. a just-restored
saved session) — and never updated, so the tab read as the raw id instead of the
saved name.

Add PaneManager.setTabTitle(paneId, text) (mirrors setTabGlyph: rewrites the tab's
title text node in place + pane.title for a later rebuild) and a
paintConvTabTitles(pm) Tier-1 hook alongside the glyph repaint.  It only UPGRADES
a tab to a real name (ws.name || ws.title) — never flickers a known name back to
the id if the ws blips out of a single frame.

Verified in the real console shell (headless): a named ws shows the name at open,
a dormant ws shows the id-slice then upgrades on repaint, no JS errors.
2026-06-08 10:08:30 -07:00
Patrick Buckley b33ccb16c5 fix(ui): rail/footer cleanup — drop admin btn, real username, logout menu, collapse Manage
Mock-review batch (4 items):
- Footer: drop the redundant Admin button — Manage already surfaces every
  admin tab, so only the theme toggle relocates from the retired header.
- Footer: the user chip now shows the real logged-in user.  whoami returns
  user_id but _storePermissions only persisted permissions, so the chip was
  stuck on the "account" placeholder; it is now stored as ts.username and the
  chip repaints once whoami lands (Tier-1 render hook).
- Footer: Log out moves into a click-menu on the user chip (reuses the
  .tab-menu popup chrome; the item clicks the hidden #logout-btn so auth.js
  stays the single owner of logout and its in-flight-refresh race guards).
- Manage: groups start collapsed instead of auto-expanding the first one —
  the rail is a discovery map, not a wall of open links.

Verified end-to-end in the real console shell (headless): chip is a button
showing the user, no admin button in the footer, the menu opens with Log out
which invokes logout, outside-click/Escape close it, no JS errors.
2026-06-08 10:08:30 -07:00
Patrick Buckley 3eb79cf725 fix(ui): live-backend pass — rehydrate saved interactive sessions, origin-first
Saved INTERACTIVE sessions opened from the console did nothing useful.
Coordinators rehydrate because their activation POSTs /open first; the
interactive branch only passed the saved DTO's node_id to openPane and
bailed "Session node unknown" when falsy. Even with a node_id nothing
streamed: the per-pane SSE /events 404s on a not-loaded ws and /history
alone does not rehydrate, so the session was never loaded onto a node.

New restoreInteractiveSession (console app.js) is ORIGIN-FIRST: POST /open
to the session's origin node (the DTO node_id, stamped at create) and pin
the pane there. This keeps node affinity and — load-bearing — REUSES a
session already live on its origin instead of loading a duplicate copy
elsewhere; the interactive pane talks directly to /node/{id} for every
verb, so the load-node and the pane-node must match (no split-brain).

Only when the origin is gone (POST /open 404 = not in registry / 502 =
unreachable) do we re-home onto a fresh rendezvous node via
GET /v1/api/route (the router skips dead nodes; persistence is shared
ws_id-keyed Postgres, so any live node is state-safe). Capacity (429) and
permission (403) are surfaced, not silently re-homed. No origin (legacy/
CLI rows) routes straight away. Mirrors the coordinator open-before-
navigate and the standalone dashboardResumeSession; the active-row path
(already-loaded sessions) is untouched.
2026-06-08 10:08:30 -07:00
Patrick Buckley dc90629843 fix(ui): live-backend pass — saved/active clicks open panes, rail polish
Bugs surfaced by the live console (the headless harnesses stubbed data, so these
only showed against a real cluster):

- Saved-session AND active/filtered-table row clicks did full-page nav (interactive
  -> /node/{node}/?ws_id=, coordinator -> /coordinator/{ws}) instead of opening an
  L-shell tab. Route both through window.TS_SHELL.panes.openPane (interactive =
  node-proxied pane, coordinator = coordinator pane). Full-page nav stays only as
  the shell-absent fallback. (The broader ?ws_id= URL-pattern cleanup is deferred to
  its own session.)
- The cluster health pills wrapped ("idle" fell to a second line) in the 266px rail.
  Tightened gaps + font + nowrap so all three fit one line (verified at 266px).
- The [+] new-session button was a no-op when the Dashboard was already active
  (showHome focuses it, no visible change). It now also focuses the launcher
  composer via a new TS_APP.focusLauncher seam — "new session" lands you ready to
  type.
- The cluster node list wasn't collapsible (unlike the Manage groups). The "Nodes"
  header is now a toggle (button + rotating caret), state persisted across the
  rail's Tier-1 re-renders.

Verified: node + prettier clean; CSS audit baseline; 89 JS guards; cluster-pill +
node-collapse render harnesses (pills one-line, toggle hides/shows + caret rotates);
wiring harnesses errs:[] (no regression).
2026-06-08 10:08:30 -07:00
Patrick Buckley 06621c463f refactor(ui): /review cleanup — shell-spine P3s (DRY scans, activate/rehydrate)
Three shell-spine P3s from the review:
- Consolidated the three near-identical Tier-1 ws-scans (wsTitle / nodeForWs /
  stateForWs each re-walked getClusterState -> nodes -> workstreams) into one
  findWs(wsId, skipConsole) helper + three thin wrappers. nodeForWs keeps the
  console-pseudo-node skip (coordinators live there, must not be node-proxied);
  the other two scan all nodes. Also restored the convTabMenu doc comment that an
  earlier glyph-helper insertion had orphaned above stateForWs.
- Simplified PaneManager.activate's dead/misleading guard (_activeId===paneId ||
  !has, with a nested re-check) to the equivalent `if (!has) return;`.
- rehydrate now counts a pane restored only when openPane actually returns one — an
  auth-gated (denied) coordinator pane returned null but still set restored=true,
  which would suppress the Dashboard fallback into a blank shell (latent today: the
  non-closable Dashboard is always in the persisted set).

Verified: 28 shell guards; mechanism harness 31/31 (activate/rehydrate/gate); both
wiring harnesses errs:[] (the scans drive the verified tab titles / node-proxy /
state glyphs — console running/idle, standalone full menu). node + prettier clean.
2026-06-08 10:08:30 -07:00
Patrick Buckley 00194aba7c refactor(ui): /review cleanup — console-app dead state + popstate branch + create-interactive guard
Three console front-door P3s from the review:
- Removed dead module state _lastOverviewJson / _lastNodePickerJson (memo caches
  for the removed renderStatusBar / renderNodePicker; only declared, never read).
- Dropped the unreachable popstate view==="admin" branch — Admin is a rehydrated
  PaneManager pane now, nothing pushes {view:"admin"}, and Back-from-admin already
  lands on the dashboard via the home/filtered path.
- _createInteractive: added an else for a 200 without target_node so a server-
  contract drift surfaces an error instead of silently stranding the user (the
  branch is currently unreachable — the node is validated non-empty server-side —
  but it was a silent failure mode).

Verified: node + prettier clean; 28 shell guards; console wiring harness errs:[].
2026-06-08 10:08:30 -07:00
Patrick Buckley cbf013c78a refactor(ui): /review cleanup — rip standalone getFocusedPane dead block + fix _formatAttachSize
getFocusedPane() has been a permanent `null` stub since the fork collapse
(PaneManager owns focus; interactive.js owns approval keys). That left ~90 LOC of
unreachable pane-dependent code — and it's exactly where the P1 closeTabDropdown
crash hid. Removed all 4 sites:
- the global-keydown Escape-cancel branch + the whole inline-approval keybinding
  block (still referencing the retired .ts-approval-feedback / .verdict-* vocab);
  every LIVE shortcut (Escape->dashboard, Ctrl+D/T/1-9, Ctrl+Shift+E/F/X, Ctrl+W)
  is kept;
- the dashboardSubmit optimistic-echo block (interactive.js echoes its own turn);
- the new-ws modal model prefill (curModel is always "");
- the stub itself.

Also fixed _formatAttachSize: called 4x (chip size + over-cap error) but defined
nowhere -> a ReferenceError that broke file staging (pre-existing on main; flagged
by the review). Defined a local B/KB/MB formatter mirroring composer_attachments's
IIFE-local formatSize.

Verified: node + prettier clean; 61 app guards; standalone harness errs:[]; all
live keyboard-shortcut verbs asserted present after the splice.
2026-06-08 10:08:30 -07:00
Patrick Buckley 0ba306cb20 refactor(ui): /review cleanup — rip dead coordinator wait_for_workstream code
The header removal (5e.2e) left the wait_for_workstream progress surface inert:
_waitIndicatorEl() mounts only into the deleted #coord-header and returns null, so
the whole #14 wait-indicator (handleWaitStarted/Progress/Ended, the activeWaits
Map, _renderWaitIndicator, the reconnect-path clear, the 3 SSE switch cases) ran
but rendered nothing. Ripped it (~110 LOC) + the orphaned .coord-wait-indicator
CSS rule. (The observability loss was a deliberate, tested design decision —
test_coordinator_page.py pins the header absence.)

The review's broader coord-chrome dead-CSS list was a false positive on
verification: `.task-row .status-done/.status-blocked` are LIVE (applied via a
dynamic `"status-" + status` class), `ts-spin` is live (coord-chrome.css:209), and
the rest (.coord-tool-*/.judging/.feed-item/.topbar) appear only in prose comments.

Also (review P3-1): clear aria-busy in _unsetBatchRunningIfAllResults so a batch
that completes via tool_result only (judge + gate bypassed, early-paint on) stops
announcing "busy" to screen readers after completion.

Verified: node + prettier clean; 16 coordinator guards; CSS audit baseline;
coord-keys harness all green (approval keys intact after the rip).
2026-06-08 10:08:30 -07:00
Patrick Buckley 6a1a58b801 chore: pre-push /review fixes — correct saved-list delete doc + retire dead csb CSS
Two lower-severity findings from the full-branch review:

- Backend doc divergence (session_routes.py + console/server.py): the comments
  justifying interactive's `state=None` saved listing claimed "the storage layer
  already excludes state='deleted' tombstones" — but neither storage impl has such
  a filter. It's incidentally safe because delete is a HARD delete (no `deleted`
  tombstone is ever written), NOT because of a filter. Corrected both comments to
  state the real mechanism + flag that a future soft-delete tombstone would need an
  explicit `state != 'deleted'` guard here.
- Dead CSS: the entire #cluster-status-bar / .csb-* block (387 lines) was orphaned
  — its HTML element + all JS writers were deleted earlier in this branch and
  nothing reuses the vocabulary (grep-confirmed across console+shared). Removed the
  main block (per-selector verified all-csb before splicing); the 9 residual csb
  rules inside two MIXED @media blocks are left as a safe over-keep, matching the
  5e.2f dead-CSS method.

Verified: ruff clean; CSS audit at baseline (zero new flips); braces balanced;
prettier clean; console builds errs:[].
2026-06-08 10:08:30 -07:00
Patrick Buckley dcb611f0fc fix(ui): pre-push /review fixes — live-shortcut crash, Cmd+D, login re-arm, dashboard rows
The full-branch pre-push review (6 subsystem slices) found 1 P1 + several real P2
bugs; the confirmed functional ones, fixed here:

- P1 (standalone): two LIVE keydown branches called the deleted closeTabDropdown()
  -> ReferenceError that silently killed Ctrl+Shift+E/F/X (edit/fork/delete) and
  Ctrl+W (close) before reaching the verb. Removed the dead calls.
- coordinator (this branch's step-7 keys): the deny branch fired on any `d` with no
  modifier guard, so Cmd+D (bookmark) / Ctrl+D / Alt+D silently DENIED a pending
  batch. Early-return on ctrl/meta/alt (Shift+A still resolves).
- shell.js: window.TS_LOGIN was defined AFTER rehydrate(), so a RESTORED
  conversational pane silently skipped its re-auth Tier-2 reconnect (onActivate saw
  no TS_LOGIN and never re-fired). Moved the fan-out (+ TS_SHELL) above rehydrate.
- shell.js: TS_LOGIN.subscribe had no unsubscribe -> a closed pane leaked its
  controller closure across open/close/re-login. Added unsubscribe + call it in
  both onClose hooks.
- standalone dashboard: updateTabIndicator dropped its `extra` arg in the fork
  collapse, so a watched row's STATE/TOKENS/CTX went stale on every ws_state tick
  until a full reload. Ported the in-place row patch from main (sans the retired
  .ws-tab indicator).

Also dropped a redundant index.html NODE-gate comment (the CSS rule + loadDashboard
already document it) that had tipped a fragile 4000-char structural guard.

Verified: 105 JS guards green; mechanism (31/31) + coord-keys (all) + wiring
harnesses errs:[]; node + prettier clean.
2026-06-08 10:08:30 -07:00
Patrick Buckley 140f7a02fe fix(ui): L-shell step 7 — console designer-pass fixes (AA + rail polish)
The merge-gate designer pass on the CONSOLE persona (coordinator + interactive,
caps on) found 0 P1 — ship-quality — and 4 small CSS P2s, applied here:

- P2-1 (non-optional AA): the pending approval card's "APPROVAL NEEDED" kicker was
  4.17:1 in light (sub-AA on the operator's primary decision signal). Darken off
  raw --warn via a theme-tracking color-mix toward --ink-2 (~5.5:1 light; dark
  stays warm + passing). In conversation.css, so both personas benefit.
- P2-2: rail micro-labels (.sec-label/.nlabel/.node-row .ver/.grp-head .gcount)
  were --ink-4 <=11px = ~3.9-4.0:1 in light. Light-scoped --ink-3 (~6.5:1), the
  same escape hatch as .tab-menu-key; drift versions keep --yellow.
- P2-3: the tab-dropdown separator was imperceptible. Use the MORE-visible hairline
  per theme (--hair-2 dark / --hair light) — the designer's suggested tokens were
  reversed; corrected against the actual hex values.
- P2-4: the relocated "Reconnecting..." rail-conn read as an alarming top-of-rail
  peer of Cluster health. Quiet it (10px, collapses when connected) + --warn (not
  the near-error --yellow) on disconnect.
- P3-5: the Manage active-tab marker (inset --hair-2) nearly vanished in light ->
  --ink-4 (reads in both themes, still not the amber `.open` of a live session).

Verified: CSS audit at baseline (zero new flips); 33 conversation/shell guards
green; node + prettier clean.  Other P3s noted (model-chip disclosure is the locked
"model lives only in the composer" decision; node-row rhythm / verdict-expand minor).
2026-06-08 10:08:30 -07:00
Patrick Buckley 76036b2364 chore(ui): L-shell step 7 — retire the dead #settings-overlay cruft (designer P3)
The settings MODAL backdrop (#settings-overlay) died when MCP connections moved to
the Manage > Connections pane (step 6) — the #settings-mcp-* content rules are
reused there, but the overlay wrapper is gone.  Remove the closed loop of dead-but-
mutually-alive references: the CSS rule, the stale "settings-overlay" modal-id
array entry, and the guarded getElementById no-op in the settings-close path.

The other fork-collapse dead-code (the getFocusedPane stub + its null-gated
branches, the partially-retired settings-gear) is woven into still-live handlers —
deferred to the merge-gate /review for a systematic sweep with the review findings.

Verified: 0 settings-overlay refs remain; node + prettier clean; CSS audit at
baseline; standalone harness errs:[].
2026-06-08 10:08:30 -07:00
Patrick Buckley 30cb9e6097 fix(ui): L-shell step 7 — wire coordinator approval keyboard shortcuts (designer P2)
The console twin of the interactive.js approval-key fix: the coordinator's
tool-batch card shows kbd hints (Enter approve / D deny / Shift+A approve-all) but
they did nothing — the keys were never wired (the standalone routed approval keys
through the app.js global keydown + getFocusedPane, retired in the fork collapse).

Add a pane-owned keydown on `root` that resolves the current pending batch:
- _currentPendingBatch() finds the last .conv-batch with a still-pending
  [data-needs-approval="1"] row whose actions aren't already disabled — the
  in-flight double-fire guard (a second key during the resolve is a no-op).
- Enter -> approve, D/Esc -> deny, Shift+A -> approve-all, routed to the existing
  _resolveBatchAction path.
- A focus guard skips when an input/textarea/contenteditable is focused, so the
  keys never hijack composer typing (the coordinator has no feedback field, unlike
  interactive, so no feedback special-case).

Verified end-to-end against the real coord pane (keydown -> _currentPendingBatch ->
_resolveBatchAction -> approveWorkstream -> postJSON -> authFetch, stubbed at the
HTTP boundary): Enter/D/Shift+A fire the right verb, the double-fire + focus guards
hold, errs:[] + a coordinator JS guard.  Live keypress confirm rides the merge gate.
2026-06-08 10:08:30 -07:00
Patrick Buckley be1fdbde80 fix(ui): L-shell step 7 — gate the NODE column off the standalone dashboard
The WORKSTREAMS table showed a NODE column (a multi-node console-ism) on the
single-node standalone server, where every row reads "local".  Drop it: remove the
NODE header span + skip the node cell in loadDashboard, and gate just that table to
6 columns by overriding the --dash-grid VARIABLE (not the grid-template-columns
property — so it stays the var's single declaration, no cascade flip), scoped by
id, so the shared --dash-grid and the Saved Workstreams table keep their 7-col
layout.  Matches the brief's capability-derived-affordances thesis (the rail drops
Cluster the same way).  Designer P2 (pre-existing, not a step-6 regression).

Verified: standalone DOM shows 0 dash-col-node + the saved table intact; CSS audit
at baseline (zero new flips); node + prettier clean.
2026-06-08 10:08:30 -07:00
Patrick Buckley 327c18c0e9 chore(ui): L-shell step 7 — confirm desktop-first, defer the mobile rail drawer
The brief defers mobile: the rail -> off-canvas drawer matches no current DS scope
(the DS is desktop-only; the console's mobile drawer was retired in step 3b).
Record the decision in-code at the .app layout seam (the brief is local-only) where
a future max-width @media would slot in.  Verified narrow viewports (720px wide)
are cramped, not broken — no silent mobile-support claim.
2026-06-08 10:08:30 -07:00
Patrick Buckley 0f137f570f feat(ui): L-shell step 7 — auth-gate openPane (coordinator scope)
openPane now auth-gates pane CREATION via an optional per-type canOpen predicate
(deny -> no pane; focusing an already-open pane is never re-gated).  PaneManager
stays generic — it holds a _gates map and consults canOpen/onDeny; the shell
supplies the gate.

The coordinator type gates on the admin.coordinator scope — the SAME
sessionStorage-backed _hasCoordPermission helper the launcher + saved-list use.
Because every coordinator open path (rail click, child-link, rehydrate, [+]
launcher) routes through openPane, this gates them all at once — closing the gap
where a rail click opened a coordinator pane a user lacked scope for (it then
404'd server-side).  Perms live in sessionStorage so they survive a refresh →
rehydrate gates correctly (an operator's persisted coord pane restores, a
non-operator's is skipped).  The backend enforces the scope too; this just avoids
opening a doomed pane.

Verified: 31/31 mechanism harness (gate allow/deny/onDeny) + real-stack console
wiring (authorized operator opens the coord pane; a no-permission stub denies a
new coord pane, gateDenied:true, errs:[]) + a shell JS guard + CSS audit baseline.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9f10e8e81d feat(ui): L-shell step 7 — [+] new-session button in the tab-bar tail
The right-floated tabbar tail (empty since the scaffold) gets a [+] button that
focuses the persona launcher — the Dashboard pane hosts the unified
coordinator/interactive launcher, and a new session needs a task prompt, so "new
session" composes there.  Cross-deployment via window.showHome (both the console
and standalone expose it) with a pm.openPane("dashboard") fallback; reuses the
scaffold's .tab-add styling.  Auth stays the launcher's concern (it gates each
persona option), so focusing it is always safe.

Verified: renders in the real shell (standalone harness DOM) + a shell JS guard.
2026-06-08 10:08:30 -07:00
Patrick Buckley e60f5a3108 feat(ui): L-shell step 7 — live tab state-glyphs (Tier-1, shape+colour)
Conversational tabs now show a live shape+colour state glyph (● ◐ ⚠ ✗ ○) instead
of the static ◆/○ placeholders the header removal (5e.2e) left behind — driven by
the SAME Tier-1 source + builder the rail uses, so tab and rail always agree.

- rail.js: export the glyph() builder (one source of truth for the mapping).
- pane.js: ShellPane.stateful + PaneManager.setTabGlyph/statefulTabs — generic
  (PaneManager owns no glyph vocabulary; the shell passes the built element).
  A stateful pane builds no static glyph; the shell paints a live .ui-glyph.
- shell.js: stateForWs() reads the Tier-1 snapshot; paintConvTabGlyphs() repaints
  every stateful tab on each Tier-1 render (subscribed to TS_APP.onRender) + per
  pane on activate. Coordinator + interactive panes are now stateful.
- shell.css: .tab .tab-glyph spacing (static + live); live glyphs keep their own
  .ui-glyph-* state colour (no .tab .glyph override).

SINGLE WRITER: the tab glyph is written only by the Tier-1 path (the pane's Tier-2
stream drives its body, not the tab) — no two-tier race, no stale open-time
placeholder on reconnect (BRIEFING L144-147). A coordinator-telemetry-parity gap
(open Q#2) would stale tab + rail equally, consistently.

Verified: 27/27 mechanism harness (8 new glyph asserts) + real-stack wiring
harnesses (console coord ui-glyph-running / int ui-glyph-idle; standalone int
ui-glyph-running — matching the stubbed Tier-1 state, errs:[]) + 26 shell JS
guards + CSS audit at baseline.
2026-06-08 10:08:30 -07:00
Patrick Buckley 7448792251 feat(ui): L-shell step 7 — tab-action dropdown (three-verb close + per-persona verbs)
PaneManager tabs gain a caret opening a generic, keyboard-navigable action
dropdown — recovering the affordances the pane-header removal (5e.2e) dropped.
The mechanism is generic; the item set is pane-type AND deployment derived.

- pane.js: the caret (a <span>, not a nested <button>) + _openTabMenu/_closeTabMenu
  — singleton, right-anchored under the caret with overflow flip + viewport clamp,
  Arrow/Home/End/Esc/Tab nav, ContextMenu/Shift+F10 + right-click open.
- shell.css: the .tab-menu chrome promoted to the SHARED sheet (both deployments),
  recovered from the retired .ws-tab-dropdown design but translated onto the DS
  token vocabulary (--panel-2/--hair-2/--ink-*/--err).
- shell.js: convTabMenu wires each type by capability/feature-detection —
    coordinator: Export · Close pane · Close workstream (its controller's
      closeSession — the Export + end removed from its header land here)
    standalone interactive: Refresh/Edit/Fork · Export · Close pane ·
      Close workstream · Delete (classic ui/static globals)
    console interactive: Export · Close pane (those globals are standalone-only)
    admin: Close pane
  Three-verb close is load-bearing: Close pane (drop tab) != Close workstream
  (stop session) != Delete (destroy + unsave).

Designer-reviewed both personas, dark+light: resting danger cue on Delete (never
colour-alone), elevated --panel-2 surface, accent-wash hover, light key-hint AA,
viewport y-clamp + max-height.

Verified: 19/19 mechanism harness + real-stack wiring harnesses (all three menus,
errs:[]) + 25 shell JS guards + CSS audit at baseline (zero new flips).
2026-06-08 10:08:30 -07:00
Patrick Buckley 9cb2b5d831 fix(ui): L-shell step 6 — wire interactive approval keyboard shortcuts (designer P2)
The converged .conv-* card advertises y/n/a (+Enter/Esc) kbd hints, but the keys
did nothing in the L-shell: the only handler was the old standalone app.js global
keydown gated on getFocusedPane(), which the fork collapse stubbed to null — so
Approve/Deny/Approve-all were mouse-only (the chips over-promised), and that dead
block also queried the retired .ts-approval-feedback class.

Wire the keys pane-owned on this.el (every embedded L-shell pane), restoring the
pre-regression behavior + using the converged .conv-feedback: when a pending
approval is up, in the feedback field Enter approves (with feedback) / Esc denies
and other keys type; elsewhere y|Enter approve, n|Esc deny, a = approve-all. The
composer is disabled while pending, so the feedback field is the only typing
surface. This fixes both the standalone and the console interactive pane (shared
interactive.js); the coordinator pane's keys (console-only) are a separate
merge-gate item. Verified: node-check, the headless shell harness still builds
clean (errs:[]), 102 JS guards green incl. a new wiring guard. The live keypress
-> resolve confirm rides the owed live-backend pass.
2026-06-08 10:08:30 -07:00
Patrick Buckley 80d201a67b chore(ui): L-shell step 6 (5e.2f) — retire dead split-pane CSS from ui/static
Removes the structurally-dead CSS the L-shell superseded — 794 lines: the tab
bar (.ws-tab*, #tab-bar, #split-btn, .ws-tab-dropdown-*), the binary split-pane
machinery (.split-*, #split-root, .pane-ctx-*), the old approval/verdict card
(.ts-approval-*, .verdict-*, the judge spinner), the fixed .dashboard-overlay,
and the retired appbar/settings-overlay bits — plus their [data-theme=light]
overrides.  The standalone now styles its conversation from the shared sheets
(chat/conversation/interactive.css); the dashboard table + saved list were
always shared (base/cards.css).

Method: a conservative token-diff — a rule is dropped only when EVERY selector's
class/id token is absent (word-boundary, comments stripped) from the standalone
runtime (index.html + every JS it loads, incl. the vendored hljs/katex/mermaid
so their runtime-built classes aren't mistaken for dead).  Mixed/any-live rules
are kept verbatim (no reformatting), so ~50 dead-but-harmless rules that share a
generic token like `.active` survive — safe over-keep.  The markdown / syntax /
math / diagram theme lives ONLY in this style.css (the shared sheets don't carry
it), so the hljs/katex/mermaid families are protected from removal.

Also fixes four dead tab-DOM pokes in app.js (editWorkstreamTitle /
confirmDeleteWorkstream read the title from the workstreams roster now, not the
retired .ws-tab .tab-name; the cancel handlers drop the gone .tab-chevron focus
restore).

Verified: braces balanced (370/370), the headless harness still builds clean
(errs:[]), git diff confirms zero live dashboard/render rules removed, and the
css_specificity_audit (manifest synced to the standalone's new sheet set) shows
the SAME 10 pre-existing findings before/after — zero new cascade flips (removing
a rule for a non-existent selector can't change any live element's cascade).
121 JS guards green, ruff/mypy clean.
2026-06-08 10:08:30 -07:00
Patrick Buckley cc508cf481 feat(ui): L-shell step 6 — standalone adopts the L-shell, retire ui/static split-pane
The renovation META-GOAL: a standalone turnstone-server now serves the SAME
capability-parameterised L-shell the console serves (caps {cluster:false,
orchestration:false}), collapsing the console/static vs ui/static fork. No server
change was needed — turnstone/server.py already mounts ui/static at /static; this
changes what ui/static CONTAINS.

ui/static/index.html -> the L-shell skeleton: a hidden #header the shell
relocates (status -> rail, theme/logout -> footer), #main as the Dashboard pane
body (launcher + workstreams table + saved list), a one-panel #view-admin hosting
MCP connections (reusing the #settings-mcp-* table ids), the modals, and the caps
block flipped to {cluster:false, orchestration:false, brandSub:server}. The
split-pane chrome (#tab-bar/#split-root/#split-btn), admin.js/governance.js, and
the separate interactive.js module tag are gone (shell.js imports it).

ui/static/app.js -> a single-node TS_APP/TS_ADMIN/showHome provider (-1417 lines):
- TS_APP.{getClusterState, onRender, bucketByParent, boot}: getClusterState
  synthesizes a one-node cluster from the flat /v1/api/events/global roster;
  boot() is shell-driven (no parse-time auto-run).
- TS_ADMIN: a one-tab Manage IA (Extensions > Connections) whose openTab opens
  the Admin pane + renders the MCP table — the floating settings gear is retired.
- The binary split-pane machinery (layout tree, splitPane/renderLayout, tab bar,
  context menu, tab dropdown, STANDALONE_HOST, createPane, the gear menu) is
  deleted; the keep surfaces (dashboard, global SSE, new-ws modal, MCP
  consent/connections, health/theme/kb) are rewired onto PaneManager + the rail
  (switchTab/renderTabBar/showDashboard become thin shims; sessions open as
  interactive panes).

interactive.js -> the window.InteractivePane bridge is retired (the shell imports
the factory in both deployments; nothing reads the global anymore).

JS guards re-pointed to the L-shell reality (gear/split-pane/window-bridge guards).
126 JS guards green, ruff/mypy clean, node clean. Verified in a headless harness:
the standalone shell builds caps-off (rail = Workspaces + Manage > Connections, no
Cluster), the Dashboard pane adopts #main, TS_APP/TS_ADMIN wired, zero uncaught JS
errors. The owed merge-gate passes (designer both personas + live-backend +
/review) are unchanged.
2026-06-08 10:08:30 -07:00
Patrick Buckley f3a0954b76 refactor(ui): L-shell step 6.0 — make shell.js deployment-agnostic
Two changes so the SAME shell mounts on a standalone turnstone-server (step 6's
META-GOAL: collapse the console/static vs ui/static fork):

- The coordinator pane import is LAZY + gated on caps.orchestration. A static
  `import ... from "/static/coordinator/coordinator.js"` 404s on a standalone
  server (whose /static is ui/static, no coordinator file) and aborts the whole
  shell module. It's now `await import()` inside mountShell, before rehydrate,
  registered only when the deployment has orchestration (the console); a
  persisted coordinator pane then degrades to a rehydrate skip. mountShell is
  now async.

- The interactive pane's nodeId is gated on caps.cluster. Node-proxy transport
  only exists in a cluster deployment; on a single-node standalone every session
  is LOCAL, so nodeId stays null -> the pane uses base="" (no /node/<id> hop),
  even though the synthesized one-node clusterState names a node.

Console behaviour is identical (orchestration:true -> the import runs + the
coordinator registers; cluster:true -> the nodeId ternary's true branch is the
original expression). Verified both personas build clean in a headless harness:
console = [Cluster, Workspaces, Manage] + coordinator registered, no errors;
standalone = caps off, no Cluster, no coordinator, no errors.
2026-06-08 10:08:30 -07:00
Patrick Buckley eaf9f96ad1 feat(ui): L-shell step 5e.2e — remove the interactive pane header (embedded)
Same as the coordinator: in the L-shell the embedded interactive pane's header
content (workstream name + INTERACTIVE persona tag) is redundant — the tab shows
the name and the rail (Workspaces) shows name + state + the INT/COORD persona.
So the embedded pane builds no header and the conversation reclaims the height.

The header build is gated behind !this._embedded (the standalone split-pane,
retired in step 6, keeps its split/close header). The --skip-permissions
SECURITY banner moves off the header to messagesEl (the console host's
warningTarget now matches the default host) so it's preserved, not dropped.
updateWsName null-guards the absent header. Dead .pane--embedded .pane-header /
.pane-ws-name / .pane-persona-tag CSS removed.

Both panes are now header-less in the L-shell — name/state/persona live in the
tab + rail. 103 JS guards green (test_embedded_chrome_is_gated updated for the
no-header reality), node-check clean.
2026-06-08 10:08:30 -07:00
Patrick Buckley 0010fd2b40 feat(ui): L-shell step 5e.2e — remove the coordinator pane header
In the L-shell a coordinator is always a pane (no standalone page), and
everything the header showed is redundant: the name + state live in the pane
tab and the rail (Workspaces); end / export moved to the tab dropdown (step 7);
the light/dark toggle is in the rail footer. So drop the header entirely and
give the wasted vertical pixels to the conversation.

buildCoordChrome no longer builds an appbar. The busy/wait indicator
self-disables (its #coord-header mount host is gone; busy shows in the rail
glyph). The per-pane SSE-connection indicator is dropped — reconnect handles
transient drops, matching the interactive pane (which has none); setSseStatus
and the name/state writes are null-guarded. End/export logic stays reachable
for step 7 (exportWorkstreamDownload(wsId); the pane's closeSession() API).

15 coordinator guards green, node-check clean. (Interactive pane header is next
— it hosts the --skip-permissions security banner, which moves to a pane-top
slot so removing the header doesn't drop a security warning.)
2026-06-08 10:08:30 -07:00
Patrick Buckley 94f1c83a5c test(ui): L-shell step 5e.2c — repoint interactive guards to the .conv-* vocab
The cutover delegated the pane's verdict/warning/status DOM to the shared
conversation.js builders, so three test_app_js guards pinning the old
implementation needed updating to the new reality:
- replayHistory now calls buildConvVerdict(tc.verdict) (was renderVerdictBadge).
- risk normalization moved into the shared builders — the pane carries no raw
  `risk_level || "medium"` fallback and builds via buildConvVerdict /
  buildConvWarning (which route through normalizeRiskLevel in conversation.js).
- the error pill converged onto .conv-status--error (was .ts-approval-badge--error).

Also dropped the now-dead normalizeRiskLevel import from interactive.js (the
builders own normalization; the pane no longer calls it directly). 123 frontend
JS/CSS guards green.
2026-06-08 10:08:30 -07:00
Patrick Buckley 5750a51753 refactor(ui): L-shell step 5e.2c — delete the dead old card CSS
The emitters switched to .conv-* (conversation.css), so the forked card
vocabularies are now dead (match nothing). Remove them:
- coordinator.css: the whole .coord-tool-* tool-batch construct (-552).
- chat.css: the .ts-approval-* / .ts-verdict-* approval shell (-261); dead
  selectors grouped with kept ones in reduced-motion/hover @media blocks were
  stripped from the group, not the whole rule.
- interactive.css: the .ts-approval-* / .verdict-* / .output-warning* / tool-div
  internals (-436), keeping the .tool-output collapse/stream + .media-* result
  subsystem (interactive-only live-execution affordances). Also fixed a latent
  malformed-comment bug — the file header had ".ts-approval-*/" whose "*/"
  accidentally closed the comment, leaving the rest as stray CSS the browser
  silently dropped.

~1249 lines of dead CSS gone. Verified: 0 dead selectors remain across all four
sheets (comments aside), kept anchors present, braces balanced, prettier-clean,
27 JS/CSS guards green. The cards render entirely via conversation.css.
2026-06-08 10:08:30 -07:00
Patrick Buckley 854f9a6e17 fix(ui): L-shell step 5e.2d — fold child-approval over-alert to canonical medium
renderApprovalBlock's child-approval pill mapped an unknown/unrecognized
risk_level -> .high (a deliberate "fail-safe over-alert"). Per the user's
2026-06-06 decision, fold it onto the canonical unknown->medium (5e.1b): the
separate "(judge unavailable)" pill already covers the genuinely-unassessed
case, so this path only fired for a malformed risk_level on an otherwise-present
verdict — a rare data edge, not a "we didn't check" signal. Unknown now maps to
.med, consistent with how every other surface (the shared builders via
normalizeRiskLevel) displays it. No remaining inline `|| "medium"` risk
fallbacks in either emitter.
2026-06-08 10:08:30 -07:00
Patrick Buckley ff7d378235 feat(ui): L-shell step 5e.2c — interactive emitter onto shared .conv-* builders
Re-vocabularize interactive.js's approval card onto the shared conversation.js
builders, converging it with the coordinator onto ONE neutral .conv-* card:
buildToolDiv -> buildConvRow + buildConvCmd, renderVerdictBadge ->
buildConvVerdict, _buildOutputWarningEl -> buildConvWarning; showInlineToolBlock
/ announceToolBlock build the .conv-batch shell + head + rows + buildConvActions
(with the inline feedback + recommended glow); resolveApproval / the auto path
-> buildConvStatus; updateVerdictBadge replaces the badge via buildConvVerdict;
the history-replay branch synthesizes the live `item` shape so replay renders
the SAME .conv-row. ~124 ts-approval-* / verdict-* references gone (a final
no-stale-vocab assert guarded it); dead toggleVerdictDetail removed.

The card chrome converges; interactive's richer post-execution result subsystem
(.tool-output collapse/stream + .media-* embeds) is KEPT as-is — those are
live-execution affordances the read-only coordinator history doesn't need.
Block state classes move to the BEM modifiers (.conv-batch--approved/--denied/
--error/--auto); per-pane keybindings (y/n/a) preserved via the builder kbd
hint. The now-dead old card CSS (.coord-tool-*/.ts-approval-*/.verdict-*) is
inert (matches nothing) and is removed in 5e.2f's CSS dedup.

Verified: node --check both emitters; the no-stale asserts; 60 JS guards green
(test_coordinator_page pinned vocab updated coord-tool-batch-> conv-batch). The
builders are behavior-tested (5e.2b). Designer + /review + live-backend run once
at the merge gate.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9a57ec71e1 feat(ui): L-shell step 5e.2c — coordinator emitter onto shared .conv-* builders
Re-vocabularize coordinator.js's tool-batch construct onto the shared
conversation.js builders (5e.2b): _renderBatchRow -> buildConvRow,
_appendVerdictLineTo -> buildConvVerdict, _attachOutputWarningChip +
appendGuardFinding -> buildConvWarning, _appendResultToRow -> buildConvResult,
_buildBatchActions -> buildConvActions, _buildStatusPill -> buildConvStatus,
the appendToolBatch shell -> buildConvBatchShell. All 115 .coord-tool-*
references (including the security-critical _resolveBatchAction call_id
selector and the SSE upgrade-in-place handlers) renamed to .conv-* (a final
"no coord-tool- remains" assert guarded the rename). Dead _makeActionButton
removed (buildConvActions replaces it).

The verdict converges on the richer expandable badge; its rationale folds into
the verdict detail (the separate .coord-tool-row-rationale <details> is gone).
The warning rationale is now inline. Coordinator renders via conversation.css
(linked since 5e.2a); the old .coord-tool-* rules in coordinator.css are now
dead and get deleted with the interactive switch. Child-approval block
(.approval-*) untouched here — it converges in 5e.2d.

net -249 lines. Verified: node --check + the no-stray-ref assert; the builders
are behavior-tested (5e.2b, 48 asserts). Holistic both-panes harness + designer
pass land after the interactive switch.
2026-06-08 10:08:30 -07:00
Patrick Buckley f79d8a2701 feat(ui): L-shell step 5e.2b — shared .conv-* card builders
Add the pure leaf DOM builders for the unified `.conv-*` card to
conversation.js (both panes import it): buildConvBatchShell / buildConvRow /
buildConvCmd / buildConvVerdict / buildConvWarning / buildConvButton /
buildConvActions / buildConvStatus / buildConvResult. The builders own only
the DOM + class vocabulary; everything stateful (the toolRows map, idempotent
upgrade-in-place, the early-paint announce shell, SSE routing) stays in each
pane and CALLS these in 5e.2c.

Parameterized by AFFORDANCE, not subclass: buildConvRow takes an indexLabel
(coordinator's parallel idx pill) or defers to buildConvCmd (interactive's
bash `$ cmd` + diff preview); buildConvActions takes per-pane keybinding hints
+ an optional feedback input (interactive) and per-pane resolve callbacks. The
persistent action unifies on "Approve all" (dashed --ok ghost), not the
coordinator's old "Always". Risk routes through normalizeRiskLevel so the
per-site `|| "medium"` fallbacks fold onto the canonical unknown->medium; the
judging spinner withholds the --{risk} class so its stripe stays neutral.

Additive — no emitter calls these yet (the re-vocabularize + delete is 5e.2c).
Verified: 48-assert headless-Chrome behavior harness (DOM shape, crit->critical
normalize, unknown->medium fold, expand toggle, action callbacks, JSON
pretty-print) + tests/test_conversation_js.py extended (11) + node --check.
2026-06-08 10:08:30 -07:00
Patrick Buckley bcc9e3dfbe feat(ui): L-shell step 5e.2a — neutral .conv-* approval-card sheet
Author shared_static/conversation.css: ONE neutral `.conv-*` approval-card
vocabulary that both panes will emit, converging the two forked cards
(coordinator's `.coord-tool-*` + interactive's `.ts-approval-*`/`.verdict-*`).
Based on the BRIEFING-blessed `.coord-tool-batch` idiom — neutral surface,
state left-stripe (warn pending / ok approved / err denied), uppercase kicker,
Approve = subtle --ok fill / Approve all = dashed --ok ghost / Deny = --err
(the DS hard-rule: approve uses --ok, never --warn) — with the interactive
affordances folded in (bash `$ cmd`, unified-diff preview, inline feedback,
recommended-button glow, auto-approved tag, the expandable verdict detail).

Converges onto the DS token vocabulary (--ok/--warn/--err, --ink-*, --panel*,
--hair*), not chat.css's legacy --green/--red/--cyan. Self-contained spinner
keyframe (conv-spin) so the sheet doesn't depend on coord-chrome.css's ts-spin,
which the standalone interactive pane never loads.

Additive only — no emitter uses `.conv-*` yet (the re-vocabularize + delete of
the old sheets is 5e.2c). Linked from the console + both standalone pages so
the card is styled the moment 5e.2c switches the emitters over.

Designer-reviewed (rendered both themes): applied the warning-chip wrap fix,
the medium-severity-weight fix (12% mix, not raw --warn-tint), ink-4 -> ink-3
on verdict-detail/tier content for light-mode AA, the bold severity label, and
the neutral judging-row stripe. Guard: tests/test_conversation_css.py (5).
2026-06-08 10:08:30 -07:00
Patrick Buckley 36c60e31e7 fix(ui): L-shell step 5e.1b — unify risk-level handling onto conversation.js
Both panes carried their own risk-level logic that disagreed on the fallback:
interactive's normalizeRiskLevel sent an unknown level to "medium" (and, lacking
the crit/med aliases, rendered a "crit" verdict as medium), while the
coordinator's _riskRank sent unknown to "high".  Lift one canonical normalize +
rank into conversation.js and route both panes through it.

- conversation.js: normalizeRiskLevel (aliases crit->critical / med->medium;
  unknown -> "medium"), riskRank, maxSeverityItem (keeps the no-verdict -> -1
  edge so an unassessed item never wins the max-severity pick).
- interactive.js: import normalizeRiskLevel, drop the local copy (its 3 callers
  unchanged); a "crit" verdict now renders critical instead of medium.
- coordinator.js: import maxSeverityItem, drop RISK_SEVERITY / _riskRank /
  _maxSeverityItem; an unknown-level item now ranks medium, not high.
- unknown -> medium is the deliberate fallback (per decision), not "high":
  medium is the neutral default both panes' displays already used.
- tests: conversation guards for the fallback + aliases + the no-verdict edge;
  the two pane guards now check the shared module.

The per-site risk->CSS-class display mappings (coordinator's inline chips and
renderApprovalBlock's deliberate unknown->crit over-alert pill) are left for the
5e.2 vocabulary reconcile.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9aad278983 refactor(ui): L-shell step 5e.1a — shared conversation.js for the dup'd helpers
Stand up shared_static/conversation.js as the deduplicated conversational-pane
substrate both panes import (interactive via ./, the coordinator via /shared/ —
both ES modules since 5e.0).  First tenants are the byte-identical duplicates the
in-file comments flagged for the step-5e lift: stripAnsi, the watch-result card
builder, and the system-nudge marker.  No visible change — the builders return
the same DOM; each caller still appends + scrolls.

- conversation.js: stripAnsi (null-safe variant), buildWatchResultCard,
  buildSystemNudgeMarker.
- interactive.js / coordinator.js: import the three, drop their local copies,
  delegate appendWatchResult + the nudge marker through the shared builders.
- stripAnsi unified on the coordinator's null-safe form (interactive's threw on a
  non-string arg); identical output for string inputs.
- tests: new test_conversation_js.py pins the module; the two retry-walk guards
  now check the watch-result marker in conversation.js (it moved there).
- refreshes interactive.js's header comment, stale since 5e.0 made the
  coordinator an ES module too.
2026-06-08 10:08:30 -07:00
Patrick Buckley 6614b4a549 refactor(ui): L-shell step 5e.0 — migrate coordinator.js to an ES module
Lift coordinator.js off the window.createCoordinatorPane bridge onto a real ESM
export, so the upcoming shared conversational module (5e) is import-consumed on
both sides rather than through a classic window global. The console shell and the
standalone page's bootstrap both import the factory now; zero behaviour change.

- coordinator.js: export the factory, drop the window bridge — no classic
  consumer remains (unlike interactive.js, whose ui/static app.js still uses its
  global).
- shell.js: import the coordinator factory by URL (mirrors the interactive
  import) and call it directly.
- console index.html: stop script-tagging coordinator.js; shell.js's import loads
  it (a classic tag chokes on the top-level export).
- coordinator/index.html: the standalone bootstrap becomes a module that imports
  the factory — a classic eager IIFE ran before the deferred module loaded it.
- tests: pin the new ESM seam (export, shell import, module bootstrap).
2026-06-08 10:08:30 -07:00
Patrick Buckley 1d399703b1 fix(ui): L-shell step 5d — designer-review fixes (rail active-marker, tab glyph)
Designer pass on the new console interactive pane.  Two clean fixes; the rest of
the findings are scoped to their planned steps (see below).

- Rail Workspaces `.open` marker now tracks the ACTIVE pane instead of being
  hardcoded to Dashboard — the rail map and the tab bar were disagreeing about
  what's focused (opening a session never moved the rail highlight).  PaneManager
  gains getActive() + onActiveChange() and fans out on activate/close; the rail
  keys `.open` off the active pane's rawId and re-renders on activation (not just
  on the next Tier-1 snapshot).
- The active tab's glyph brightens (--ink-4 -> --ink-2) so an open session's `○`
  placeholder doesn't read permanently "idle" beside its live (running ●) rail
  row.  (Tab glyphs go fully live in step 7.)

Deferred (planned elsewhere, not regressions): the tab CLOSE affordance is step 7
(the brief's three-verb `.ws-tab-dropdown`); the interactive/coordinator HEADER
consistency is what the step-5e base lift unifies (a shared header parameterized
by affordances), so partial coordinator-header surgery now would be a half-measure.

Verified: a headless screenshot (rail `.open` now on the active session, slim
header + persona tag render clean) + 107 JS-guard tests (test_shell_js 5d guard),
node --check, prettier, ruff.
2026-06-08 10:08:30 -07:00
Patrick Buckley da8ee0b018 feat(ui): L-shell step 5c — coordinator child-links open interactive panes
Rewire the coordinator's child ws links (deferred from step 4) to open the child
as a node-proxied interactive pane inside the console L-shell, instead of a
full-page new tab to /node/{id}/?ws_id=.

- A delegated click handler on the pane root catches .ws-link (children tree,
  renderChildRow) and .coord-ws-link (linkified tool output, renderToolOutput)
  clicks; both link types now carry data-ws-id + data-node-id. When a
  PaneManager is present it opens openPane('interactive', child_ws_id,
  {nodeId: child_node_id}) — the child's OWN node, so its stream proxies to that
  node even though the coordinator lives in the console.
- Progressive enhancement: the link's href (/node/{node}/?ws_id=) stays the
  standalone fallback — the standalone coordinator page has no PaneManager, so
  the new-tab nav stands. No innerHTML introduced (the tool-output linkifier
  still returns a string; only data-* attrs were added).

Verified: a harness running the console stack (coordinator.js + shell.js +
interactive.js) — open a coordinator pane, click a child link -> a node-proxied
interactive pane opens (/node/{child_node}/.../events), zero errors; 106
JS-guard tests (test_coordinator_page step-5c guard), node --check, prettier.
2026-06-08 10:08:30 -07:00
Patrick Buckley b9ba542ce6 feat(ui): L-shell step 5b — register the interactive pane in the console shell
Wire the shared interactive Pane (5a) into the console L-shell as a ws_id-keyed,
node-proxied conversational pane.

- shell.js IMPORTS createInteractivePane (interactive is a real ES module, so
  the shell consumes it the modern way; the legacy coordinator pane stays on the
  window.* seam — the incremental "pulled by the adopting pane" modernization).
  registerType('interactive') mirrors the coordinator: build on mount, connect
  on activate (idempotent) + login re-arm, deactivate on tab-away (stops
  focus-stealing while the stream stays live), destroy on close.
- The node-proxy target is DERIVED from the Tier-1 snapshot (nodeForWs), so a
  rehydrated pane needs no persisted node_id; a rail click / child link can pass
  {nodeId} as an open-time hint. openPane(type, id, extra) threads that hint to
  the factory (not persisted).
- rail.js: interactive session clicks now openPane('interactive', ws.id,
  {nodeId: ws.node}) instead of full-page nav to /node/{id}/.
- interactive.css (new, shared): the embedded slim-header layout (scoped to
  .pane--embedded so it never collides with the ShellPane's own .pane section —
  the brief's namespace watch-out) + the conversational rendering (tool output /
  media / MCP-error / verdict / output-guard cards) COPIED from ui/static. The
  shared chat.css .msg/.ts-approval base is left untouched, so the coordinator
  pane is unaffected; step 5e unifies the vocabularies, and ui/static keeps its
  copy for the standalone until step 6.

Verified: an integration harness running the REAL shell.js + rail.js +
interactive.js (register -> rail-open -> embedded chrome -> node-proxy SSE
/node/{id}/.../events -> /history replay into real .msg turns -> destroy, zero
errors) + a screenshot; 105 JS-guard tests (test_shell_js step-5 guard),
node --check, prettier, ruff.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9674e566d9 feat(ui): L-shell step 5a — extract interactive Pane into a shared ESM module
Lift the per-workstream conversational Pane (chat + approval cards + composer +
voice + tool/media/MCP-error/verdict rendering) out of ui/static/app.js into a
new shared ES module shared_static/interactive.js, so BOTH deployments can
mount it: the standalone turnstone-server UI (its split-pane shell stays in
app.js and builds panes via window.InteractivePane) and — next, in step 5b —
the console L-shell over a node-proxied Tier-2 stream.

- Transport seam: a per-pane `base` prefix ("" local, "/node/{id}" proxied)
  threads through every request; createInteractivePane derives it from nodeId
  (the LOCALITY invariant — an interactive session lives on a cluster node).
- Host seam: the couplings only the surrounding shell knows (workstream name,
  focus, stream-error recovery, the --skip-permissions banner target, the MCP
  consent badge) route through an injected host adapter; the standalone shell
  supplies the real one (refetchWorkstreamsAndReassign + STANDALONE_HOST), the
  console factory a Tier-1 / no-op one.
- Embedded chrome: the standalone split-pane affordances (focus tracking,
  context menu, split/close buttons) are gated behind !embedded; the embedded
  path adds the INTERACTIVE persona tag.
- First legacy pane lifted into a real module: it exports the factory for the
  console shell's import and bridges window.* for the still-classic standalone
  shell (which builds panes only after the workstream fetch, so the deferred
  module has run). coordinator.js + the shared substrate stay classic.

The whole tool-output / media / MCP-error / verdict cluster moved with the Pane
(used only by it); the consent-BADGE subsystem stays in the standalone shell,
reached via host.onConsentDetected.

Verified: 104 JS-guard tests + a headless harness running the real module
(standalone + embedded chrome, node-proxy transport, lifecycle, zero errors),
node --check, prettier, ruff.
2026-06-08 10:08:30 -07:00
Patrick Buckley 025bc4a3fd chore(ui): prettier-format the web-UI frontend (clean baseline for the next session)
A dedicated, formatting-only pass over the renovation's frontend so future edits
inherit a consistent style — LLM/contributor edits pattern-match the surrounding
code, so a clean baseline keeps it clean.  Covers every non-conformant
.js/.css/.html under shared_static/ + console/static/ + ui/static/ (vendored
katex/hljs + *.min.* excluded; the other ~22 frontend files were already clean).
No rule, value, or markup-semantic changes — whitespace/wrapping only.

Also fixes a real (browser-tolerated) bug the pass surfaced: a `*/` inside a
coord-chrome.css header comment (`#coord-*/coordinator-class`) closed the CSS
comment early; reworded so the comment is valid.

Files: coord-chrome.css, console/static/{index.html,style.css}, coordinator.css,
shared_static/{auth.js,base.css,chat.css,ui-base.css}, ui/static/index.html.
2026-06-08 10:08:30 -07:00
Patrick Buckley 6e0901ee1b fix(ui): L-shell step 4 — designer-review fixes (pane-aware close, overflow, glyph, a11y)
Four findings from the step-4 designer pass on the coordinator pane.

- P1 (bug): the `end` button ran `window.location.href = "/"`, which inside the
  L-shell reloaded the WHOLE console — every other pane destroyed, all their
  Tier-2 streams dropped. Thread an `onClose` through the factory; the console
  pane passes `() => pm.close(pane.id)` so `end` closes that tab (and runs the
  controller teardown via onClose→destroy); the standalone page passes none and
  keeps the console redirect.
- P2: the pane root carries both `.pane-body` (overflow:auto) and
  `.coord-chrome-root` (flex column), so the generic pane scroller redundantly
  wrapped the sticky appbar. `.pane-body.coord-chrome-root { overflow: hidden }`
  (scoped to this pane type) — the coord chrome owns its own scroll regions.
- P3: the coordinator tab glyph `●` collided with the rail's running state-dot
  vocabulary (a static dot reading as "live"); swap to `◆` (a shape marker that
  pairs with dashboard's `◇`), pending the real state-glyph in step 7.
- P3: the destructive `end` button had only a title; add aria-label
  "End coordinator session".

Verified via the harness (clicking `end` closes the pane without reloading;
glyph `◆`; aria-label present; zero errors); guards pin the pane-aware close.
test_shell_js + test_coordinator_page (97 green), ruff.
2026-06-08 10:08:30 -07:00
Patrick Buckley 05dbcf3818 feat(ui): L-shell step 4b — coordinator sessions as console panes
The console can now host coordinator sessions as ws_id-keyed panes alongside
dashboard/admin — step 4 complete (the de-globalization landed in 4a).

- coordinator.js: `buildCoordChrome(root, opts)` builds the coordinator chrome
  programmatically (createElement, no innerHTML); the factory builds it on
  instantiate, so the SAME factory serves the standalone page and a console pane.
  `opts.standalone` adds the page-level bits a pane doesn't want (the Console
  back-link, the theme toggle, the shared #toast).
- index.html (standalone): goes thin — a bootstrap calling
  createCoordinatorPane(document.body, ws_id, {standalone:true}); the ~500-line
  inline <style> is migrated to coord-chrome.css (its lone page-level body rule
  scoped to .coord-chrome-root) so the console can load the same chrome CSS.
- shell.js: registerType('coordinator') keyed by ws_id — onMount builds the
  controller into the pane body, onActivate opens its Tier-2 SSE once, onClose
  destroys it. Plus a window.TS_LOGIN fan-out registry so every pane re-arms its
  own stream on re-auth (app.js's single onLoginSuccess becomes one subscriber).
- rail.js: coordinator clicks → openPane('coordinator', ws_id) instead of
  full-page nav (interactive sessions stay interim full-page until step 5).
- console/index.html: loads the coordinator controller + chrome CSS + the shared
  composer/renderer deps it needs.

Child links → openPane('interactive', ws_id) are deferred to step 5 (the
interactive pane doesn't exist yet); coordinator transport stays console-local
inline (parameterized only when the shared ConversationalPane base is lifted).

Verified end-to-end with a headless harness running the real shell.js + rail.js +
coordinator.js: opening a coordinator pane registers the type, the rail row opens
it, buildCoordChrome populates the pane, the Tier-2 SSE connects, destroy() tears
down — zero uncaught errors; renders cleanly (appbar + chat + children/tasks
sidebar + status bar). test_shell_js + test_coordinator_page (97 green), ruff.
2026-06-08 10:08:30 -07:00
Patrick Buckley 0231253c68 refactor(ui): L-shell step 4a — de-globalize coordinator.js into a pane factory
coordinator.js was a page-global IIFE keyed off <html data-ws-id>. Make it
multi-instantiable so the console shell can host coordinator sessions as panes
(one per ws_id) alongside dashboard/admin — the first conversational pane-content.

- IIFE -> `createCoordinatorPane(root, wsId)`: the ~40 module-state vars stay
  closure-local (now automatically per-instance), every #coord-* lookup is
  root-scoped (27 getElementById -> root.querySelector), ws_id is a constructor arg.
- New lifecycle: `connect` (= init), `destroy` (closes the EventSource + clears the
  6 timers + the prune interval + the IntersectionObserver — the IIFE had no
  teardown, so a backgrounded pane would leak an SSE and fire into detached DOM),
  `onLogin` (re-arm after a 401), `closeSession`.
- Drop the page-global collision points: `window.coordSend`/`coordCloseSession`
  -> local fns (the close button binds per-instance; its inline onclick is removed);
  `window.onLoginSuccess` -> the returned `onLogin` (the console shell will fan
  login out to every pane; standalone keeps the single hook).
- Standalone coordinator page = one pane filling the body: a thin bootstrap calls
  `createCoordinatorPane(document.body, ws_id).connect()`.

Console-local transport (the coordinator endpoints) stays inline — coordinators
always live in the console; transport is parameterized only when the shared
ConversationalPane base is lifted (after step 5). The chrome builder, CSS
migration, and console pane registration are 4b.

Verified: node --check; a headless smoke (the real factory instantiates against a
provided root, runs connect()'s snapshot/history/children/tasks/SSE on stubs, then
destroy()s — zero uncaught errors); test_coordinator_page.py (13, incl. a new
factory-shape guard); ruff.
2026-06-08 10:08:30 -07:00
Patrick Buckley 1f254d764a fix(ui): L-shell step 3 — designer-review fixes (admin-pane inset, rail seed, a11y)
Five findings from the step-3 designer pass; the P3 chevron-rotation (taste) was
skipped — the text-swap is already motion-safe.

- P1: the adopted #view-admin had no inset, so the first admin section-header
  butted the tab-bar hairline + rail edge. Add `padding:16px 0 0 16px` on
  `.pane-body > #view-admin` (.admin-content keeps its right pad).
- P2: the rail Manage active-marker never seeded from getActiveTab(), so a
  PaneManager.rehydrate-restored Admin pane showed no active group/row until a
  re-click. mountManage now takes the PaneManager, seeds the marker + expands the
  owning group when the Admin pane is already open (new PaneManager.hasPane()).
- P2: the active-row band was byte-identical to the amber `.row.open` of live
  sessions (distinct only by a 2px stripe). Give it its own neutral idiom —
  `--panel-2` fill + a hairline `inset 2px` marker — so "which admin tab" reads
  as different in kind from "which session is live".
- P3: `.gcount` pinned right with `margin-left:auto` (was incidental via flex).
- P3: strip the dangling `role="tabpanel"`/`aria-labelledby="tab-*"` from the 18
  adopted admin panels (their sidebar buttons were deleted in 3b); the 9 legit
  tabpanels elsewhere are untouched.

Verified via the headless harness (rail / admin-open / rehydrate states) +
test_shell_js.py guards (the aria strip is now pinned).
2026-06-08 10:08:30 -07:00
Patrick Buckley 1001293217 refactor(ui): L-shell step 3b — delete the retired admin sidebar + mobile drawer
The rail's Manage groups replaced the in-pane admin sidebar in 3a; this removes
the now-dead markup, JS, and CSS that it leaves behind.

- index.html: drop the #admin-sidebar nav (6 groups / 18 buttons) + the mobile
  #admin-sidebar-backdrop; #admin-layout now wraps #admin-content alone.
- admin.js: delete the mobile off-canvas drawer (_mobileSidebarOpen,
  _injectMobileToggle, _toggleMobileSidebar, the Escape-to-close + arrow-nav +
  resize-sync handlers) and switchAdminTab's now-dead .admin-nav active loop +
  breadcrumb write.
- style.css: remove the .admin-sidebar* / .admin-nav* / .admin-mobile-toggle*
  rules, the mobile off-canvas @media block, and the dead reduced-motion entries.
- shell.css: drop the .pane-body .admin-sidebar hide rule (nothing to hide now).

.admin-layout / .admin-content / #view-admin stay (the Admin pane adopts them).
Verified: no residual sidebar/mobile refs, CSS braces balanced, admin.js parses,
headless render unchanged, and test_shell_js.py pins the removal.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9c4ec4855d feat(ui): L-shell step 3a — Admin pane + rail Manage groups
Admin becomes a singleton pane and the rail's Manage section becomes its
navigation; the in-pane sidebar is retired.

- shell.js registers an `admin` pane type that adopts #view-admin (the 18
  tabpanels) on first open; the dashboard pane keeps #main.
- admin.js: new ADMIN_IA seam (window.TS_ADMIN) — the group→tab map, a shared
  adminTabAllowed() gate (mirrors the legacy showAdmin permission gate, incl.
  the ungated node list), an active-tab subscription, and openTab. showAdmin is
  now a thin delegator (openPane('admin') + switchAdminTab); the in-#main view
  toggle, breadcrumb write, history push, and mobile-hamburger injection go.
- rail.js: mountManage() builds the six collapsible .grp groups from the seam,
  permission-filtered, routing a row click through openTab — never touching
  admin DOM.
- app.js: home/drill re-focus the Dashboard pane instead of blanking the moved
  #view-admin.
- shell.css: the .grp vocabulary + admin-pane layout (in-pane sidebar hidden,
  #view-admin fills the pane).

The legacy #admin-sidebar is hidden via CSS pending its deletion in 3b; this is
the additive, independently-runnable half. Verified with a headless-Chrome
harness driving the real shell.js + rail.js over a stubbed seam, plus the
test_shell_js.py guards (19 passing).
2026-06-08 10:08:30 -07:00
Patrick Buckley 98b4e08d3f fix(ui): L-shell step-2 designer-review fixes — drift outlier, toggle a11y, rail focus ring
From the designer pass on the live rail + persona launcher:

- rail.js: the version-drift amber now marks only nodes whose version differs from the cluster majority (was painting every node when the cluster drifted — the highlight pointed at everything, so at nothing). Adds a per-node title naming the majority.

- app.js + index.html: the persona toggle honours its role=radiogroup contract — arrow keys move the selection, roving tabindex makes the group a single tab stop (seeded statically + in _setLauncherKind), instead of announcing radios but behaving like plain buttons.

- shell.css: an inset (-2px) :focus-visible ring for the rail rows/pills + persona buttons, so the keyboard focus outline doesn't clip against the 266px rail edge (mirrors .dash-row:focus-visible).
2026-06-08 10:08:30 -07:00
Patrick Buckley b453ac5bb3 feat(ui): L-shell step 2b — persona-unified dashboard launcher + both-kinds saved list
The dashboard body becomes a persona-unified launcher (start a coordinator OR an interactive session from one composer) and the saved list spans both kinds; the redundant active-coordinators table is dropped (the rail covers it now).

Backend — the console /v1/api/workstreams/saved now returns both kinds: session_routes.py extracts _collect_saved_rows (shared by the refactored, behaviour-preserving make_saved_handler) + adds make_unified_saved_handler (merges per-kind queries — run concurrently via asyncio.gather — sorted by updated desc). The operator gate (admin.coordinator) is applied once; no new exposure (operators already see every session). console/server.py mounts it with [coordinator, interactive] cfgs.

Frontend — a persona toggle routes submit by kind: coordinator -> console-local POST /v1/api/workstreams/new; interactive -> node-proxy POST /v1/api/cluster/workstreams/new (auto placement). Each option is scope-gated (admin.coordinator / workstreams.create); attachments stay coordinator-only. The saved list gains a KIND tag column + kind-routed activation (coordinator -> /open + /coordinator; interactive -> /node/{id}/?ws_id=) and stays operator-gated. The active-coordinators table + _renderHomeView/_activeCoordsFromClusterState are removed.

Tests: make_unified_saved_handler coverage (tests/test_saved_handler_unified.py, synthetic fixtures, no DB) + a console-launcher static guard (tests/test_shell_js.py). Reviewed via the multi-stage pipeline; findings applied (client/server gate match, concurrent queries, chip-CSS dedup, static guards, stale-comment cleanup).
2026-06-08 10:08:30 -07:00
Patrick Buckley 6a2f83f829 feat(ui): L-shell step 2a — rail Cluster + Workspaces go live, retire bottom bar
The rail's Cluster + Workspaces sections (step-1 stub labels) now render live from the Tier-1 clusterState, and the legacy bottom #cluster-status-bar is retired — the rail replaces it (the L-shell has no bottom bar).

New shared_static/rail.js (ESM): renders Cluster (health pills wired to drillDownByState + a node list with version/drift) and Workspaces (the session tree — coordinators with children nested via the shared _bucketByParent, COORD/INT persona tags, state = shape+colour via ui-base .ui-glyph-*).

app.js exposes a minimal Tier-1 seam on window.TS_APP (getClusterState + onRender + the rail's nav actions); renderFromState fires subscribers. No physical clusterState extraction — the seam closures see the live binding. shell.js builds the Cluster/Workspaces render targets and mounts rail.js before boot so it catches the first snapshot.

Retire the bottom bar: delete the #cluster-status-bar markup + renderStatusBar / renderNodePicker / the node-picker helpers + STATE_ORDER (~310 lines), and the .stale toggles in connectSSE (the rail-conn #status-bar carries connection state now). buildNodeInfoFromSnapshot / recomputeOverview / _bucketByParent stay — the rail reuses them. The dashboard body still carries its active-coordinator table transiently; 2b reshapes it into the persona launcher + unified saved list.
2026-06-08 10:08:30 -07:00
Patrick Buckley 96762c2874 feat(ui): L-shell scaffold — rail + tab bar + PaneManager pane host
Step 1 of the console renovation: a full-height left rail, a top tab bar, and a generic pane host that shows one pane per tab. Existing console content is hosted unchanged inside it as the default Dashboard pane.

New shared_static ES modules (the first ESM citizens; classic scripts keep loading alongside them): pane.js (PaneManager + ShellPane — typed-window host with openPane/activate/close, sessionStorage rehydrate, a WAI-ARIA tablist with roving tabindex + arrow-key nav, reconcile-in-place tabs); shell.js (builds the rail/tab-bar/pane-host, reparents #main + #status-bar with ids preserved so connectSSE needs no rewire, relocates the header controls into the rail footer, drives the app boot); shell.css (chrome ported from the layout mock to base.css tokens).

console index.html loads the shell module + capability flags + stylesheet; app.js's bottom init is wrapped into window.TS_APP.boot, which the deferred shell module drives (it runs after the classic scripts). Cluster health, the Workspaces tree, admin, and conversational pane types arrive in later steps; the rail sections are labelled stubs.
2026-06-08 10:08:30 -07:00
renovate[bot] 3003e935f4 chore(deps): lock file maintenance 2026-06-07 22:44:37 -07:00
Patrick Buckley 87d1ea8530 chore: bump version to 1.6.0a11 2026-06-04 18:40:02 -07:00
Patrick Buckley 09e41d1502 fix(coord): extend the system_turn /history+replay dedup to the coordinator pane
The interactive pane (app.js) got the event-id dedup that skips an
operator-context system turn already painted from /history when an SSE replay
redelivers it; the coordinator pane (coordinator.js) shares the identical
/history + live system_turn + last_event_id replay seam but was left without
the guard.  The backend row/event-id alignment already fixes the actual double
for both panes — this restores the defense-in-depth symmetry.

- Module-scoped renderedSystemEventIds (persists across reconnects like
  lastEventId), reset in refetchHistory.
- onmessage tags each event with its SSE id; the live system_turn handler skips
  an already-rendered id; the history loop records the ids it paints.
- Parallel static-shape regression test in test_coordinator_page.py.
2026-06-04 11:03:13 -07:00
Patrick Buckley 21af6c4970 fix(ui): align system-turn row event_id with its SSE event (no double-render)
A first-class operator-context system turn (metacognition nudge, output-guard
finding, interjection, watch result) was persisted stamped with the event-id
counter's PRE-emit value, then its live `on_system_turn` SSE event was emitted
with the post-increment id — so the row sat one below its own event.  On an
in-flight-orphan `/history` resume, `_resume_cursor_and_trim` derives the SSE
replay cursor from the row's id; being one low, the replay redelivered the
turn's own `system_turn` event and the frontend (no dedup) painted the
operator bubble twice.  Reliable for coordinator-spawned children (opened
mid-task) and self-healing on rehydrate — a non-persisted, live-only double.

- `SessionUIBase._enqueue` returns the monotonic `_event_id` it assigns;
  `on_system_turn` returns it; `_append_system_turn` emits the hook first and
  persists the row with that id (fallback to the current cursor for non-SSE
  UIs / a throwing hook).  Now row.event_id == its own SSE event id.
- `project_history_messages` surfaces each row's `event_id` so the frontend
  can dedup.
- app.js: tag each SSE event with its id, reset a per-pane rendered-id set on
  `replayHistory`, and skip a `system_turn` already painted from `/history`
  (belt-and-braces against any future cursor skew).
- Regression tests pin the row/event id alignment, the `/history` emit, and
  the FE dedup.
2026-06-04 11:03:13 -07:00
Patrick Buckley 0c3d1d6cd1 test: satisfy ruff-format and lift side-effects out of asserts
- ruff format on two test modules that had drifted (a stray blank line
  and multi-line calls that now fit on one line) — restores a clean
  `ruff format --check`.
- test_attachment_buffer: pull `buf.discard(...)` out of the `assert`
  expressions into locals so the eviction still runs under `python -O`
  (CodeQL: assert statement has a side effect).
2026-06-04 11:03:13 -07:00
Patrick Buckley b3c1b9c9e0 build: promote anthropic, postgres, console, tls to core dependencies
The Anthropic SDK provider was the lone first-class provider gated behind
an optional extra, while OpenAI ships in core and Google rides the
OpenAI-compatible path. Fold anthropic, psycopg (postgres), croniter
(console), and lacme (tls) into the base dependency set so a default
`pip install turnstone` yields a complete single- or multi-node
deployment; only the Discord/Slack channel gateways stay optional.

- pyproject: four extras → base deps; `all` is now discord+slack; drop the
  redundant croniter from the `test` extra; regenerate uv.lock.
- ci: the postgres test job installs `.[test]` (psycopg is base now).
- providers: `_ensure_anthropic` becomes a thin SDK accessor for
  `create_client`; drop the now-redundant eager import-guard calls from
  the streaming/completion hot path (anthropic is always present).
- bootstrap: import anthropic directly.
- tests/docs: drop the anthropic importorskips and stale extra-install hints.
2026-06-04 11:03:13 -07:00
Patrick Buckley b2273089bd fix(ui): label metacognition nudges as "metacognition", not the raw nudge type
The operator-context consolidation persists each metacognition nudge's type as
``_source`` (start / resume / correction / denial / completion / repeat), and
both panes rendered it raw as "operator · start".  Add a shared
``operatorSourceLabel`` helper in utils.js (loaded by both UIs) that collapses
the metacognition types to one "metacognition" category and humanizes
``tool_error`` / ``skill_hint``; both panes call the single helper so they can't
drift.  Carded kinds (watch / guard / idle / interjection) are unaffected.
2026-06-04 11:03:13 -07:00
Patrick Buckley 16294397c2 perf: dict-native wire-prep — drop the per-send Turn<->dict round-trip
The canonical-Turn migration left lowering's fold/drop/repair passes Turn-typed even
though they convert to dicts internally and feed dicts to the translators, so
_prepare_wire_messages round-tripped the whole history Turn->dict->Turn ~7-8x per send
(even on the no-op early-return paths). Make fold_system_turns / drop_empty_user_turns
/ repair_wire_messages dict-native (list[dict]->list[dict]); _prepare_wire_messages now
threads the dict projection _full_messages already produced straight through, with no
Turn round-trip. self.messages stays the canonical Turn trajectory. export.py is
simplified (it converted to dicts immediately after repair anyway). Equivalence-
preserving — test_wire_payload_golden stays byte-identical.
2026-06-04 11:03:13 -07:00
Patrick Buckley 4927efe942 refactor: pre-push review — content-addressed upload buffer, GC dedup, security hardening
- Buffer (attachment_buffer.py): content-address staged bytes once and track the
  per-(ws_id,user_id) references to them, so identical bytes staged from two tabs
  dedupe to one copy yet neither scope's send can drop the other's pending upload
  (the prior hash-only key let one overwrite the other). Single lock; add a public
  clear() that replaces test reaches into the private store.
- GC: lift the byte-identical _release_attachment_refs out of both backends into one
  dialect-agnostic storage/_utils.release_attachment_refs with a portable searched-
  CASE single-query decrement (was one UPDATE per id in a Python loop).
- _format_messages_for_summary: mark by-reference vision results
  ({type:image, attachment_id}) as [image], not just inline image_url.
- Security: escape_like() the attachment_referenced_in_ws LIKE needle on both
  backends; secrets.compare_digest for the output-guard operator-fence leak check.
2026-06-04 11:03:13 -07:00
Patrick Buckley 514b1ff8bb fix: pre-push review — page migration 060 backfill, heal legacy orphan tool_use on load
- B1: page _backfill_content_addressed_attachments via a composite keyset cursor
  (message_id, created, attachment_id) instead of one un-paged fetchall() of every
  blob's bytes — bounds peak migration memory regardless of stored blob volume.
  Validated on the dev-DB snapshot: upgrade + downgrade clean, 5431 conversations
  preserved, blobs deduped + content-addressed.
- B2: _native_from_provider_data strips orphan client tool-call blocks on load when
  the row's tool_calls column is empty (the truncated-mid-tool_use legacy hole), so
  a same-provider resume can't replay an unanswered tool_use — closing the Anthropic
  400 and the Google tool-call resurrection path. Healthy (mirror-holds) rows decode
  byte-identically.
2026-06-04 11:03:13 -07:00
Patrick Buckley 107967f548 fix: pre-push review — operator-context retry walk, get_content offload, doc cleanups
- A1: add a shared `operator-context` marker to every operator-context row in
  both UIs; the retry-skip walk keys on it, so a trailing watch-result /
  guard-finding / idle-children card no longer makes retry regenerate the
  wrong turn. Pinned by source-grep tests + a headless-DOM self-test.
- A2: wrap get_content's two sync DB gates (get_attachment + the unbounded
  ws-scoped attachment_referenced_in_ws LIKE scan) in asyncio.to_thread so a
  long scan can't stall the event loop — matching the module's convention.
- A3: add the HistoryEvent attachments docstring bullet; drop the dead
  `interjection` class; document the interactive-only system-context label;
  correct the SDK attachments-meta docs to {kind, filename, mime_type}
  (size_bytes is not carried through the history projection).
2026-06-04 11:03:13 -07:00
Patrick Buckley 164f74dead feat(operator-context): deliver structured per-kind meta to the UI
Operator-context system turns (watch results, output-guard findings, idle
children, user interjections) carried their kind (_source) and a flattened
text content, but the structured per-kind fields were dropped at every
persist/deliver boundary — so the UI rendered every kind as one generic
operator bubble and the structured watch-result card was lost.

Wire the structured meta through as the single source of truth:

- Storage: new conversations.meta JSON column (migration 060); threaded
  through save_message/save_messages_bulk (facade + protocol + both backends)
  and rehydrated in reconstruct_turns onto Turn.meta.extra["source_meta"].
- Canonical: make_system_turn carries meta as one _source_meta dict;
  turn_from_dict/turn_to_dict bridge it to/from Turn.meta.extra.
- Live + history: widen on_system_turn(content, source, meta) across all
  impls + the SSE payload; surface _source_meta -> meta in the /history
  projection. SDK HistoryEvent docs note the field.
- Producers derive both the model-facing content text AND the card from one
  meta dict, so they cannot drift: render_output_guard_text, build_watch_
  reminder carrying output, idle_children and user_interjection metadata.
- Frontend: addSystemContext / renderSystemTurn dispatch by source to the
  watch-result, guard-finding, idle-children, and queued-message cards in
  both the interactive and coordinator panes; every untrusted field renders
  via textContent.

The meta is a leading-underscore key, stripped before the wire (sanitize_
messages and the native mid-conversation path copy only role+content), so the
per-provider wire payloads stay byte-identical. Additive column, no backfill:
operator turns predating it reload as plain text bubbles.
2026-06-04 11:03:13 -07:00
Patrick Buckley 0e0d0bbf72 fix: pre-push review fixes from the canonical-trajectory deep-dive
A deep-dive review of the branch surfaced a budget regression, SDK doc
drift, dead code, and stale docstrings. Each was boundary-spiked before
fixing.

- R1 (regression): by-reference document attachments were invisible to the
  token budget — _msg_text_chars returned 0 doc_chars for a
  {type:document,attachment_id} placeholder, and the comment's claim that
  the budget "lands at calibration" was false (calibration discards
  doc_chars). Thread the doc size through _attachments_meta (size_bytes, at
  both the live-append and reconstruct build sites) and count it in
  _msg_text_chars, guarded against double-counting the inline form.
  Regression test added.
- F1: the history-DTO schema description and the TS HistoryEvent docstring
  still advertised the removed reminders/advisories keys and omitted the
  system role; corrected server_schemas.py + sdk/typescript/src/events.ts to
  match the shipped shape. The committed OpenAPI JSON snapshots were already
  ~679 lines stale on main; their regen is left to its own chore branch.
- D1: removed AttachmentBuffer.take() — dead (no production caller; the
  commit path uses discard()) and scope-weak (ws_id only, unlike its
  siblings) — with its test and the now-orphaned Iterable import.
- O1: 4 docstrings referenced the moved ChatSession._fold_system_turns →
  lowering.fold_system_turns.
2026-06-04 11:03:13 -07:00
Patrick Buckley 0f1d03755a refactor(storage): drop dead ws_id/user_id from workstream_attachments
The blob store is global content-addressed — identical bytes dedupe across
workstreams and users, so the per-tenant ws_id/user_id scope columns are dead:
nothing reads them, and a committed blob is authorised via the
conversations.attachments ref-list (attachment_referenced_in_ws), not a row
scope. Drop both columns and idx_ws_attachments_ws_id from the schema and from
the save_attachment signature (protocol + both backends + memory wrapper + the
caller); fold the column/index drops and their downgrade into the unshipped
migration 060.

tool_name stays: it is a live denormalised search label (search_history →
recall + /history), not trajectory data — "never rehydrated" held only for the
wire path, which already ignores it.
2026-06-04 11:03:13 -07:00
Patrick Buckley 964a390e5e feat(attachments): resolve AttachmentRef at the translator; drop RawContentBlock
The by-reference content lane now materializes at the provider translator (the
C layer), not in the session.  Each create_streaming / create_completion takes
a resolve_attachments callback and runs materialize_attachments() up front,
expanding {type:kind, attachment_id} placeholders to inline data-URI / document
parts by a content-addressed point-lookup the session hands down
(_resolve_attachments).  _full_messages emits placeholders; the dict bridge
carries only placeholders.

RawContentBlock is removed — ContentBlock = TextBlock | AttachmentRef.  A
resolved inline part is terminal (the wire payload / display output) and never
re-enters the canonical path, so turn_from_dict drops a stray inline image_url
rather than carrying bytes.  resolve_attachment_parts / materialize_attachments
operate on the dict projection.  Tool vision output rides by reference too
(_tool_content_by_reference): the turn carries placeholders, the bytes persist
content-addressed.  The per-turn token estimate counts a by-ref image as one
fixed image budget; the document char budget lands at send (on resolution).

Wire harness byte-identical (the multipart fixture is a placeholder + a matching
resolver); full non-live suite green (7136).
2026-06-04 11:03:13 -07:00
Patrick Buckley 8c538148cc feat(attachments): AttachmentRef as the canonical by-reference content
Non-text content (user uploads, reloaded tool images) rides as AttachmentRef(id,kind)
in the canonical Turn — session.messages carries ids, never bytes.  Each output
materializes it to inline data-URI/document parts by point-lookup on the content-
addressed store: the wire (ChatSession._lower_messages_to_wire, in _full_messages),
/history + export (reconstruct_messages resolves), and the per-turn token estimate
(a by-ref image costs one fixed image budget; the doc char budget lands at send).
reconstruct splits: reconstruct_turns = unresolved row→Turn (load_message_turns, the
resume path); reconstruct_messages = resolved dict facade.  RawContentBlock is demoted
to the transient carrier for a resolved inline part on the dict↔Turn bridge.
2026-06-04 11:03:13 -07:00
Patrick Buckley 3bf32d0649 refactor(core): lowering operates on canonical Turns
repair_wire_messages / fold_system_turns / drop_empty_user_turns take and
return list[Turn] — the neutral lowering layer (A representation + B validity)
now speaks the canonical type.  Their intricate content-merge / orphan-detect
internals run over the dict projection (reading Turn content blocks would only
duplicate turn_to_dict's content logic), so each bridges
dicts_from_turns ↔ turns_from_dicts at its boundary; byte-identical.

ChatSession._prepare_wire_messages lifts the wire dicts into Turns, runs the
lowering passes, and lowers the result back to the dict projection the provider
translators (the C layer) consume — the dict bridge now lives in the wire layer,
not in _full_messages.  Export runs the same repair, reordered before the
non-canonical reasoning-content attach (a key the Turn model does not carry).

The provider translators keep their dict input by design: they are the format
layer that emits provider bytes, the vLLM reasoning-attach is a non-canonical
wire concern that sits between lowering and the provider on dicts, and feeding
the converters the lowered projection is equivalent to — and simpler than —
threading Turn content through them.  Wire harness byte-identical; full
non-live suite green (7130).
2026-06-04 11:03:13 -07:00
Patrick Buckley dc88060b79 refactor(core): session.messages is the canonical Turn trajectory
ChatSession.messages flips from list[dict] to list[Turn] — the in-memory
canonical trajectory.  Reads migrate to typed fields (turn.role, turn.text,
turn.tool_calls); appends and assignments go through turn_from_dict /
turns_from_dicts; the fork bulk-save and retry's multipart check read via
turn_to_dict.  _full_messages lowers Turns→dicts at the wire boundary — the
fold/repair and provider translators still consume dicts until the next slice.
The token-accounting helpers accept a dict or a Turn.

Non-session consumers migrate too: coordinator_idle_observer and eval to typed
fields (mypy-enumerated), and server's last-assistant extractor via turn_to_dict
(an Any-typed call site mypy could not flag).  An all-text multipart content
list (the unreadable-attachment placeholder path) now round-trips faithfully
through the adapter (single text block → str, multiple → list).

Tests that inspected session.messages as dicts read it through the
dicts_from_turns / turn_to_dict bridge; those that built it pass dicts through
turns_from_dicts / turn_from_dict.  Byte-identical wire harness; full non-live
suite green (7130).
2026-06-04 11:03:13 -07:00
Patrick Buckley a3f91657c3 refactor(storage): reconstruct as row→Turn; extract recover_trajectory
reconstruct_turns is the pure row→Turn deserialize: one positional unpack of
the row tuple, one Turn per row, no wire-validity correction.  The scattered
per-role dict-building and the side-channel keys collapse into typed Turn
fields (native ← {producer,blocks}, source ← _source, …); the dead tool_name
column is unpacked but unused.  recover_trajectory(turns) is the load-time
trailing-strip policy, lifted out as its own function (one of lowering's three
orphan policies).

reconstruct_messages stays the dict-returning facade for now —
dicts_from_turns(recover_trajectory? · reconstruct_turns) — so every consumer
is unchanged and byte-identical (verified across the storage + reconstruct +
export + wire-payload suites, 7129 green).  developer collapses into
Role.SYSTEM (zero writers, wire-identical); a bare-dict provider_data (never a
real native shape — the lane is a block list) no longer round-trips, which the
storage test now reflects.
2026-06-04 11:03:13 -07:00
Patrick Buckley ba0c723a29 feat(core): dict↔Turn adapters — the strangler bridge for the Turn migration
turn_from_dict / turn_to_dict losslessly bridge the OpenAI-like message dict
(plus its _-prefixed side channels) and the typed Turn, so the migration to
Turn can proceed one boundary at a time: a dict-producing layer can be read as
Turns, and a Turn-holding layer can hand dicts to a not-yet-migrated consumer.

The _ side channels become typed fields: _source→source, _provider_content
(+_producer)→native, _event_id→meta.event_id, _attachments_meta→meta.extra.
A transitional RawContentBlock carries image/document parts verbatim until the
by-reference AttachmentRef wiring (§2/§6) relocates byte-resolution to the
translator; text parts become TextBlock so .text/FTS stays faithful.

turn_to_dict(turn_from_dict(d)) == d for every shape reconstruct and the wire
path emit (test_trajectory). No consumers yet — wiring is the next slices.
2026-06-04 11:03:13 -07:00
Patrick Buckley 63e9205e83 refactor(storage): drop the load-time orphan synth; repair only at send
reconstruct_messages(repair=True) did two things: strip a trailing incomplete
tool-call turn AND synthesize cancellation results for mid-conversation
orphans.  The mid-orphan synth was a near-duplicate of
lowering.repair_wire_messages — same detector, same contiguous insert past
interspersed system turns, same cancellation string — running at the wrong
layer (storage, on every load).

Drop it: load is now trailing-strip only (boot-crash recovery), and the
mid-orphan synth happens once, at send, in lowering.repair_wire_messages — the
single place the wire path fills orphans.  The session send path gets it via
_prepare_wire_messages; export, which bypasses that path, now runs
repair_wire_messages itself (otherwise a mid-conversation orphan would
serialize as an unanswered tool_call).  The duplicated cancellation string
goes with the synth — CANCELLED_TOOL_RESULT lives only in lowering now.

Safe: a bare mid-orphan is harmless between load and send (token count is
additive, /history reads repair=False, compaction summarizes to text), and
every wire path repairs it.  Reconstruct tests updated to the new load
contract; an export mid-orphan test added.
2026-06-04 11:03:13 -07:00
Patrick Buckley e8021e247a refactor(core): move the operator fold into lowering.py
The fold (representation) joins repair (validity) in the shared lowering
sibling module: fold_system_turns / _neutralize_host / _append_text_block /
drop_empty_user_turns move out of ChatSession as free functions.
_prepare_wire_messages now composes the two neutral passes plus repair, so
session.py owns zero wire-shape mutation.

The nonce stays session-minted and session-owned (_envelope_nonce binds three
consumers: the fold, the cached-prefix trust declaration, and the output-guard
forgery check) — lowering borrows it as a parameter and never mints its own.
The capability gate (supports_mid_conversation_system) is a parameter too, so
native-passthrough is unit-testable without monkeypatching a session; the
provider-None case is handled by the caller.

Pure relocation: the fold algorithm, the once-per-host neutralize ordering,
the read-only contract, and drop-after-fold are unchanged. Wire harness
byte-identical; fold unit + _prepare_wire_messages integration tests green.
2026-06-04 11:03:13 -07:00
Patrick Buckley dbf8d88a73 refactor(providers): unify orphan tool-call repair into one send-time pass
Synthesizing a cancellation result for an assistant tool_call with no
matching tool result was triplicated across the translators: Anthropic's
verbatim-replay (pc_tool_ids) and rebuild branches, and sanitize_messages
for the OpenAI-compatible lanes (Chat, Responses, Google). The Anthropic
pc_tool_ids branch was also the sole repairer of a native tool_use orphan.

Lift it to one neutral policy — lowering.repair_wire_messages — run once in
ChatSession._prepare_wire_messages before the translator. It reads tool_calls
only, which is sound because the native/tool_calls mirror is enforced at save
(normalize_native_for_save): a verbatim-replay orphan is caught via its
mirrored top-level call. The translators become pure format translation and
carry no orphan synthesis.

The neutral cancellation turn carries is_error=True; Anthropic renders it on
the tool_result block, the OpenAI-compatible tool message has no such field
so sanitize_messages drops it (the C-layer translation of the flag).

sanitize_messages keeps one orphan synth of its own: a back-filled empty-id
tool_call (local servers that omit ids) is id-less when the upstream repair
runs and so invisible to it, so that lane owns its cancellation — preserving
the pre-refactor behavior for local servers.

reconstruct's load-time strip and the runtime-cancel persist-synth are
unchanged. Proven byte-identical against the per-provider wire-payload golden
harness (including a new native_orphan fixture); the harness applies the same
send-side repair the session does.
2026-06-04 11:03:13 -07:00
Patrick Buckley 4ebaeb8f15 test(providers): add native-orphan wire fixture (verbatim-replay baseline)
Freeze the wire payload for an unanswered native tool_use whose id is
mirrored top-level in tool_calls (the P1 invariant).  This pins the
verbatim-replay orphan path each provider repairs today — Anthropic via
the provider_content tool_use synthesis, the OpenAI-compatible lane via
sanitize_messages — as the baseline the repair-unification change is
proven byte-identical against.
2026-06-04 11:03:13 -07:00
Patrick Buckley 98cee4d20c feat(storage): content-addressed refcounted attachments + in-memory upload buffer
Replace the persisted pending/reserved/consumed upload lifecycle (and its orphan-sweep
and per-user cap) with a content-addressed, refcounted blob store fronted by the per-node
in-memory pending buffer:

- Upload stages bytes in the buffer (keyed by sha256); send-commit drains the referenced
  handles, writes each blob content-addressed (INSERT-OR-IGNORE then refcount += 1, so a
  stored blob is born referenced and dedupes across messages/workstreams), and records the
  ordered conversations.attachments ref-list — the sole message->blob link.
- reconstruct rebuilds inline image_url/document multipart content from the ref-list,
  role-agnostically (so tool-produced images via _exec_read_image now persist + rehydrate
  instead of being flattened to text and lost). Output shape unchanged.
- GC is reference counting: delete_messages_after / delete_workstream decrement once per
  reference and prune a blob at 0; a deduped blob shared with a kept turn (or another ws)
  survives.
- get_content for a committed blob is gated by reference-ownership (the requester owns a
  turn in the ws whose ref-list names the id), replacing the dropped ws_id/user_id scope.
- Migration 060 re-keys legacy consumed attachments to their content hash, dedups, sets
  refcounts, writes the ref-lists, and drops message_id/reserved_*; pending legacy rows are
  dropped (pending now lives only in the buffer).

Both backends symmetric; the reservation methods, cap, and orphan-sweep are removed across
storage/facade/protocol/endpoints/coordinator. Wire harness byte-identical; full suite green.
2026-06-04 11:03:13 -07:00
Patrick Buckley 2e785fbdcf feat(attachments): per-node in-memory pending-upload buffer
The content-addressed model writes blob bytes to workstream_attachments only at
send-commit (so every stored blob is born referenced). Pending uploads — between the
upload request and the send that references them — live in this per-node, content-hash-
keyed buffer, scoped to (ws_id, user_id) and bounded by a TTL + total-size ceiling
(OOM-safety, not the removed per-user cap). ws->node affinity (HRW routing) keeps it
process-local. Losing an unsent upload on crash/re-route is acceptable transient state.

This replaces the persisted pending/reserved/consumed lifecycle + orphan-sweep. No
consumers yet — the upload-endpoint rework and the send-commit drain wire onto it as the
attachment cutover lands.
2026-06-04 11:03:13 -07:00
Patrick Buckley e20aae732c feat(storage): add content-addressed attachment columns (additive)
Additive schema for the attachment cutover: workstream_attachments gains refcount +
origin, conversations gains the attachments ref-list column (migration 060 + _schema in
lockstep). Columns sit unused until the cutover, which fills them and retires the
message_id/reserved_* upload-lifecycle in favour of a content-addressed, refcounted blob
store keyed by the conversations ref-list.

Also registers the coordinator test's backend via init_storage: the attachment handlers
resolve storage through the global registry, so a bare SQLiteBackend left get_attachment
hitting a stale default db — latent until the new column made the schema drift bite.
2026-06-04 11:03:13 -07:00
Patrick Buckley 6684234a02 feat(storage): backfill legacy provider_data with its inferred producer
060 now tags legacy bare-list provider_data rows with the {producer, blocks} envelope,
inferring the producer from block types — and the inference yields the exact provider_name
strings the live save writes (anthropic / google / openai / openai-compatible) so a
backfilled row compares equal to a freshly-saved one under the lowering layer's
producer==active rule. Google is keyed on a 'function' block carrying thought_signature;
xAI is byte-identical to OpenAI-Responses in the stored blocks so legacy xAI rows tag as
'openai' (bounded, self-healing). Un-inferable rows are left bare (reconstruct dual-reads
them). Paged like the envelope rewrite. Completes the producer story: 2a tags new rows,
this tags legacy. Sub-commit 2b of the canonical-trajectory storage cut.
2026-06-04 11:03:13 -07:00
Patrick Buckley caa589e39b feat(storage): tag the native lane with its producer ({producer, blocks})
Persist provider_data as a {producer, blocks} envelope (producer = the generating
provider's name) so the lowering layer can later replay the native lane verbatim only
to its producer and rebuild from neutral fields for any other.

The envelope is storage-only: prepare_provider_data_for_save runs the P1 mirror on the
bare block list and then wraps; reconstruct_messages dual-reads (new envelope OR legacy
bare list), unwraps to a bare _provider_content list (every consumer's contract), and
surfaces the producer on the stripped-before-wire _producer side channel. The producer
threads the same four save layers as is_error (facade -> protocol -> SQLite + Postgres);
the live assistant save tags it from self._provider.provider_name and the fork carries
_producer. Legacy rows need no migration to keep working (dual-read); the one-shot
backfill that tags them is a follow-up.

Sub-commit 2a of the canonical-trajectory storage cut.
2026-06-04 11:03:13 -07:00
Patrick Buckley bfa80f2159 feat(storage): persist tool-result is_error (migration 060)
Tool-result error state was an in-memory-only message key, lost on reload. Add an
is_error column to conversations (migration 060, backfilled False) and thread it through
the four save layers (memory facade → StorageBackend protocol → SQLite + PostgreSQL):
save_message/save_messages_bulk persist it, reconstruct_messages emits it on tool rows,
and the session tool-result + synthetic-cancel saves + the fork bulk-copy pass it. It
rides as the last conversations column so reconstruct's row-tuple positions stay stable
(legacy fixtures default False). history_decoration already prefers the persisted flag
over its text heuristic, so reload fidelity improves immediately.

First sub-commit of the canonical-trajectory storage cut (folds into rev 060).
2026-06-04 11:03:13 -07:00
Patrick Buckley 975fb4714c feat(core): add the canonical Turn trajectory model
The provider-neutral typed Turn (flat, role-discriminated; uniform tuple[ContentBlock]
content + .text; AttachmentRef by-reference content; ToolCall raw-arg str;
ProviderNative producer-tagged opaque lane; TurnMeta sidecar).  In-memory foundation
for the wire-shape narrow waist — no consumers yet; storage deserialization, the
lowering layer, and the provider translators wire onto it in subsequent steps.
2026-06-04 11:03:13 -07:00
Patrick Buckley 5323559e78 fix(storage): enforce the native/tool_calls mirror at the save boundary
A max-tokens truncation mid-tool_use can leave an orphan tool_use in the native lane
(provider_data / _provider_content) with no matching tool_calls; on a same-provider
resume that replays as a tool call with no result and the API rejects it.

normalize_native_for_save strips orphan client tool-call blocks (tool_use /
function_call / function) when tool_calls is empty, applied by save_message and
save_messages_bulk in both backends; strip_orphan_client_tool_blocks enforces the
same mirror in memory at message assembly (the truncation path). The mirror now holds
by construction, so the orphan-repair pass can read tool_calls alone and the Anthropic
pc_tool_ids fallback can be retired.
2026-06-04 11:03:13 -07:00
Patrick Buckley c9311e32e4 test(providers): freeze per-provider wire payloads as a refactor baseline
Captures the exact request kwargs each provider hands to its SDK seam (Anthropic
messages.stream, OpenAI chat/responses create, Google OpenAI-compat) for a
representative set of trajectories, asserted against committed goldens. This is the
behavior-equivalence net the canonical-trajectory wire-shape refactor is proven
against. Regenerate the baseline with UPDATE_WIRE_GOLDENS=1.
2026-06-04 11:03:13 -07:00
Patrick Buckley 6e260bddc5 fix(skills): don't echo model-controlled filter values into the trusted hint
Phase-2 review follow-ups:
- sec-1 (major): the skills find-zero hint interpolated the model-supplied
  filter values (query/category/tag/…) into the system_reminder, which now
  rides a TRUSTED operator system turn (fold fence / native system role). Under
  an indirect prompt injection the model could be steered to call
  skills(find, query='<directive>', category='nonexistent') so 0 rows match,
  laundering the attacker text into operator authority. Drop the filter echo —
  the count is harness-derived and the model already knows its own filters.
- q-1 (minor): refresh the stale :func:`escape_wrapper_tags` cross-reference in
  metacognition.sanitize_payload's docstring (the function was removed; fold-time
  fence.neutralize is the current marker defense).
2026-06-04 11:03:13 -07:00
Patrick Buckley f3c96e6493 feat(skills): make skill hints first-class system turns; drop escape_wrapper_tags
_skill_hint spliced its guidance into the tool result as a bare <system-reminder>
block — but the operator declaration now tells the model to treat bare markers
as untrusted, silently demoting the hint. Make the hint first-class instead:

- _skill_hint returns the tool result verbatim and queues the guidance via
  _queue_tool_advisory("skill_hint", ...); _collect_advisories drains it into a
  {role:system, _source:"skill_hint"} turn after the clean result — folded in
  the trusted nonce fence for non-native models, inline for native. (Queuing
  no-ops mid-wake, like the other tool-channel advisories.)
- skill_hint added to SYSTEM_TURN_SOURCES (an advisory-producer source).
- escape_wrapper_tags removed outright: it was the last consumer, and its job
  (defang a marker next to the bare block) is now covered at fold time by
  _neutralize_host. The result message rides through verbatim. This also
  collapses the two-escaping-mechanism confusion the review flagged.

Tests assert the clean result + the queued/drained hint, plus wake suppression.
2026-06-04 11:03:13 -07:00
Patrick Buckley 8513016503 refactor(storage): drop the dead _reminders column instead of carrying it
Operator context moved to first-class system turns, leaving _reminders written
by nothing and read by nothing. Nulling it (the prior 060 step) left a writable
dead column — a foot-gun inviting accidental reuse. Drop it outright and remove
every reference in one shot so there is no half-alive state:

- migration 060: replace the wholesale null with batch_alter_table drop_column
  (per migration 027); downgrade re-adds the empty column to match the 059
  schema (the envelope un-wrap stays irreversible).
- _schema.py: remove the column.
- _sqlite / _postgresql: drop the reminders save param, the INSERT/bulk values,
  and both SELECT columns.
- reconstruct_messages: the row tuple is now 8/9-tuple (event_id shifts from
  index 9 to 8); _utils + the _row test helper updated.
- _protocol / memory save_message: drop the reminders param + docstrings.
- tests: replace the reminders-roundtrip tests with a _source-only file and a
  060 drop-column assertion; remove the obsolete legacy-reminders wire test.

No production caller passed reminders=, and the SELECT no longer reads the
column, so an un-migrated DB simply ignores any residual values.
2026-06-04 11:03:13 -07:00
Patrick Buckley 99ba82e8ec fix(session): operator-turn wire correctness — framing, empty turns, leading system
Phase-2 follow-ups to the mid-conversation-system consolidation:

- user_interjection framing (known #2): a queued message that drains mid-turn is
  re-framed via render_user_interjection ("The user sent … User message: …") so
  the user's words keep USER authority, not operator authority — the regression
  mattered most on the native path, where the turn enters as a real role=system
  message. Empty/whitespace interjections (e.g. a bare "!!!") are dropped (bug-2).
- empty-content user turns dropped at the wire boundary after the fold
  (known #3): the wake pipeline's synthetic empty send("") leaves an empty user
  turn on the native path (the nudge stays inline); an empty user message is
  invalid on every provider. The drop runs after the fold so the fold-path wake
  turn, which the nudge fills, survives.
- leading-system guard (_anthropic): a turn that converts to nothing no longer
  lets a system message become messages[0] (the API requires messages[0]=user).
  Newly reachable now that the empty-turn drop can expose it on a fresh-session
  native wake.
- refresh stale .msg.watch-result comments (the card was removed) to describe
  the current operator-bubble rendering.
2026-06-04 11:03:13 -07:00
Patrick Buckley 2d64569cf8 fix(fence): close defang/detection whitespace gap; test host-escaping
Phase-1 review follow-ups:
- neutralize() now tolerates whitespace between '<' and the slash ('< /tag',
  '<  /tag'), matching output_guard's detection regex so a marker can no longer
  be detected-but-not-defanged (a leaked-nonce break-out gap).
- Add direct tests for the sec-1 forge-in defence: _neutralize_host defangs a
  forged <system-reminder_{nonce}> in both string- and list-content untrusted
  hosts before the real fence is appended, and the host is defanged exactly once
  so consecutive folds don't corrupt the first appended fence.
2026-06-04 11:03:13 -07:00
Patrick Buckley d9955805fd test(channels): drop orphaned PlanReviewView owner-check tests
PlanReviewView was removed alongside the plan_agent built-in tool (110d44b0),
but its Discord owner-check tests were left behind importing a class that no
longer exists. They raise ImportError wherever discord.py is installed (green in
CI only because discord.py is absent there). Remove the dead test class and the
now-unused send_plan_feedback router mock from the shared bot double.
2026-06-04 11:03:13 -07:00
Patrick Buckley 095f46a1fc fix(migration): tighten 060 envelope match; stop re-activating escaped tags
060 un-wrapped legacy <tool_output> envelopes with a loose guard (open + close)
that could irreversibly mis-rewrite a bare tool row resembling the open, and
entity-decoded the wrapper tags back to live form — re-activating injection the
old escape had neutralised (and downgrade cannot undo it).

- Require the full legacy signature (the exact </tool_output>\n\n<system-
  reminder>\n join plus a trailing </system-reminder>), which wrap_tool_result
  only ever emitted with advisories. A bare row with a matching close but no
  advisory is left byte-for-byte untouched.
- Reverse only &amp; -> & ; leave the wrapper-tag entities escaped so a
  previously-defanged injection stays defanged.

Adds false-positive guard tests (open+close without advisory; missing tail).
2026-06-04 11:03:13 -07:00
Patrick Buckley bb9e50c714 feat(fence): unify operator + judge trust fences on one primitive
Both the operator fold and the output-guard judge wrap spans in nonce-delimited
fences, but the two had drifted: the operator path minted a 32-bit nonce reused
per session with no body escaping, while the judge used a 64-bit per-call nonce
plus closing-tag escaping. Extract the shared mechanism (mint/neutralise/wrap)
into turnstone/core/fence.py and put both callers on it so they cannot diverge
again.

- Operator fold (sec-1): 64-bit nonce; fence.wrap neutralises the operator
  body's close marker, and _fold_system_turns neutralises the untrusted host
  turn's <system-reminder> markers once before the first fold, so a leaked or
  guessed per-session nonce still cannot forge a trusted block. Per-session +
  cached declaration kept (the declaration pins the exact value, so per-turn
  rotation would bust the prompt cache). Marker is now <system-reminder_{nonce}>.
- Judge: refactored onto fence (behaviour-preserving; still per-call).
- Forgery detection: output_guard scans tool output for trust-fence markers —
  an exact session-nonce match is HIGH (operator_marker_leak: the token has
  leaked and is being replayed), any other marker LOW (operator_marker_forgery).

Removes mint_envelope_nonce / wrap_system_context (folded into fence.wrap).
2026-06-04 11:03:13 -07:00
Patrick Buckley c6b2288302 feat(session): consolidate operator-context into first-class system turns
Replace the two operator-context hacks (the <tool_output>/<system-reminder> content envelope and the transient _reminders side-channel) with one persistent {role: system, _source} trajectory turn. Adds supports_mid_conversation_system (claude-opus-4-8): native models take the turn inline; all others fold it into the preceding turn as a nonce-delimited <system-reminder> block declared in the system prompt as the sole trusted marker. Producers (advisories, metacog nudges, user interjections, idle/watch) emit system turns; the envelope/_reminders machinery, escaping round-trip, replay parser, and reminder SSE events are removed. Eager 060 migration un-wraps legacy envelopes. Net -1662 lines.

Known follow-ups from review (unfixed here): (1) the 060 un-wrap heuristic can irreversibly mis-rewrite bare tool rows that resemble the envelope, so do not run the migration until it is tightened; (2) user_interjection turns lost the user-framing/priority preamble (a regression, and a native-path authority-framing concern); (3) native-path wake nudge can emit empty user content.
2026-06-04 11:03:13 -07:00
renovate[bot] ef2b18b62f chore(deps): lock file maintenance 2026-06-03 18:56:09 -07:00
renovate[bot] fb4c6eefe0 chore(deps): update helm release postgresql to ~18.7.0 2026-06-03 18:55:55 -07:00
renovate[bot] a80c4b025b chore(deps): update typescript sdk to v4.1.8 2026-06-03 18:55:42 -07:00
renovate[bot] 88683729d2 chore(deps): update docker images to v0.11.19 2026-06-03 18:55:29 -07:00
renovate[bot] 1696eeb9e5 chore(deps): update github actions 2026-06-03 18:55:16 -07:00
renovate[bot] e8211f12a0 chore(deps): update dependency aiohttp to v3.14.0 [security] 2026-06-03 18:41:51 -07:00
Patrick Buckley 87c51b43ca chore: bump version to 1.6.0a10 2026-06-01 21:36:36 -07:00
Patrick Buckley 24d75a690b fix(review): address review findings on the rerank/memory stack
- rerank_config.py: the runtime instruction fallback had a dead tail
  (`get_rerank_instruction() or str(cs.get(...))` -- the cs.get term can only
  return the registry default ""), via a stored_keys() branch that also diverged
  from the calibrate CLI / endpoint. Collapse to the sibling idiom
  (`cs.get(...) or get_rerank_instruction()`) so the instruction used at
  calibration time matches the one used at runtime. Correct the module docstring:
  ChatSession is the sole caller (the CLI/endpoint share only the instruction
  precedence, not this function).

- session.py: the deferred first-turn memory recompose was gated on the flag
  alone, so a synthetic wake send (empty user content -> flag stays False) re-ran
  the full compose on every wake before the first real turn. Gate on a non-empty
  query too, so wakes don't re-pay it and the real turn still fires exactly once
  (+ test).

Accepted as-is: the __init__ compose (kept so system_messages/_agent_system_messages
are valid for early readers; one cheap extra compose per fresh session) and the
orphaned tools.rerank_* config rows (inert -- no read path, never listed or
redacted; a purge migration would collide with the 060 in flight on another branch).
2026-06-01 21:34:12 -07:00
Patrick Buckley 47d0b6c3c6 fix(memory): defer system-message compose to the first user turn so memory selection has a query
Proactive memory selection scores candidates against the recent-user-message
query (extract_recent_context), but a fresh session composes the system prefix
once in __init__ while self.messages is still empty. That empty query takes the
no-context path: _select_memory_candidates returns recency order and
score_memories returns memories[:k] verbatim -- the 5 most recently UPDATED
memories, with BM25 and the reranker never invoked. send() never recomposes, so
those recency-only memories are what the model sees for the whole session (until
an unrelated event -- skill / MCP / model refresh / resume / memory write /
command -- happens to rebuild the prefix). Net effect: the injected memories are
unrelated to the actual question.

Fix: defer the memory-bearing compose to the first real user turn.
- Track _system_composed_with_context, set once extract_recent_context is
  non-empty in _init_system_messages.
- send() recomposes once, right after _append_user_turn, while the flag is still
  False -- so the opening turn's memory block is selected (and reranked) against
  the real message. The flag then stays True, so the prefix is composed once and
  stays cache-stable exactly as before (no per-turn prompt-cache churn).

This is the targeted fix; per-turn memory refresh (so later topic shifts also
re-rank) is the larger tail-injection redesign tracked on another branch. Two
adjacent gaps are left as-is for now: the reranker/BM25 only see content[:200],
and build_memory_context flat-truncates each memory to 500 chars (max_content is
the save cap, not an injection budget).

Tests: flag is False on a fresh session and after a whitespace-only wake turn,
flips True on a real query; send() runs the deferred recompose with the user
message in the query.
2026-06-01 21:34:12 -07:00
Patrick Buckley 8b4b8b3fd5 refactor(rerank): reranker is a per-model definition only (drop global endpoint settings)
The reranker_alias -> model-definition path (added when reranking became a model
role) made the older global endpoint settings redundant. Resolve reranking
solely through the Reranker role and remove the parallel global config.

- Removed settings tools.rerank_url / rerank_model / rerank_api_key, their
  config.py getters (+ $TURNSTONE_RERANK_URL / $TURNSTONE_RERANK_MODEL and the
  module caches), and the fallback branch in resolve_rerank_client_from. The
  resolver now returns a client only when a Reranker model (capability
  supports_rerank, base_url = its /rerank endpoint) is selected, else None.
- Kept as global knobs: reranker_alias (the selector), rerank_web_search,
  rerank_bm25, rerank_bm25_threshold, and rerank_instruction -- a task-level
  query knob (Qwen3-style), not endpoint identity.
- The Settings tab is registry-driven, so the three fields disappear with their
  SettingDefs. Updated the Reranker role help, example config, and docs/tools.md.

BREAKING: a reranker configured via [tools] rerank_url (config.toml / env /
Settings tab) no longer works -- add the reranker in the admin Models tab and
pick it under Models -> Roles -> Reranker. No migration: reranking is days old
and disabled by default, so any orphaned tools.rerank_* config rows are inert.

Tests: the resolver covers no-store / no-alias / non-rerank-alias -> None and the
model-definition happy path; the obsolete global-fallback tests are removed.
2026-06-01 21:34:12 -07:00
Patrick Buckley a4a0965e85 fix(rerank): calibrate-as-probe for reranker detect (skip /v1/models)
Adding a reranker model through the admin modal was impossible: Detect
(admin_detect_model) always ran the OpenAI /v1/models probe first and gated
calibration on its `reachable` result. A Cohere/Jina /rerank endpoint can't
answer /v1/models, so it failed both ways -- `$host/v1` passed the probe but
calibration POSTed to the wrong path, `$host/rerank` 404'd the probe outright.

- console/server.py: branch on `supports_rerank` BEFORE the probe and calibrate
  the endpoint directly; that round-trip IS the reachability check (there is no
  independent /v1/models signal for a rerank-only endpoint). Success
  autopopulates the three calibration fields the way context_window does;
  calibration failure -> reachable:False + error; empty base_url -> 400 with the
  /rerank hint; reachable-but-no-clean-separation -> a note. Drops the now-dead
  post-probe calibrate-on-detect block.

Reranker selection stays per-model (reranker_alias -> registry); recalibrate on
a saved reranker was already correct (it calibrates directly). Flagging a model
as a reranker still rides the capabilities JSON (supports_rerank).

Negative-tested: rerank detect skips the probe, autopopulates on success, notes
no-separation, reports unreachable on calibration failure, and 400s on an empty
base_url; the non-rerank detect path is unchanged.
2026-06-01 21:34:12 -07:00
Patrick Buckley 68ed0b6c15 feat(rerank): per-model calibration on detect + calibrate endpoint
Phase 3. Stores reranker calibration per-model on the model definition's
capabilities (rerank_threshold/rerank_scale/rerank_separated; a non-empty
rerank_scale is the "has been calibrated" marker) instead of a single global
threshold, populated automatically when a reranker endpoint is detected.

- ChatSession._bm25_rerank_threshold precedence: the active reranker model's
  calibrated rerank_threshold (when separated) wins; calibrated-but-not-
  separated -> 0 (no floor); else the global tools.rerank_bm25_threshold
  fallback. Reads the raw caps dict, in-memory per turn.
- Detect (admin_detect_model) calibrates a supports_rerank endpoint and
  autopopulates the three fields like context window; the create-model UI shows
  a verdict chip (calibrated / no-clean-separation / not-calibrated).
- POST /api/admin/model-definitions/{id}/calibrate backs the Re-calibrate
  button; calibration runs off the event loop (run_in_executor, bounded by
  asyncio.timeout(90)), persists the fields + refreshes the registry, and is
  graceful on a down/slow endpoint (never 500). Shared
  merge_calibration_into_caps helper used by the endpoint and the CLI.
- turnstone-admin rerank-calibrate is now per-model: --model <alias> required;
  --apply writes that model's caps (not the global setting). A no-separation
  result records the marker (calibrated, no floor) consistently across CLI and
  endpoint.

The serving lesson stays documented: Qwen3-Reranker needs vLLM --chat-template
or its scores are near-random (live-validated 0.6B + 4B: the calibrated floor
came out 0.95 vs 0.33 for the same task -- why per-model calibration exists).

Negative-tested: the floor-precedence branches (calibrated+separated -> per-
model, calibrated+!separated -> 0, uncalibrated -> global, no-alias -> global,
registry-must-not-be-consulted), endpoint persist/refresh + graceful failure,
detect-skips-non-rerankers, the CLI per-model write, and the caps-merge
preserving supports_rerank. Chip states verified via headless Chrome.
2026-06-01 15:47:14 -07:00
Patrick Buckley f6bae70ea6 feat(rerank): calibration CLI, 0-1 normalization, instruction support
Phase 2 of BM25 reranking (follows #627). Makes the rerank_bm25_threshold floor
usable across reranker models and adds tooling to pick it.

- normalize_scores (rerank.py): map a rerank batch into a 0-1 relevance
  probability -- sigmoid when any score falls outside [0,1] (logit endpoints
  like bge/TEI), identity otherwise (Cohere/Jina/Qwen already 0-1). Applied in
  the _bm25_reranker closure AND calibration so the threshold means the same on
  every endpoint. Monotonic, so ranking order is unchanged.

- rerank_calibrate.py + `turnstone-admin rerank-calibrate [--apply]`: probe the
  endpoint with labelled relevant/irrelevant groups, normalise, and recommend a
  recall-biased floor -- or report "no clean separation" (a mis-served/weak
  reranker). A warmup loop absorbs a cold endpoint's first-request compile so
  calibration doesn't time out. Validated live against Qwen3-Reranker 0.6B and
  4B: the calibrated floor differs sharply per model (~0.95 vs ~0.33 for the
  same task) -- exactly why per-endpoint calibration exists.

- rerank_config.py: extract resolve_rerank_client_from(config_store, registry);
  the alias/url precedence now lives in one place, shared by ChatSession (which
  delegates) and the CLI.

- tools.rerank_instruction (config + setting + client): wrap the query as
  <Instruct>:/<Query>: for instruction-aware rerankers (Qwen3) on endpoints that
  don't apply the model's own chat template. Docs note the critical vLLM serving
  detail: Qwen3-Reranker needs --chat-template or its scores are near-random and
  reranking hurts retrieval.

Negative-tested: normalize sigmoid/identity branches, closure-normalises-before-
floor, calibration separation/recall-bias/warmup-absorbs-cold-start, the CLI
apply/no-apply/no-separation paths, and instruction query-wrapping through the
real httpx boundary.
2026-06-01 14:44:30 -07:00
Patrick Buckley 215f7506ba feat(rerank): wire endpoint-backed reranking into BM25 retrieval surfaces
Reuse the shipped Cohere/Jina rerank client as an optional post-process on
the BM25 surfaces (tool search, skill search, memory composition) via one
seam: BM25Index gains an injected reranker + a two-stage search (BM25 recall
top-50 -> rerank -> top-k). No new storage.

Gated on a configured endpoint plus tools.rerank_bm25 (default on, matching
rerank_web_search). tools.rerank_bm25_threshold (default 0.0 = off) is a
relevance FLOOR for proactive memory surfacing: BM25 always returns something,
so without a floor every-turn memory injection spends tokens on the top-k of
whatever lexically matched; the reranker score is what makes a meaningful
"inject nothing" gate possible.

Two reranker modes (BM25Index rerank_filters):
- REORDER (reactive tool/skill search): the reranker must never drop results
  -> fall back to BM25 order on empty, backfill omitted pool items, so a
  misbehaving endpoint can't silently lose tools.
- FILTER (memory, rerank_filters = threshold > 0): a clean empty/short result
  is honoured (inject nothing) -- a deliberate divergence from
  web_search._rerank_results.
Parse/endpoint failure is a discrete branch from the floor: an empty result
for non-empty input means an unparseable response (a conforming reranker
scores every doc), so the closure raises RerankError and BM25Index falls back
to BM25 order in BOTH modes -- the floor only acts on valid scores.

Also: cap the rerank client timeout at 15s (the per-turn memory path can't
afford tools.timeout's 120s default); move the Reranker alias to rerank.py
(shared, no import cycle); document the endpoint egress in the rerank_bm25
help, the admin Reranker-role description, and docs/tools.md; add
scripts/bench_bm25_rerank.py (manual, needs a live endpoint) to measure
precision@k/MRR lift and recommend a threshold default.

Negative-tested: reorder fallback-on-empty and omitted-item backfill,
filter-mode honor-empty, singleton-still-floored, the parse-fail RerankError
raise, the >= floor boundary, and pool-position-to-doc-index mapping -- each
guard reverted to confirm its test fails, then restored.
2026-06-01 12:56:45 -07:00
Patrick Buckley c6e0293185 fix(rerank): harden web_search rerank fallback and correct stale docs
list()-materialize the reranker's output inside the guarded block so a
None / non-iterable / lazily-raising reranker falls back to native order
instead of raising out of web_search, and reject bool indices (an int
subclass) the same way _parse_hits already does.

Drop leftover web_fetch references from the rerank settings and Reranker
role help (reranking is wired into web_search only), and document both
endpoint paths (tools.reranker_alias and tools.rerank_url).
2026-06-01 11:01:30 -07:00
Patrick Buckley 6a0bc852d9 feat(rerank): endpoint-backed reranking for web_search
Reranking is delegated to an external Cohere/Jina-compatible /rerank endpoint
(self-hosted vLLM/TEI/llama.cpp, or hosted Cohere/Jina/Voyage); Turnstone runs
no reranker model itself. Disabled until an endpoint is configured.

- core/rerank.py: CohereJinaRerankClient (tolerant of results-wrapped and
  bare-list responses) + resolver.
- web_search: rerank the SearxNG result pool by query relevance before top-k,
  with a native-order fallback on error; answers/infoboxes untouched.
- Reranker as a model definition: add a model with the supports_rerank
  capability and pick it under Models -> Roles -> Reranker
  (tools.reranker_alias); takes precedence over the tools.rerank_url settings.

Settings: tools.rerank_url/model/api_key, tools.rerank_web_search,
tools.reranker_alias. Docs: docs/tools.md, turnstone.example.toml.

(web_fetch reranking was evaluated and dropped: for single-document chunk
selection it did not reliably beat head-truncation. Reranking is reserved for
multi-item ranking.)
2026-06-01 11:01:30 -07:00
Patrick Buckley 40b49c1d6c chore: bump version to 1.6.0a9 2026-05-31 20:45:06 -07:00
Patrick Buckley 110d44b07e refactor(tools): remove man, math, and plan_agent built-in tools
`man` and `math` duplicated capabilities already reachable through
`bash`; `plan_agent` is better expressed as a `task_agent` running a
planning skill, and carried a large amount of special-case machinery
(plan-review gate, refinement loop, per-kind model routing). Removing
all three shrinks the tool surface and cuts per-call token cost.

Also removed, as dead-once-the-tools-are-gone:
- the `math` sandbox executor (`turnstone.core.sandbox`) and its
  `[sandbox]` extra; the eval analyst now runs bash-only
- the read-only `AGENT_TOOLS` sub-agent tool set and the `agent`
  tool-metadata key (`task_agent`/`TASK_AGENT_TOOLS` retained)
- the plan-review protocol end to end: the `on_plan_review` UI hook,
  `resolve_plan`, `POST /v1/api/plan` + `POST /v1/api/route/plan`,
  the `plan_review`/`plan_resolved` SSE events, and their Python SDK /
  TypeScript SDK / OpenAPI / frontend / Discord+Slack bindings
- the `model.plan_alias` / `model.plan_effort` settings and the
  registry `plan_model` / `plan_effort` routing fields

TOOLS 31->28, TASK_AGENT_TOOLS 13->11; COORDINATOR_TOOLS unchanged.

BREAKING CHANGE: removes the `man`, `math`, `plan_agent` tools, the
plan-review SSE/HTTP/SDK surface, and the plan_* model-routing settings
from the experimental 1.6 line.
2026-05-31 19:54:43 -07:00
Patrick Buckley 15b3aad815 feat(web-search): let the model pick a SearxNG category
Rename the web_search tool's `topic` parameter to `category` and expand the
enum to general/news/it/science, mapped to SearxNG `categories=`. The model can
now target the right corpus per query (e.g. `it` for code, `science` for
papers) — useful when generic engines rate-limit. The Tavily-era `finance`
topic (no SearxNG equivalent) is dropped. Threaded consistently through
_prepare_web_search / _exec_web_search / both search clients.

BREAKING: the web_search `topic` argument is now `category`.
2026-05-31 19:03:16 -07:00
Patrick Buckley eea8bf9e96 fix(bootstrap): keep "identical content" in the write-skip message
The SearxNG change reworded _tool_write_compose's duplicate-skip return and
dropped the "identical content" phrase that test_identical_content_skipped
asserts on, failing CI (which then fail-fast-cancelled the parallel matrix
jobs). Restore the phrase (now covering all three bundled files) and assert
the searxng/settings.yml extraction in test_writes_compose_file.
2026-05-31 19:03:16 -07:00
Patrick Buckley 1728a4c0af feat(web-search): replace Tavily/DuckDuckGo backends with self-hosted SearxNG
Drop the Tavily and DuckDuckGo (ddgs) web_search backends for a single
self-hosted SearxNG service bundled into the docker-compose stacks.

Core:
- New SearXNGClient + _format_searxng; rewrite resolve_web_search_client to
  (backend, searxng_url, searxng_engines, ...). MCP backend + oauth_user guard
  unchanged. _resolve_search_client follows storage -> toml -> env -> default
  precedence (explicit "" disables, via ConfigStore.stored_keys()).
- Drop the Tavily-era topic=finance (no SearxNG category); topic is now
  general/news.

Settings/config:
- Remove tools.tavily_api_key, get_tavily_key, $TAVILY_API_KEY, [api].tavily_key.
- Add tools.searxng_url (default http://searxng:8080) + tools.searxng_engines,
  with get_searxng_url/get_searxng_engines.

Compose + bundled config:
- Internal-only searxng service (no published API port, :ro config, /healthz
  healthcheck, persistent searxng-cache volume) in both stacks; bundle
  turnstone/deploy/searxng/settings.yml (JSON output on, limiter off).
- Caddy serves the SearxNG web UI on :8444 (dev: localhost-only; prod: opt-in).
- bootstrap extractor + wheel packaging updated.

Deps: drop the ddg extra + ddgs mypy override (regenerates uv.lock, removing the
lxml/h2/brotli transitives).

Docs: tools/docker/architecture/openshell + diagrams + config example + CHANGELOG;
docs/docker.md carries the AGPL-3.0 §13 operator note.

BREAKING: tools.web_search_backend no longer accepts "tavily"/"ddg";
tools.tavily_api_key and the ddg extra are removed. Run the bundled SearxNG (ships
in the compose stacks) or set TURNSTONE_SEARXNG_URL to an external instance.

Closes #545
2026-05-31 19:03:16 -07:00
Patrick Buckley effc60297c feat(install): add a one-line curl|bash installer
run.sh autodetects Ubuntu/Debian, Fedora/RHEL, Arch, and WSL; ensures git and
Docker; clones, builds, picks free ports (Caddy prefers 443, Postgres 5432),
generates a .env with a JWT secret and Postgres password, asks how many nodes to
run, and starts the stack. The node count persists via an auto-loaded
compose.override.yaml, so a later plain `docker compose up -d` keeps it; a fresh
clone with no override still starts all 10. Ignores the generated override.
2026-05-31 16:05:21 -07:00
Patrick Buckley aac782ba78 docs(readme): reframe around local-first/no-telemetry and add Discord
Lead with privacy, local-first, and no-telemetry; demote governance to an
optional team-controls line. Fix the dashboard URL (Caddy on 8443), add the
one-line installer, and link the Discord community.
2026-05-31 16:05:21 -07:00
Patrick Buckley 7fde6f9122 fix(bootstrap): extract the Caddyfile and refresh the wizard
The bundled production compose mounts ./Caddyfile, so write_compose has to write
it alongside compose.yaml or `docker compose up` fails to start Caddy. Update
the wizard's system prompt for the new model (no profiles, Caddy-fronted
dashboard, current ports).
2026-05-31 16:05:21 -07:00
Patrick Buckley 8f02e688a7 fix(channels): stand by instead of exiting when no adapter token is set
Without a Discord/Slack token the channel gateway exited, which crash-loops
under `restart: unless-stopped`. Run the HTTP server and service heartbeat with
zero adapters instead — registered and idle — until a token is set. The Slack
token-pair mismatch stays a hard error.
2026-05-31 16:05:21 -07:00
Patrick Buckley cc4d9b1a8d fix(server): boot nodes with no models in a degraded state
A node refused to start with no model configured and no LLM reachable, so a
fresh cluster couldn't come up to be configured. load_model_registry now
accepts allow_empty and returns an empty registry; ModelRegistry permits the
empty state (default unset); the server passes allow_empty so a node registers
and shows in the console, then picks up models added in the admin UI live. The
CLI keeps failing fast — a REPL with no model is unusable.
2026-05-31 16:05:21 -07:00
Patrick Buckley 842826501d fix(storage): prevent concurrent-migration deadlock on the advisory lock
Migrations run on every node at boot, and one migration rebuilds an index with
CREATE INDEX CONCURRENTLY, which can't run in a transaction and waits for all
concurrent transactions to drain. The advisory lock that serialises migrations
was held inside an open transaction, so the lock-holder's own idle-in-
transaction connection deadlocked the concurrent build when several nodes
started together. Acquire the lock on an AUTOCOMMIT connection and poll
pg_try_advisory_lock so no waiter pins a snapshot. Adds a Postgres concurrency
regression test (skipped on SQLite).
2026-05-31 16:05:21 -07:00
Patrick Buckley 631f1b0021 feat(compose): cluster-by-default Caddy-fronted stack with bare-metal join
`docker compose up` from a clone builds one image and brings up the whole stack
— PostgreSQL, console, Caddy, channel, and 10 server nodes — sharing one
Postgres so the console discovers every node. The dashboard is reachable only
through Caddy (HTTP/2 avoids the browser's 6-connection cap on the dashboard's
SSE streams); the console's plain-HTTP port is no longer published. Postgres
binds 127.0.0.1 so a bare-metal turnstone-server can join the cluster — the
bare-metal overlay is folded in and removed. Insecure dev defaults keep it
zero-config; the bundled production stack mirrors the shape but pulls ghcr
images and requires real secrets.

Move the Caddyfile under turnstone/deploy so it ships in the wheel; update docs,
QUICKSTART, and .env.example to match.
2026-05-31 16:05:21 -07:00
Patrick Buckley c58df26a30 style(tests): ruff-format provider/registry empty-string tests 2026-05-31 13:06:24 -07:00
chrismuzyn 8737081373 add tests for anthropic api 2026-05-31 13:06:24 -07:00
chrismuzyn 85db6895e3 make sure empty strings don't get passed to the openapi sdk which don't
allow env var fallback
2026-05-31 13:06:24 -07:00
Patrick Buckley e322b69c8b chore: bump version to 1.6.0a8 2026-05-31 00:58:44 -07:00
Patrick Buckley 1580e8e64b style(tests): ruff-format the appended early-paint guards
The guard tests were appended via heredoc, bypassing the editor's
auto-format; ruff-format collapses one wrapped call onto a single line.
No behaviour change.
2026-05-31 00:57:13 -07:00
Patrick Buckley 44f19f1401 feat(judge,console,ui): paint pending tool calls before the intent verdict
The intent-validation judge runs before the approval gate resolves, and
Smart Approvals (judge.smart_approvals) parks approve_tools on the async LLM
verdict for up to judge.timeout — so the tool-call card never reached the UI
until the judge had ruled. An operator could not see a committed call, let
alone Stop it, during that window.

approve_tools now emits a tool_pending event carrying the serialized batch at
the top of the gate, before the tool-policy lookup, the verdict wait, and the
human prompt. It is a UI paint only — no persistence, audit, or verdict
bookkeeping — so it cannot perturb the gate's accounting. The authoritative
tool_info / approve_request / tool_result events that follow upgrade the same
construct in place, keyed by call_id, and the Last-Event-ID replay slice
reconstructs it on reconnect. A ToolPendingEvent joins the SDK registry.

Coordinator: appendToolBatch was already idempotent on call_ids; the new
handler reuses the --running placeholder it already upgrades, with an
"Evaluating" kicker that swaps to "Running" on the auto-approve upgrade.

Interactive: showInlineToolBlock was create-only, so a second card would
duplicate. Added announceToolBlock + _takeAnnouncedBlock to reuse the
announced shell (matched on its call_id set) instead. The announced rail is
dashed amber and must out-specify the .msg.ts-approval--inline cyan-hold
(specificity 0,2,0) — at 0,1,0 it rendered cyan, indistinguishable from a
normal card — so the announced card is the one visually distinct surface in
the stream.

Screen-reader parity: the early paint announces politely through dedicated
off-screen aria-live regions on both surfaces (the messages log is
aria-live=off mid-stream, so the appended shell alone is inaudible), and the
announced shell carries aria-busy until the upgrade clears it. Polite, not
assertive — the human gate keeps its assertive announcement.

Tests cover the gate ordering (tool_pending precedes tool_info and the Smart
Approvals gate) plus string-guards on both UIs' wiring, the announced-rail
specificity, and the screen-reader regions.
2026-05-31 00:57:13 -07:00
Patrick Buckley 83ab25e611 fix(ui): normalize risk_level before className/data-risk interpolation (#562)
risk_level is server-supplied and was interpolated straight into className
and data-risk at three sites — updateVerdictBadge, _buildOutputWarningEl,
renderVerdictBadge — as `risk_level || "medium"`. Whitespace, a stray case,
or a future relaxed-validation value would pass into the class string and
silently break the selectors that updateVerdictBadge, toggleVerdictDetail,
and the d-key handler rely on. It is not an XSS vector (className assignment
is text-typed), but a broken selector is a real failure.

Funnel all three through a normalizeRiskLevel() chokepoint backed by a
{low, medium, high, critical} allowlist; unknown or blank falls back to the
neutral "medium" default. Pre-existing; surfaced while rendering verdict
badges from the early-paint path.
2026-05-31 00:57:13 -07:00
Patrick Buckley da07554693 docs(judge): correct Smart Approvals heuristic-floor wording
The floor blocks only explicit heuristic deny/critical verdicts — it is
not a general "never lower the heuristic" rule. The heuristic default for
an unmatched tool is `review`, and letting a confident LLM `approve`
upgrade a `review` is the feature's purpose. Matches the implementation
and addresses PR review feedback.
2026-05-30 19:48:17 -07:00
Patrick Buckley 948e413f66 feat(judge): add Smart Approvals (auto-approve trusted judge verdicts)
Opt-in judge.smart_approvals (default off): when the intent-validation
LLM judge returns a high-confidence "approve" verdict, the tool batch is
approved automatically with no operator prompt. review/deny recommendations,
low confidence, judge errors (llm_fallback), and a deterministic heuristic
deny/critical finding all still require a human. Requires judge.enabled.

- Batch-atomic: a parallel tool batch auto-approves only if every call
  qualifies; one non-qualifying call holds the whole batch for a human.
- Gate: tier==llm + recommendation==approve + confidence >=
  judge.confidence_threshold (default raised 0.7 -> 0.95), with a floor
  that never clears an explicit heuristic deny/critical verdict.
- approve_tools waits for the async LLM verdicts, finalises the audit
  trail (AutoApproveReason.smart_approval), and re-emits verdicts after
  the card so the live chip updates; the auto-approved row renders the
  LLM verdict rather than the cautious heuristic carry-over.
- judge: always deliver exactly one verdict per call (fallback on error);
  reject non-finite confidence so NaN can't clear the bar.
- Drop verdicts from a superseded judge generation so a reused call_id
  from a prior turn's still-running daemon can't satisfy the gate's wait.

Config plumbed through the server/console/CLI builders and the live
_judge_cfg; admin Judge tab renders the toggle. Docs + example config
updated. ~35 tests covering the gate matrix, batch-atomicity, the
heuristic floor, audit stamping, the streaming re-emit, NaN/duplicate-id
defenses, and the cross-turn generation guard.
2026-05-30 19:48:17 -07:00
Patrick Buckley deb5a9b5b7 fix(console): name the node picker for assistive tech in all states
- Surface the degraded state in each node item's aria-label (only
  unreachable was included), so screen readers announce it alongside
  the visible DEGRADED word.
- Give the trigger an initial aria-label="Nodes" so it isn't an unnamed
  control before the first snapshot render populates it; renderNodePicker
  overwrites it with the live count/version once data arrives.
2026-05-30 17:03:57 -07:00
Patrick Buckley 1bff1a4fc3 style(console): give the node-picker trigger a resting button box
The trigger only showed a border on hover/open, so at rest it read as
plain text rather than a control. Add a persistent recessed box (subtle
fill + border) so it's visibly clickable, and brighten the border to
accent on hover and when open.
2026-05-30 17:03:57 -07:00
Patrick Buckley 84d38fd64b feat(console): replace NODES table with a bottom-bar node picker
The always-visible NODES table dominated the coordinator-first landing
page for information most users glance at rarely. Replace it with a
compact node picker in the cluster status bar: the rightmost segment
shows "N nodes" + the cluster version (or a DRIFT chip on mixed
versions), and clicking it opens a dropdown of every compute node with
its live workstream count. Selecting a node navigates to /node/{id}/ —
the same destination the table rows linked to.

The picker reads the same /v1/api/cluster/snapshot + SSE data the table
did (via the retained buildNodeInfoFromSnapshot), so no backend change
was needed; the table was a pure client-side render. The node-grouping
/ prefix-collapsing JS and all the table CSS are removed.

Accessibility / design:
- Status is encoded by shape and colour (round = healthy, diamond =
  degraded, square = unreachable), mirroring the .csb-state-dot
  vocabulary, plus a spelled-out DEGRADED/DOWN word — colour alone
  fails at 7px for color-blind users.
- DRIFT renders as a solid amber chip (dark text on fill) so it reads
  as a real alert rather than yellow-on-yellow.
- role="menu"/menuitem (navigation, not selection), aria-haspopup,
  aria-expanded; Escape / outside-click / Arrow / Home / End handling;
  full node id surfaced via title when the name ellipsizes; menu height
  clamped to the viewport so a long list never touches the top edge.

tests/test_console.py: assert the picker markup is served and the old
table markup (#view-overview / #node-table) stays gone.
2026-05-30 17:03:57 -07:00
Patrick Buckley 2c2f9e15e3 fix(tls): contain reload + startup-GC failures in console TLS init
Address PR review threads:
- _on_renewed now wraps the client-context hot-reload in try/except, so a
  load_cert_chain failure can't abort the renewal callback before the
  frontend-bundle update (matching the node-side reload hook).
- The startup gc_expired_certs() sweep is contained like the periodic one,
  so a malformed legacy cert row can't abort the TLS block and skip the
  proxy/collector mTLS client setup that follows it.
2026-05-30 16:27:15 -07:00
Patrick Buckley d820168f3f fix(tls): repair cluster mTLS — cert identity, renewal scoping, hot-reload
Enabling mTLS broke the cluster in three layered ways:

- Service certs were keyed on socket.gethostname() (the container ID) and
  never carried the advertised service name as a SAN, so every collector and
  routing-proxy handshake failed the hostname check. build_cert_hostnames()
  now puts the advertised host first: it becomes the cert's primary domain
  (hence a SAN) and a stable store key that survives container recreation.

- lacme's RenewalManager renews everything in the store; with the store shared
  cluster-wide, every node renewed every other node's (and every dead
  container's) cert — an N×M renewal storm. _SingleDomainStore scopes each
  node's sweep to its own cert, and the console adds a periodic GC for the
  certs of long-departed nodes.

- uvicorn loads its cert once at boot and never reloads, so renewed certs
  never reached the listener and the served cert expired mid-process.
  swap_context_cert() hot-swaps renewed material into the live SSL context
  (server listener and console client context) via load_cert_chain.

Observability and browser access:

- The collector logged connection/TLS failures at DEBUG, so a persistent
  mTLS-verify failure was invisible. It now logs the first failure per node
  (reachable->unreachable) at WARNING and stays at DEBUG on retries.

- The console serves plain HTTP (it is the ACME bootstrap endpoint) and no
  longer rewrites its advertised URL to https://. Browser->console TLS is
  terminated by a reverse proxy: the cluster profile gains a caddy service
  (browser h2/HTTPS -> caddy -> console h1.1/HTTP) plus browser-TLS docs.

Tests: tests/test_tls_san_renewal.py, tests/test_collector_reachability.py.
2026-05-30 16:27:15 -07:00
Patrick Buckley 3d12798315 feat(audio): voice I/O — speech-to-text + text-to-speech via model roles (#618)
* feat(audio): voice I/O — speech-to-text + text-to-speech via model roles

Browser voice input/output over the OpenAI audio wire protocol, selected
through the existing model-roles system so the same code path serves OpenAI,
vLLM/vLLM-Omni, or any compatible backend — pure registry config, no new
in-process deps. Anthropic has no audio API, so it is capability-gated out of
the audio roles while remaining valid as the agent model.

Backend
- core/audio.py: role resolution + capability gating + transcribe()/synthesize()
  over a registry-resolved client. Typed AudioUnavailableError (503) /
  AudioBackendError (502 — body masked, SDK detail logged). Optional STT prompt.
- Endpoints POST /v1/api/workstreams/{ws_id}/speech-to-text and POST /v1/api/tts,
  registered in v1_routes, write-scoped (direct + proxied), offloaded with
  asyncio.to_thread. Silence -> 422; configured-but-failed backend -> masked 502.
- Model roles: audio.stt_model_alias / audio.tts_model_alias / audio.tts_voice /
  audio.stt_prompt settings; Models -> Roles entries (capability-gated dropdowns,
  "(disabled — voice off)" when unset). /v1/api/models exposes resolved
  stt_default_alias / tts_default_alias + per-model capabilities.
- Capabilities: supports_transcription / supports_speech_synthesis on
  ModelCapabilities; current OpenAI audio lineup (whisper-1, gpt-4o[-mini]-
  transcribe, tts-1[-hd], gpt-4o-mini-tts) registered as known models, with a
  name-inference backstop for local/openai-compatible aliases.

Frontend (interactive UI)
- Mic dictation (record -> transcribe -> fill composer for review) and
  per-message playback, shown only when the role is configured.
- CSS-mask icon set, aria-pressed + live-region announcements, recording timer,
  reduced-motion cue, error-typed toasts + persistent denial, mic disabled while
  busy, code/math stripped before TTS.

Tests: new test_audio.py plus STT/TTS endpoint, settings, openapi, available-
models, and OpenAI-lineup capability coverage. ruff + mypy + node --check clean.

* fix(audio): use const for AUDIO_MODEL_HINTS (var-sweep invariant)
2026-05-30 14:03:39 -07:00
Patrick Buckley f9204a80e9 feat(ui): re-add saved-list pagination (cap 20) and align coordinator dashboard
Saved Workstreams / Saved Coordinators (shared createSavedTable):
- Re-add the pagination retired by #611, capped at 20 rows/page, in the
  shared component so both surfaces stay consistent. The list is fetched
  whole and sliced client-side; the delete controller only sees the visible
  page so Select-All stays bounded. Page resets on filter/sort, clamps on
  shrink, hides on a single page or in delete mode.
- Footer is range-aware ("Showing 1-20 of N"); footer + pager share one
  justified row (range left, pager right) so they read as one region.
- Saved rows carry the pointer cursor in the shared cards.css. The console
  only set it on .dash-row.has-link, which the shared row builder never adds,
  so saved-coordinator rows had fallen back to the default cursor.

Active Coordinators (console home):
- Give the active-coordinators block the full card chrome matching the
  server's Workstreams block: a dash-header bar with an "N active / M total"
  summary, the shared dash-colheaders band (was missing entirely), the rows,
  and a dash-footer count line.
- Share .dash-footer into base.css (was server-only); the server keeps its
  bottom-margin override.
- Make both coordinator cards contiguous by dropping the console-only
  home-section gap, matching the server which ships both cards contiguous.

Frontend only -- no API, DTO, or migration changes. Pagination logic
covered by a DOM-stub harness; two designer passes applied.
2026-05-30 11:33:47 -07:00
Patrick Buckley a5570f027b fix(sse): consume the resume cursor in the coordinator dashboard
make_history_handler is shared by interactive and coord, so coord
/history already trims the executing in-flight orphan turn and returns
a cursor. But coordinator.js never read it -- it connected fresh, so the
trimmed turn was neither in /history nor delta-replayed and vanished
from the dashboard (a regression vs the prior #610 in-flight render).

Mirror the ui/static/app.js fix in coordinator.js: refetchHistory takes
a seedCursor flag (default false) and seeds lastEventId from hist.cursor
only on the initial-connect path; connectSSE gates ?last_event_id= on
!= null so a cursor of 0 isn't dropped. The clear_ui / replay_truncated
re-render callers leave seedCursor false (they run on a live stream and
must not rewind the live cursor). Adds a coordinator.js static guard.
2026-05-30 04:23:11 -07:00
Patrick Buckley 3070bc4eb5 fix(sse): coerce non-int ui._event_id to None when stamping saves
When the active UI is a MagicMock test double, _ui_event_id() returned
the auto-vivified _event_id mock (getattr finds it, so the None default
never applies). That mock reached the conversations INSERT and failed
to bind ("type 'MagicMock' is not supported"), so save_message raised,
the row was dropped, and tests on the real-storage + mock-UI path broke
(CI: test_session_attachments::test_db_row_stores_text_only).

Coerce a non-int _event_id to None so mock UIs -- and counterless
CLI/eval/placeholder UIs -- stamp NULL (the synthetic-snapshot floor),
matching the documented contract. Production UIs always carry an int,
so behaviour there is unchanged.

Also drop two redundant local `import json` in the new /history
integration tests; the module-level import already covers them.
2026-05-30 04:23:11 -07:00
Patrick Buckley cdc1dbcc1d feat(sse): event-id cursor resume for fresh-connect in-flight tool batches
A fresh browser connect during a parallel tool batch (e.g. several
web_fetch) left completed siblings' tool blocks empty until a manual
refresh: each tool_result SSE event fires the instant a sibling
finishes, but the result messages persist only after the whole batch
returns, so a fresh connect replayed neither the already-fired event
(a fresh connect doesn't replay the ring buffer) nor a /history row.

Route the fresh connect through the same delta replay a reconnect
already uses. Persist the per-ws SSE ring-buffer high-water mark
(_event_id) onto each saved conversation row. /history returns the
committed snapshot up to a resolved-turn-boundary cursor and omits the
trailing executing in-flight turn; the client opens its initial SSE
with that cursor (Last-Event-ID) so the existing replay_ok path
fast-forwards the in-flight turn whole -- tool blocks, results, and
approve/plan prompts all rebuild from the ring buffer.

The cut sits at the last resolved-turn boundary (not max(saved
event_id)), so out-of-order result saves in the post-batch loop can't
move it or strand a sibling. Gated on buffer-liveness (can_replay_from):
reloaded / evicted / awaiting-approval cases keep the in-flight turn in
/history and return a null cursor, falling back to the synthetic
snapshot floor -- preserving the existing in-flight render and never
leaving a turn unrenderable.

- Migration 059: nullable event_id BIGINT on conversations + a
  (ws_id, event_id) index (keeps the cold-open high-water reseed a seek).
- save_message(event_id=) across the storage wrapper / protocol /
  sqlite / postgres backends; get_max_event_id; reconstruct_messages
  surfaces the _event_id side-channel.
- SessionUIBase: reseed _event_id from storage on construction (so the
  id space stays monotonic across restarts); can_replay_from() gate.
- make_history_handler: _resume_cursor_and_trim() + cursor in the
  response (WorkstreamHistoryResponse.cursor). The shared projection,
  export, and coord-rebuild paths are untouched.
- app.js: seed the resume cursor on the initial-connect path only, and
  gate the last_event_id param on != null so a cursor of 0 (a brand-new
  workstream's first-turn boundary) is not dropped.

Tests: helper, storage round-trip, and seed unit tests; two
make_history_handler integration tests (cursor + orphan-trim when
replayable, null cursor + orphan kept when not); app.js static guards.
Migration applies up and down on SQLite.
2026-05-30 04:23:11 -07:00
Patrick Buckley 7b8cc157f7 fix(web): preserve block structure in strip_html, remove ReDoS risk
strip_html deleted every HTML tag with no separator, gluing paragraphs,
headings, list items, and table cells into a structureless run of text
("<p>a</p><p>b</p>" -> "ab"). This degrades web_fetch, which feeds the
cleaned page to a summarising agent — and it flattens the structure any
downstream chunking/retrieval would rely on.

Block-level tags and <br> now become newlines so structure survives
("<p>a</p><p>b</p>" -> "a\n\nb"); inline tags are still dropped.

The conversion is a single linear tag scan: one pass over `<[^>]++>` with
a possessive quantifier, dispatching each tag name against a frozenset.
This replaces three full-document passes plus a 24-way alternation, and:

- Removes catastrophic backtracking (ReDoS). The earlier `<\s*/?\s*` and
  `<\s*br\s*/?\s*>` patterns were quadratic on '<' + a long whitespace
  run (~2s at 4k chars); the scan is now linear (~3ms at 1M chars) on the
  untrusted, up-to-10MB web_fetch input. The possessive quantifier also
  neutralises the pre-existing quadratic in the old `<[^>]+>` pass.
- Matches <br> carrying attributes (e.g. `<br clear="all">`), which the
  first cut missed.

Tests cover block separation, inline-tag joining, uppercase tags, <br>
with attributes, lookalike tag names, and a pathological-whitespace
regression guard.

Note (pre-existing, not changed here): in _exec_web_fetch the 10 MB cap is
applied after strip_html, so the stripper sees the full fetched body. With
the scan now linear this is no longer a CPU concern; capping the raw input
before stripping remains a worthwhile defence-in-depth follow-up.
2026-05-30 01:11:48 -07:00
Patrick Buckley a2834349b0 feat(export): export workstream conversations as OpenAI messages JSON
Add a workstream conversation export on three surfaces, all sharing one
serializer (turnstone/core/export.py):

- `turnstone-admin export <ws_id> [--children] [-o FILE|-]` — offline,
  direct-DB. `--children` bundles a coordinator's parent conversation
  plus one JSON per child into a zip (parent.json + children/<id>.json,
  no manifest).
- `GET /v1/api/workstreams/{ws_id}/export` — conversation-only file
  download, mounted on both the node (interactive) and console
  (coordinator) lifespans via `make_export_handler(cfg)`, reusing the
  /history gate ladder (permission_gate, tenant_check, list_kind
  cross-kind isolation) so ownership and isolation come for free.
- Web UI — an "Export conversation" item in the interactive per-tab
  dropdown (scoped to that tab's workstream) and an Export button on the
  coordinator appbar.

Format is OpenAI Chat Completions messages JSON (`{"messages": [...]}`),
built from `sanitize_messages(load_messages(repair=True))`. Persisted
reasoning is surfaced on assistant messages as a flat `reasoning_content`
field (the convention OpenAI-compatible inference servers use) via a
dedicated helper that runs before sanitize strips the internal
_provider_content lane. Attachments ride along as the standard image_url
/ inlined-document content parts.

Lets users get conversations out in a portable interchange format
(backup, fine-tuning datasets, sharing, interop) without lock-in.
Closes #613.

Non-obvious decisions:
- Single format (openai-json); children/zip is CLI-only. The HTTP
  endpoint and web UI are conversation-only, keeping the served surface
  — and its security surface (no child rows read through the coordinator
  handler) — small.
- `reasoning_content`, not the `reasoning` field /history and the
  reasoning-replay path use: export targets the chat-completions
  convention. Documented in export.py to prevent a "consistency fix".
- list_workstreams exposes no cursor, so the child walk passes an
  explicit high limit rather than inheriting the default 100, which
  would silently drop a coordinator's children past 100.
- Interactive export lives in the per-tab menu (interactive is
  per-tab/pane — avoids focused-workstream ambiguity); the coordinator
  is one conversation, so it keeps an appbar button.

Tested: 25 new tests through real storage + handlers (TestClient), incl.
cross-kind isolation 404, misconfig 500, the reasoning + attachment
pipeline, and the coordinator children zip. The shared frontend helper
is verified by a node sandbox harness (re-entrancy guard, button
disable/aria-busy, no-button tab-menu path). Full non-live suite green
(6714 passed); ruff + format + mypy clean; OpenAPI spec updated.
2026-05-29 21:11:16 -07:00
Patrick Buckley 14369cecfa chore: bump version to 1.6.0a7 2026-05-29 18:44:56 -07:00
Patrick Buckley 8f89e9c159 fix(sse): advance reconnect cursor on the replayed last_error event
PR #612 review (Copilot): the synthetic `error` event surfaced on a fresh
connect carried no SSE `id:`, so the client's `lastEventId` never advanced.
The client's `error` handler is append-only (not idempotent like
`state_change` / `in_progress_snapshot`), and a terminal-errored idle ws
emits no live event to set a cursor — so a native EventSource reconnect sent
no `Last-Event-ID`, re-ran the fresh path, and appended a DUPLICATE error
bubble on every reconnect cycle (proxy idle-timeout, network blip).

Attach `id: str(snap_seq)` (the registration-time buffer cursor already in
scope) to the surfaced error. The reconnect then sends that `Last-Event-ID`
→ `register_listener_with_replay` returns `replay_ok` (nothing buffered past
snap_seq on an idle ws) → the handler's replay_ok branch skips the synthetic
surface. No duplicate.

Test asserts the surfaced error carries `id: snap_seq`; the existing
`test_handler_replay_ok_does_not_resurface_last_error` pins the
reconnect-skips half.
2026-05-29 18:35:16 -07:00
Patrick Buckley 60dbb1c9cf fix(sse): surface persisted last_error on fresh-connect replay
A browser connecting fresh to a workstream sitting in the error state
saw the error STATE (composer unlock + retry, via the replayed
state_change) but not the error TEXT explaining why — on a fresh connect
there was no source for it. `on_error` is never persisted as a message,
so `/history` can't rebuild it; only the reconnect path (ring buffer)
carried the original `error` event.

Surface the persisted `last_error` in `make_events_handler`'s
fresh/truncated synthetic-replay branch when the workstream is in the
error state. Gated on the error state so a healthy ws skips the storage
read, and confined to the fresh/truncated path — the `replay_ok` branch's
ring buffer already replays the original `error` event, so surfacing here
would double it. The persist (`_record_fatal_error`, sanitized) / clear
(on recovery) lifecycle already exists; this only reconstructs the event
on a fresh connect, reaching parity with reconnect.

Second of the fresh-connect replay-completeness fixes surfaced by the
audit (sibling to the tool-call `pending` fix in this branch). Non-terminal
mid-turn errors (tool parse failures, truncation — not state=error, not
persisted) remain an accepted gap; the queued-message indicator gap is
deferred (needs client-side render-on-replay).

Adds two parity tests: fresh+error → surfaced / fresh+idle → gate skips,
and replay_ok → not double-emitted.
2026-05-29 18:35:16 -07:00
Patrick Buckley 7f7a762acd fix(history): gate /history pending flag on live awaiting-approval
In-flight tool calls did not render when a browser connected fresh to an
in-progress workstream mid-tool-execution; they only reappeared after the
SSE dropped and reconnected.

`project_history_messages` marked the trailing tool-call turn `pending`
from orphan-detection (a tool_call with no result) as a proxy for
"awaiting approval". But an orphan that is *executing* (already approved,
running) is orphan-but-not-awaiting. The renderer skips `pending` turns
because the SSE replay re-emits the interactive approve_request prompt
instead — and during execution `_pending_approval` is None, so nothing
re-emits. The tool call rendered from neither source on a fresh connect,
recovering only on reconnect (ring-buffer replay carries the
tool_info / tool_result events).

Regression from the REST-first history convergence (0ad1ab7f), inherited
by the wire-shape unification (#596): both swapped the `pending` predicate
from the live `_pending_approval` signal to storage orphan-detection,
which diverge exactly during tool execution.

Thread the live awaiting-approval signal from `make_history_handler` into
`project_history_messages` (new `awaiting_approval` param) and gate the
pending mark on it, re-syncing `pending` with the same `_pending_approval`
signal that drives the SSE prompt re-emit. A storage-only / closed ws has
no live session → never pending → trailing orphans render as historical.
Asserted as `dict` to match the detail handler's MagicMock-safe guard.

Adds a projection-level gate test and two handler boundary tests
(execution → renders, awaiting approval → pending). The existing
partial-trailing-turn test now asserts the turn RENDERS, not just that the
row survives — the parity gap that let this regression through.
2026-05-29 17:27:44 -07:00
Patrick Buckley 458bc7c4a4 feat(ui): saved workstreams & coordinators — card grid → sortable table
Replaces the Saved Workstreams (ui/static) and Saved Coordinators
(console/static) card grids with a dense, sortable table that reuses the
active dashboard's row system, via one shared component in
shared_static/cards.js (renderSessionRow, SavedColumns, createSavedTable)
+ cards.css. The two surfaces differ only by column spec (MSGS vs CHILDREN)
and per-app data/ids/delete-request; everything generic is shared.

- NAME flexes to full width (kills the card grid's near-duplicate-name
  truncation); client-side filter + sortable headers; scroll-all
  (pagination retired); multi-select delete preserved on rows.
- Consumes the enriched saved-list DTO: MODEL, CTX (context-window
  occupancy, a frozen last-activity snapshot), SKILL chip, CHILDREN, and a
  red left-edge for failed runs.
- Saved rows reuse the dash-table chrome but opt out of the active table's
  live-state styling: idle rows aren't dimmed, CTX reads as a snapshot (not
  the live gauge), legible zebra + AA-contrast muted text for a long
  terminal list, and responsive compact columns keep NAME readable on
  narrow viewports.
- a11y: sortable headers exposed to assistive tech (aria-label / aria-sort
  + at-rest carets); footers are live regions.
- Removes the now-dead renderSessionCard + card-grid CSS.
2026-05-29 17:26:25 -07:00
Patrick Buckley c80354880a feat(api): enrich saved-workstream list with model/skill/context fields
GET /v1/api/workstreams/saved returned only ws_id/alias/title/created/
updated/message_count — too little to drive the planned saved-list table
redesign. Add seven fields, all sourced from already-persisted data (no
migration):

- state, kind, node_id: columns on the workstreams table
- model_alias, launch_skill: from workstream_config via LEFT JOIN
- child_count: COUNT of child workstreams via parent_ws_id
- context_tokens: most recent usage_events prompt size for the workstream
- context_ratio: context-window occupancy (context_tokens / model context
  window), computed in the handler so the NULL / zero-window cases stay
  explicit and identical across both storage backends

context_window comes from a model_definitions join; aliases defined only in
config.toml are absent there, so context_ratio degrades to 0.0 rather than
reporting bogus occupancy. The Python SDK reuses the Pydantic model; the
TypeScript SDK OpenAPI snapshot and hand-maintained interface are updated.

Tests cover the new storage columns (including NULL-when-absent), the
handler ratio math + zero-window degradation, and the SDK enriched
round-trip.
2026-05-29 16:05:17 -07:00
Patrick Buckley bdc1f35f94 fix(usage): correct dashboard totals + record auxiliary LLM token spend
The Usage dashboard summary cards read the oldest day bucket
(`summary.breakdown[0]`) instead of the window SUM, so every headline
(total/prompt/completion/tool-calls/cache) showed a single day's value —
e.g. 30-day tool-calls reading lower than 7-day. Read `.summary[0]` and
collapse the redundant two-request fetch into one (the response already
carried both `summary` and `breakdown`).

Only the main streaming loop (`on_status`) recorded `usage_events`.
Auxiliary non-streaming calls — title generation, conversation
compaction, web-fetch summarization, and plan/task sub-agents — bypassed
that path and were never counted, undercounting real consumption by a
large factor for agent-heavy workstreams. Add an `on_aux_usage` UI hook
(storage row via a shared `_write_usage_row` helper with `on_status`;
`WebUI` override feeds Prometheus) and route `_utility_completion` and
sub-agent turns through it, attributed to the agent's own model. Judge
token spend remains uncounted — deferred to a follow-up.
2026-05-29 14:40:10 -07:00
Patrick Buckley 8e32aa09d4 fix(server): advertise registry.default when model.default_alias is foreign
GET /v1/api/models blanked default_alias whenever model.default_alias named
an alias absent from the server's live registry — e.g. when a standalone
turnstone-server shares a ConfigStore with a console whose model.default_alias
points at a console-only / DB alias (or the underlying model id rather than
the alias). The interactive dashboard then showed a bare "Default model"
placeholder even though a new workstream launches on a concrete model.

Mirror session_factory's _effective_default_alias / _effective_routing: fall
back to registry.default (which already incorporates a *valid* model.default_alias
override) when the configured alias is unset or foreign, blanking only if
registry.default is itself unresolvable. The endpoint now reports the model
creation actually uses.
2026-05-29 13:09:18 -07:00
Patrick Buckley 3e5633f440 chore(sdk): regenerate OpenAPI snapshots from current specs
openapi-server.json / openapi-console.json had drifted well behind
build_server_spec() / build_console_spec() — the committed snapshots are
regenerated periodically (via sdk/typescript/scripts/generate-types.py)
rather than on every schema-changing PR, so accumulated additions (skill
parsing, pending-approval items, model-definition CRUD, etc.) had not been
captured. This resyncs both with no code changes.
2026-05-29 12:35:51 -07:00
Patrick Buckley 48fc381ab6 fix(ui): hide saved-workstreams pagination during dashboard load/error
Addresses #603 review: the pagination control is a sibling of the cards
container, so loadDashboard()'s "Loading…" / "Failed to load" states (which
replaceChildren only the cards) left stale Prev/Next visible and still wired
to the previous _wsSavedItems cache — in the error state clicking them would
resurrect the old cards over "Failed to load". Route both transient states
through a _setSavedWsMessage() helper that clears the cards and hides the
pagination in lockstep; a successful load re-renders both via
renderSavedWorkstreams.
2026-05-29 12:32:47 -07:00
Patrick Buckley 757561db9a feat(ui): backport coordinator selector + pagination UX to interactive dashboard
The interactive dashboard had drifted from the coordinator launcher in two
ways; this backports both for consistency.

Selectors: the Model / Judge Model dropdowns now show the resolved default
model in the placeholder (e.g. "Default — primary (vendor/primary)") instead
of a generic "Default model" / "Default (agent model)". The server's
GET /v1/api/models now returns judge_default_alias (mirroring the console
endpoint), sourced from the judge.model setting. It is intentionally left
blank when judge.model is unset or points at a disabled/removed alias,
because the judge then follows the per-workstream agent model at runtime
(session_factory: judge_config.model or model) — the UI keeps the honest
"Default (agent model)" wording in that case. This also fixes a latent
mislabel: the judge row previously said "agent model" even when an operator
had configured judge.model.

Pagination: Saved Workstreams now paginates at 24/page (Prev · X / Y · Next),
matching Saved Coordinators — page clamp after deletes, hidden on a single
page and in delete mode, Select-All bounded to the visible page. The empty
branch drops out of delete mode (matching the launcher) so the toolbar can't
linger over an empty grid.

The shared .pagination CSS is lifted from console/static/style.css into
shared/cards.css (loaded by both apps) so the two dashboards keep one source
of truth instead of a third copy. The pagination JS render wiring stays
per-app (it binds per-app DOM ids + controller instances) with a
cross-reference comment to its console twin.

Tests: new tests/test_server_available_models.py pins the judge/model
resolution chain (unset / configured / unknown / whitespace / registry-default
fallback).
2026-05-29 12:32:47 -07:00
Patrick Buckley f4ca967726 feat(admin): surface output-guard judge model in Models → Roles
The output-guard judge's model (judge.output_guard_model) was only
configurable on the Judge settings tab, while every other model role —
coordinator, intent judge, plan/task agents, channel adapter — lives in
Models → Roles. Add it there as a role (mirroring the intent Judge role)
and skip it on the Judge tab so it renders in exactly one place.

No backend change: the role read/write goes through the generic
/v1/api/admin/settings endpoints, the same path the intent-judge model
role already uses.
2026-05-29 11:17:32 -07:00
Patrick Buckley 59a0ed445c fix(judge): address Copilot review on #601
- LLM-tier row no longer duplicates the judge reasoning into its
  annotations column — reasoning lives in the dedicated reasoning column,
  so annotations stays heuristic-only and audit consumers aren't confused.
  (The replay merge reads the heuristic row's annotations + the LLM row's
  reasoning, never the LLM row's annotations, so this is display-safe.)
- Correct the output-warning chip comment: tier "llm" means the judge
  returned a verdict (it may have cleared a heuristic-positive), not that
  it owns the displayed finding.
2026-05-29 11:17:12 -07:00
Patrick Buckley 0608fdd634 docs(judge): correct output_guard_llm help text — merge, not override
After the heuristic+LLM merge, the LLM verdict no longer "overrides" the regex
verdict; it merges (risk = max, flags = union) and can raise but never lower a
regex finding. Fix the admin Settings help string to match.
2026-05-29 11:17:12 -07:00
Patrick Buckley 30d670338e feat(judge): merge output-guard heuristic + LLM judge, annotate findings
Surface the output-guard LLM judge on the inline finding chip and merge it
with the regex heuristic instead of one stage winning outright.

Merge rule (issue #560, "show, annotated"):
- risk_level = max(heuristic, llm); flags = union. The judge can escalate
  but never lower a heuristic positive — it evaluates adversarial tool
  output, so defeating it must not erase a deterministic regex finding.
  Credentials stay heuristic-only and are always redacted.
- The judge's own verdict rides along as a dissent-aware annotation
  (judge_risk / confidence / reasoning / judge_model) on the chip in both
  the interactive and coordinator UIs, live and on reconnect. One shared
  merge_guard_display_payload drives both paths so they cannot drift.
- The model is shown the merged risk + flags but never the judge's "benign"
  verdict — a fooled judge must not talk the model out of caution.

Fixes a reconnect bug: a judge that ran but failed wrote a risk="none" row
that won the replay dedup and hid the heuristic finding (it showed live but
vanished on refresh). Failed judges now persist under tier="llm_error",
excluded from the display merge; the max-merge also floors the displayed
risk at the heuristic level so the chip never vanishes.

Also adds a regression test confirming the LLM judge runs on every tool
output, not just heuristic-flagged ones.

Tests: merge unit tests, storage-backed replay regression, live/replay
wire-shape parity, SDK-event drift guard. ruff + mypy clean.
2026-05-29 11:17:12 -07:00
Patrick Buckley 4ee043d949 chore: bump version to 1.6.0a6 2026-05-28 21:30:37 -07:00
Patrick Buckley 06c52e8e7e refactor(ui): share per-message affordance CSS via chat.css (#549)
The per-message rewind / edit / retry affordance — the icon glyphs
(.icon-edit / .icon-rewind / .icon-retry) plus the inline edit-in-place
form (.msg-edit-*) and the [data-busy] / .msg-editing states — was
duplicated verbatim in both pane stylesheets: ui/static/style.css
(interactive) and console/static/coordinator/coordinator.css
(coordinator). PR #598 deferred consolidating them to keep that
coord-only change off the shipped interactive stylesheet's cascade.

Move the block into shared_static/chat.css, immediately after the
.msg-actions / .msg-action-btn primitives both panes already share, and
delete both copies (including coordinator.css's now-obsolete FOLLOW-UP
note describing the duplication).

Both index.html files load chat.css before their pane stylesheet, so the
rules land earlier in the cascade; the selectors are unique (defined
nowhere else, confirmed repo-wide) so it is a visual no-op. The block is
moved verbatim — chat.css's sibling rules use a different variable
vocabulary (--r-sm=4px / --font-mono) than the affordance block
(--radius-sm=3px / --font-ui), so renaming would change radii/fonts.

Verified pixel-identical via a headless-Chrome render-diff of both panes,
before vs after, across all four affordance states (edit+rewind, retry,
editing-open, busy): 0 differing pixels.
2026-05-28 21:25:10 -07:00
Patrick Buckley 371830a428 fix(admin): let scrollable kebab menus scroll instead of self-dismissing
The capture-phase window scroll listener that dismisses an open overflow
menu also fired for scrolls originating inside the menu itself (the menu can
overflow-y:auto at high browser zoom / short viewports), so a tall menu
closed the instant you tried to scroll it. Skip scroll events whose target
is inside .admin-kebab-menu; page/ancestor scroll still dismisses.

Addresses review feedback on #599.
2026-05-28 21:23:57 -07:00
Patrick Buckley 15e5e866b5 fix(admin): replace clipped action-button strips with an overflow menu
Admin tables render their per-row actions in an ACTIONS column whose grid
track is fixed-width and inherits `.admin-col`'s overflow:hidden +
text-overflow:ellipsis. Rows with several actions (MCP servers:
refresh/reconnect/edit/del) overflowed the track, so only the first button
showed and the rest were clipped behind a misleading "…". The edit button
was unreachable — the only way to change an MCP server was editing the DB
by hand (#593).

Replace the inline button strips across all 15 admin tables (admin.js +
governance.js) with a shared kebab (⋯) overflow menu:

- _kebabMenu()/_kebabMenuEl() build the menu; a single action degrades to
  an inline button. _initKebabMenus() wires open/close, outside-click,
  Escape, arrow-key nav and viewport-aware flip-up/flip-left through one
  document-level delegated listener. Menu items keep the same data-*
  attributes, so the existing per-table click bindings are unchanged.
- Right-align the actions column so the trigger shares an edge with its
  menu; pair danger/caution items with warning glyphs (not colour alone);
  theme-aware --red-glow/--yellow-glow hover tints.

Fixes #593.
2026-05-28 21:23:57 -07:00
Patrick Buckley 60b8ceda87 fix(coordinator): attach retry affordance on history re-render (#549)
_refreshRetryButton() ran only from finishAssistantStream (live turn ends), so a reloaded coordinator workstream or a clear_ui/replay_truncated re-render after rewind/retry showed assistant turns without the retry button — unlike the interactive replayHistory() path, and inconsistent with the edit/rewind buttons (which DO attach on re-render via appendUserMessageWithAttachments). Call it at the end of refetchHistory() so retry attaches on every render. (Addresses Copilot review on PR #598.)
2026-05-28 21:02:51 -07:00
Patrick Buckley 7944eb255b feat(coordinator): add rewind / retry / edit-first-message UX (#549)
Give coordinator workstreams the rewind/retry/edit affordance the interactive pane has, completing the #549 verb lift on the frontend. Browser-verified separately (the console won't boot in-sandbox).

- Factor refetchHistory() out of init(): the history-render block moves into a reusable function that clears the message column + tool-tracking state (toolRows / activeBatch) before re-rendering, so a mid-session re-render leaves no stale call_id->row mappings. init() keeps the first-paint-only pending-approval replay + children/tasks/attachments/SSE.

- SSE: case clear_ui re-renders from REST then dispatches the latched edit-and-resend via path-keyed /send; case replay_truncated re-syncs (skipped mid-stream). New _pendingEditSend module latch.

- Per-message affordance mirroring the interactive pane: edit + rewind buttons on every user bubble, a retry button on the last assistant turn (skipped when the turn ended tool-only, gated on .coord-tool-batch). Bare .msg.user turn-count matches the server's _find_turn_boundaries.

- Port the icon-glyph + inline-edit-form CSS into coordinator.css (.msg-actions / .msg-action-btn already live in shared chat.css; consolidating all of it into shared chat.css is a tracked follow-up).

coord-LOCAL mirror (not a shared_static extraction): app.js is class-based over this, coordinator.js is a module closure.
2026-05-28 21:02:51 -07:00
Patrick Buckley 5bd7af6b73 feat(session): path-key /rewind + /retry into shared verb handlers (#549)
Lift the conversation-modifying /rewind and /retry verbs out of the body-keyed POST /v1/api/command into path-keyed POST /v1/api/workstreams/{ws_id}/rewind ({turns:N}) and /retry, as make_rewind_handler/make_retry_handler in SharedSessionVerbHandlers (template: make_close_handler/make_cancel_handler), wired on both interactive and coordinator kinds. Closes the last unlifted conversation-modifying surface — coordinator workstreams gain rewind/retry where they had none — and removes the surviving exception to the post-#422 path-keyed URL convention.

Handler shape: auth gate (coord -> admin.coordinator via permission_gate; interactive -> conversation.modify via accepted_permissions) -> busy-gate -> session.rewind(n)/retry() -> always emit clear_ui (incl. rewind-to-zero, carries #503) -> audit (conversation.rewind/retry on both kinds). Retry re-dispatch reuses the shared session_worker.send via a per-kind dispatch_retry closure (hard-reject on busy), not a third hand-rolled thread.

The web /command handler now rejects /rewind+/retry with a pointer to the path-keyed endpoint (BREAKING; 1.6.0aN-tolerant); session.handle_command's branches stay for the terminal CLI. auth.py adds the verbs to both write suffix-sets; Python + TS SDKs, OpenAPI (RewindRequest + server/console specs), the /route/ proxy mounts + audit actions, and coordinator_client all gain them.

Interactive frontend (app.js): the 3 /command POST sites + the hand-typed-slash reroute now hit the path-keyed endpoints; the bare .msg.user rewind selector is kept (matches the server's _find_turn_boundaries, which counts system-nudge user turns). The coordinator frontend rewind UX lands in a follow-up commit (browser-verified).

Tests: route-walk mount/order, /route/ audit rows, required_scope, OpenAPI catalog, SDK body-inspection, and HTTP-level handler behavior (busy-gate, turns validation, clear_ui emit, retry dispatch, audit invocation + swallow).
2026-05-28 21:02:51 -07:00
Patrick Buckley 9d50e90f67 feat(providers): add Claude Opus 4.8 model support
Register `claude-opus-4-8` in the Anthropic capability table. Opus 4.8
shares Opus 4.7's request/response surface exactly — adaptive-thinking
only (`budget_tokens` rejected), sampling params removed, the
low/medium/high/xhigh/max effort levels, `thinking.display` defaulting to
omitted, 1M context, and 128K output — so the entry is a verbatim copy of
the 4.7 row. `_lookup_capabilities` longest-prefix matching then resolves
date-suffixed ids (e.g. `claude-opus-4-8-20260601`) without colliding
with the 4.7 key.

No provider code paths change: the existing 4.7 handling already covers
all of 4.8's behavior. Models are selected via config.toml / the admin
ConfigStore UI, so there is no catalog or dropdown to update.

- _anthropic.py: new claude-opus-4-8 capability entry + effort comment
- tests/test_providers.py: opus 4.8 bare + dated capability tests
- turnstone.example.toml: bump the showcased model example to 4.8
2026-05-28 19:08:26 -07:00
Patrick Buckley 56f230afa2 refactor(coordinator): consume the projected /history shape
Migrate the coordinator dashboard's init() history rebuild onto the
server-projected wire shape (the prior commit's project_history_messages),
retiring its inline raw-storage-shape handling. Field-sourcing only -- the
batch render + orphan->--running state machine is unchanged:

- callOutcomes reads the server-derived `denied` / `is_error` flags
  instead of re-sniffing tool content prefixes;
- tool_calls are read flat (`tc.name` / `tc.arguments`) now that the
  projection flattens the nested `function` wrapper;
- user content is a plain string and attachments come from the projected
  `attachments` list, replacing the multipart walk + `_attachments_meta`
  side-channel read.

Fix two latent reload bugs along the way: coord read `m.reminders` /
`m.source` but the raw shape carried `_reminders` / `_source`, so metacog
reminder bubbles and the system-nudge marker never rendered on a coord
history reload. The projection surfaces both top-level, so coord's
existing (unchanged) render paths now fire.

Repoint the test_coordinator_page deny-classifier guard at the live
`m.denied` / `m.is_error` reads instead of comment prose.

Refs #549.
2026-05-28 17:14:50 -07:00
Patrick Buckley ee8dc7c1c3 refactor(history): project the /history wire shape server-side
Collapse the three hand-synced "raw storage -> render shape" projections
into one server-side projection. The projection previously lived in a
test-only `_build_history` (SSE-era reference impl), a client-side JS
normaliser (`history_normalize.js`, the transitional bridge), and coord's
inline `init()` handling -- drifting silently with no parity test.

Add `project_history_messages` to `history_decoration.py` and run it as the
final step of the `make_history_handler` pipeline (load_messages -> decorate
-> extract_reasoning -> project), so `GET /history` emits the canonical
render shape directly: flat tool_calls (with verdict / output_assessment),
top-level source / reminders / attachments, collapsed multipart content,
derived denied / is_error / pending, reasoning, and advisories. Interactive
`replayHistory` now consumes the payload verbatim.

Close two gaps the JS bridge deferred:
- list-content <tool_output> advisory extraction (decorate handles only
  string content; the projection extracts list-carrier advisories, then
  joins remaining text parts to the string the renderers require);
- orphan->pending marks ONLY the last orphan tool-call turn, so a
  mid-conversation cancelled tool still renders instead of vanishing.

Delete `history_normalize.js` (+ its <script> tag and node test) and the
test-only `_build_history` (+ orphaned imports); retarget its direct tests
onto the projection helpers. Update the WorkstreamHistoryResponse
description and the Web UI Resilience architecture note to the projected
shape.

Coord's `init()` still reads the raw side-channels; migrating it to the
projected shape is the next commit, browser-verified separately.

Refs #549.
2026-05-28 17:14:50 -07:00
Patrick Buckley 91ffbf8692 fix(ui): guard interactive history load against ws-switch races + stale SSE cursor
Two issues on the new REST-first history path (PR #595 review + a user repro):

- Stale history on a fast workstream switch: a slow `_refetchHistory` (e.g. a
  large resumed session) could resolve AFTER the pane moved to another ws,
  rendering the old ws's history over the new one — and its `.finally`
  reconnecting the old stream. Add a per-pane load-generation token:
  `_loadHistoryThenConnect` bumps it, and the refetch render, the deferred
  `connectSSE`, and the `clear_ui` resend are each discarded when a newer load
  supersedes them. Fixes the "open a child workstream, see the previously
  resumed workstream's history" repro.

- Stale per-ws SSE replay cursor (Copilot): ws-assign callers set `this.wsId`
  before `_loadHistoryThenConnect`, so `connectSSE`'s `wsChanged` is already
  false, and `reset()` never cleared `_lastEventId` — so a tab switch / child
  open sent ws-A's `last_event_id` to ws-B, mis-triggering the server's
  `replay_ok` path and skipping the synthetic replay (connected / status /
  in_progress_snapshot). Reset `_lastEventId` + `_lastStatusEvt` in
  `_loadHistoryThenConnect` so a ws (re)load always opens a fresh stream;
  transient same-ws reconnects (direct `connectSSE`) still reuse the cursor
  for `replay_ok`.

test_app_js.py pins the load-generation guard.
2026-05-28 15:53:25 -07:00
Patrick Buckley 0ad1ab7f6d refactor(ui): converge interactive history delivery onto REST-first model
Interactive fetched conversation history as an inline SSE `history` event on
every (re)connect — a multi-MB payload — while coord fetches it once via REST
`GET /history` and uses SSE for live deltas only. This converges interactive
onto coord's model so both kinds share one history-delivery pattern, the
prerequisite for lifting the `/command` (rewind/retry) verb to coord.

Backend (server.py, core/session_routes.py):
- `_interactive_events_replay` and the `/command` resume/rewind branches no
  longer emit the inline `history` SSE event; the open/create-resume paths
  emit `clear_ui` only. The `/events` stream no longer carries conversation
  history — REST `GET /history` is the source (acceptable on 1.6.0aN).
- Removed the now-orphaned `events_replay_prepare` hook.
- `_build_history` retained as the canonical wire-shape reference for the
  decoration/parity tests (no production callers post-convergence).

Frontend (ui/static/app.js, ui/static/index.html):
- `_loadHistoryThenConnect` fetches REST `/history`, renders, then opens SSE
  (mirrors coord's `init()` ordering); wired into the seven ws-assign sites.
- `clear_ui` re-renders via a REST refetch and dispatches the edit-and-resend
  latch; `replay_truncated` re-syncs (skipped mid-stream so it cannot clobber
  an in-flight turn). The `case "history"` SSE handler is removed.

New shared module (shared_static/history_normalize.js):
- `normalizeHistoryMessages` converts the raw provider-native REST shape
  (nested tool_calls, `_source`/`_reminders`/`_attachments_meta` side-channels,
  multipart content, no derived flags) into the projected shape `replayHistory`
  renders. Pure/DOM-free and node-unit-tested. This is a transitional bridge —
  a server-side wire-shape unification (folding this projection back into the
  server so interactive, coord, and coord's inline raw-handling collapse onto
  one shape) is planned to replace it.

Tests: backend replay-omits-history regression; a node-executed normalizer
projection test (incl. the orphan->pending and denial-propagation edges); and
REST-first wiring guards in test_app_js.py.
2026-05-28 15:53:25 -07:00
Patrick Buckley b02e4312ac feat(tools): make notify dual-kind, expose to coordinator sessions (#559)
notify was interactive-only — a coord with a natural "fan-out complete"
or "batch failed" beat could only post by spawning a child for the
single message, which is a lot of ceremony.  Routing is session-kind-
agnostic in _prepare_notify / _exec_notify; this is a metadata flip
that adds the coord flag (plus the explicit interactive flag the loader
needs once coordinator is set) and updates the dual-kind whitelists,
coord tool-set assertions, and skill-author docs accordingly.  Adds
two coord-session tests pinning the prepare dispatch contract
(needs_approval=False matches notify.json auto_approve) and the exec
→ channel-gateway path.
2026-05-26 17:07:10 -07:00
Patrick Buckley b5c748c985 fix(judge-ui): apply ? help-btn pattern to Judge → Settings tab (#582)
The Judge → Settings tab has its own renderer in governance.js separate
from _renderSettingRow in admin.js — it builds card-style rows inline
and was painting ``s.help || s.description`` straight into the row,
which is why the Judge settings still showed long paragraphs inline
after the prior commit on this branch.

Convert renderJudgeSettings to the same shape as the generic Settings
tab:

- Short description (s.description) renders inline below the key — the
  always-visible TLDR.
- A ? button next to the key gates the long-form s.help paragraph via
  a sibling .settings-help-popover with id ``<key>-help``.  Picked up
  automatically by the document-delegated click handler in admin.js.

Toggle behavior, save/reset buttons, and Judge-specific save endpoints
are untouched.
2026-05-26 16:31:48 -07:00
Patrick Buckley 271d7fe781 fix(console-ui): converge settings-help-btn on data-help-target (#582)
Settings-tab buttons assembled in admin.js and the skill-modal buttons in
index.html used two parallel rendering shapes with two binding mechanisms
(per-button addEventListener vs. document delegation). Listeners stacked
on the same DOM whenever the Settings tab re-rendered.

Converge both surfaces on the empty-button + ``data-help-target`` +
document-delegated dispatch pattern:

- ``_renderSettingRow`` emits empty <button> with ``data-help-target`` and
  gives the sibling popover a matching ``id``. Per-button listener loop in
  ``_renderSettings`` removed.
- ``_toggleSettingsHelp`` and ``_closeAllSettingsHelp`` drop their
  ``.settings-label-col`` fallback branches; ``data-help-target`` reverse
  lookup is the only path.
- 12 skill-modal buttons in index.html lose their literal ``?`` text so
  every emitter produces empty content.
- ``font-size: 0`` on ``.settings-help-btn`` removed (workaround is no
  longer load-bearing now that all emitters are empty).

Also tucks long-form help text in Judge modals behind the same ``?``:

- 8 ``.label-hint`` spans in the Create/Edit Heuristic Rule and Create/Edit
  Output Guard Pattern modals (Tool Pattern, Arg Patterns, Confidence,
  Pattern Flags) become ``?`` + popover. Short ``.label-hint`` strings
  elsewhere in the admin UI are unchanged.

Description rows (the short TLDR under each key) stay inline as before.
The ``?`` popover carries only the long-form ``help`` paragraph and any
``reference_url`` learn-more link.
2026-05-26 16:31:48 -07:00
renovate[bot] ee2e8a3847 chore(deps): lock file maintenance 2026-05-26 14:01:01 -07:00
Patrick Buckley 76faf81bf2 chore: bump version to 1.6.0a5 2026-05-24 18:37:20 -07:00
Patrick Buckley d068366a61 rbac: builtin-role override editor + tighten under-enforced perm gates (#585)
* feat(rbac): editable builtin role permissions via overlay layer

Adds a ``role_permission_overrides`` table that stores per-(role_id,
permission) grant/revoke deltas, applied on top of the immutable
``roles.permissions`` baseline at permission-load time. Builtin roles
(``builtin-admin/operator/viewer``) become customizable through the
admin Roles UI without losing the "reset to default" guarantee — every
override is auditable and reversible.

Motivating case: ``model.skills.write`` is deliberately default-ungranted
on every role so operators must consciously opt in before a coordinator
session can mutate the skill catalog. Until now there was no UX path to
do that opt-in — the only options were dropping into SQL or running a
fresh migration. The overrides editor closes that gap.

Backend
- Migration 057 + storage methods on both sqlite + postgresql backends
- ``get_user_permissions`` merges baseline ∪ grants − revokes for builtin
  rows; custom rows pass through unchanged
- ``GET /v1/api/admin/roles/{id}/effective`` for inspect
- ``PUT /v1/api/admin/roles/{id}/overrides`` for write — admin.roles gated,
  audited, validates against ``_VALID_PERMISSIONS``, refuses non-builtin
  targets, strips no-op grants/revokes before persisting
- Lockout guard: cannot revoke ``admin.roles`` if doing so would leave
  zero users with the permission (returns 409)
- ``coordinator.trust.send`` added to ``_VALID_PERMISSIONS`` — was
  seeded into builtin-admin by migration 042 but never registered with
  the validator, so the very first round-trip through the editor 400'd
  on it. Drift-detection test guards future migrations from recreating
  the same gap

Frontend
- Roles tab redesign: chevron + permission-count chip replace the
  "..." truncation; expand-on-click drawer groups perms by namespace
  with baseline / grant (green +) / revoke (red −) chip variants
- Edit modal opens for builtin rows ("Customize Built-in Role" title);
  toggles show baseline-default vs override state; submit diffs against
  the rendered toggle universe (not raw baseline) so future taxonomy
  drift can't silently strip unknown perms
- "Modified +N/-N" pill on rows with active overrides; "Reset to default"
  drawer action clears the override set
- ``_PERMISSION_SECTIONS`` brought up to date with all currently-seeded
  perms (admin.coordinator, admin.cluster.inspect, admin.models,
  admin.nodes, admin.prompt_policies, conversation.modify,
  coordinator.trust.send were missing)

Tests
- 7 storage tests covering set/list/clear/effective + overlay merge into
  ``get_user_permissions`` for both builtin and custom roles
- 11 endpoint tests covering effective/overrides happy paths, validation,
  lockout guard, builtin-only restriction, no-op normalization, list
  enrichment

* feat(rbac): enforce workstreams.{create,close} + tools.approve gates

These three permissions were declared in ``_VALID_PERMISSIONS``, seeded
into ``builtin-operator``'s baseline by migration 008/017, surfaced in
the admin Roles UI as toggles, and documented in ``bootstrap.py`` as
the operator role's capabilities — and never enforced anywhere. The
audit that ran out of the overlay PR found zero ``require_permission``
sites for any of them; any authenticated user could create workstreams,
close any workstream, or approve any pending tool regardless of role.

Behaviour change for callers without the perms:

- ``POST /v1/api/workstreams/new`` (node + console proxy variants)
  now 403 without ``workstreams.create``
- ``POST /v1/api/workstreams/{ws_id}/close`` (and ``/route/`` proxy)
  now 403 without ``workstreams.close``
- ``POST /v1/api/workstreams/{ws_id}/approve`` (and ``/route/`` proxy)
  now 403 without ``tools.approve``

The OR-fallback to ``admin.coordinator`` keeps coord sessions spawning
interactive children unblocked without needing operator-style perms.
Service-scoped inter-cluster calls bypass via the existing
``allow_service_bypass`` path on the new ``require_any_permission``
helper. Builtin admin and operator both already carry these perms;
viewer correctly loses workstream create/close/approve (it already
couldn't do those in spirit).

Implementation
- ``require_any_permission`` (core/auth.py) — OR-semantics variant of
  ``require_permission`` with per-conditional comments documenting the
  security policy at the choke point. 403 body names every accepted
  perm so operators get an actionable remediation
- ``make_{create,close,approve}_handler`` (core/session_routes.py)
  accept ``fallback_permissions: tuple[str, ...]`` — checked only when
  ``cfg.permission_gate is None`` (interactive case). Coord's
  ``permission_gate=_require_admin_coordinator`` continues to take
  precedence on the coord-config side
- Console-side ``create_workstream`` and ``route_create`` inline the
  same OR check before proxying — fail fast on a forbidden request
  without burning a cluster round-trip
- ``route_proxy`` adds a verb-scoped gate on ``approve`` and ``close``
  only; ``send``/``cancel``/``dequeue``/``command``/``plan`` remain
  authenticated-only (pre-existing, out of scope for this audit)

Tests
- New ``TestPermissionGatesOnLifecycle`` (4 tests) in test_server_authz
  pinning 403-without-perm + non-403-with-perm at the node lift sites
- New ``TestRouteProxyPermissionGates`` (5 tests) in
  test_console_routing_proxy covering 403 paths, OR fallback via
  ``admin.coordinator``, and that ``send`` remains ungated
- ``_make_jwt`` helpers in test_server_authz, test_close_reason_
  persistence, test_server_attachments_on_create updated to embed
  operator-shaped perms by default so existing tests continue to
  exercise the post-gate logic rather than 403'ing on the new check

Docs
- ``bootstrap.py`` operator role line corrected to list every perm
  it actually carries (was missing ``tools.approve`` and
  ``conversation.modify``)

* fix(rbac): close lockout + escalation gaps in role-overrides editor

Three issues surfaced by /review of the overlay layer and gate uplift —
all in the RBAC/auth surface, treated as zero-days.

**F-1: lockout guard misses the grant-removal path.** PUT-replace
semantics on ``set_role_overrides`` mean an existing grant of
``admin.roles`` (added via override to e.g. builtin-operator) is
silently dropped when the new payload omits it.  The previous guard
short-circuited on ``"admin.roles" not in revokes`` and never noticed.
Concrete cluster-bricking scenario: grant admin.roles to operator via
override, unassign builtin-admin, click "Reset to default" on operator
→ all users lose admin.roles, recoverable only via SQL.

The rewritten guard simulates the post-PUT effective set on the target
role directly: if ``(baseline | new_grants) - new_revokes`` lacks
admin.roles AND nobody holds it via another role, refuse the change.
The "via another role" question is answered by one bulk query rather
than the prior O(users × roles) round-trip loop.

**F-3: lockout check blocked the event loop on moderate deployments.**
The prior check called ``storage.list_user_roles`` per user and
``storage.effective_role_permissions`` per (user, role) pair —
synchronous SQL inside an async handler.  200 users × 5 roles = 1000
connection cycles long enough to trip reverse-proxy timeouts on a
permission revoke.

Replaced with ``storage.users_with_permission(perm, *,
exclude_role_id)`` — one join over ``user_roles ⋈ roles`` plus one IN
fetch on overrides for the builtin role ids in the result, folded
in-process.  Two queries total, independent of cluster size.  The whole
check now runs under ``asyncio.to_thread`` so even the bulk read
doesn't stall the loop.

**F-2 reframed: admin_assign_role's subset check ignored the overlay.**
The check at lines 6321-6328 reads ``target_role.get("permissions",
"")`` (baseline column) when computing the perms it requires the
caller to hold.  After this branch, an admin.roles holder can grant
e.g. ``model.skills.write`` to builtin-operator via override; an
admin.users holder (who happens to NOT hold that perm) could then
assign operator to a new user, silently escalating the assignee.  The
existing two-person-rule by perm split (admin.roles for catalog edits,
admin.users for assignments) only holds if the assignment-time check
considers the overlay.  Switched ``target_perms`` to
``storage.effective_role_permissions(role_id)["effective"]``.

Note: this PR retains the existing model where admin.roles is the
catalog-edit superuser (admin_create_role, admin_update_role, and now
admin_role_overrides all skip the caller-holds-grants check).  The
two-person rule against escalation lives at the assignment gate, which
this fix reinforces.

**F-7: delete_role left orphaned override rows.** No FK on
``role_permission_overrides.role_id`` (migration 057 omitted FKs to
match the rest of the governance schema).  Added explicit cleanup in
both sqlite + postgresql ``delete_role`` implementations so a
re-seeded role_id (deterministic for builtins on schema reseed) can't
silently inherit stale overrides from the prior occupant.

Tests
- storage: ``test_users_with_permission_bulk`` exercises the new bulk
  helper including ``exclude_role_id`` and overlay folding
- storage: ``test_delete_role_cleans_up_overrides`` pins the F-7 fix
- endpoint: ``test_overrides_lockout_guard_blocks_grant_removal`` is
  the F-1 reproduction — operator-overlay grants admin.roles, builtin-
  admin has it removed, attempting to reset operator's overrides 409s
- endpoint: ``test_assign_role_blocks_escalation_via_overlay_grant``
  pins the F-2 reframed fix — overlay-poisoned operator can't be
  assigned by a caller missing the overlay perms

* refactor(rbac): cleanup batch from /review (#584)

Five non-security findings folded into one commit so the security
batch stays focused.  All consistent with the existing intent of
``feat/builtin-role-overrides``.

**F-4: presence check on ``_effectivePerms``.** ``governance.js`` was
guarding on ``Array.isArray(role.effective) && role.effective.length > 0``,
falling through to splitting ``role.permissions`` (the baseline) when
the array was empty.  For a builtin role whose overrides legitimately
revoke every baseline perm, that path silently rendered the baseline
chips with no override indicators — the inspector lied about what the
role can do.  ``_enrich_role`` always sets ``effective: []``, so
presence is the right sentinel.

**F-5: JS-side drift detector.**  Commit 1 added a Python-side test
asserting ``_VALID_PERMISSIONS`` covers every baseline perm; the
mirror invariant on the frontend went uncaught.  A new perm added to
``_VALID_PERMISSIONS`` without a matching entry in
``_PERMISSION_SECTIONS`` becomes silently un-customizable through the
admin UI (the only documented grant/revoke path).  Test parses the
JS const out via regex and asserts set-equality both directions —
detects "missing in UI" and "extra in UI" so the toggle catalog and
validator can't fork.

**F-6: bulk enrich for ``admin_list_roles``.**  Was ``1 +
2*builtin_count + 1*custom_count`` SELECTs per admin-tab open;
collapsed to one ``IN``-filtered query via new
``storage.effective_role_permissions_bulk(role_ids)``.  Implemented
on both sqlite + postgresql backends following the existing
``effective_role_permissions`` shape.

**F-8: rename ``fallback_permissions`` → ``accepted_permissions``.**
The lift body uses ``if cfg.permission_gate / elif accepted_permissions``
— mutually exclusive — so when ``permission_gate`` is None this IS
the primary gate, not a fallback to anything.  The "fallback" name
suggested a tier-2-after-tier-1 semantic that didn't exist.  Renamed
across ``make_{approve,close,create}_handler`` factories, the three
call sites in ``turnstone/server.py``, and the docstrings.

**F-9: positive lift-level tests for ``admin.coordinator``-only.**
``TestPermissionGatesOnLifecycle`` previously had a single positive
test for ``workstreams.create`` alone, plus negative-403 tests for
each verb without perms.  The OR-fallback to ``admin.coordinator``
(which keeps coord sessions spawning interactive children unblocked)
had no positive coverage at the lift code path — only at the proxy,
which exercises a different verb-dict gate.  Added three tests
(create / close / approve) that pass ``admin.coordinator`` alone and
assert non-403, so a future tightening of the accepted_permissions
tuple can't silently regress coord-driven child workstreams.

Out of scope: nit perf-4 (event-delegation refactor on
``_renderGovRoles``).  ``setSafeHtml`` rebuild is the existing
pattern across every admin tab; rewriting one tab's render path on
this branch would be drive-by inconsistent with the surrounding
codebase.  Filed as a separate concern if the Roles tab grows past
the scale where it bites.

* fix(rbac-ui): aria-expanded + row-click on Roles drawer (#585)

Two Copilot review findings on governance.js:

- Expand button was missing aria-expanded — screen readers couldn't
  announce drawer state.  Now reflects the row's expanded flag.
- Comment said "row + chevron both work" but only the chevron was
  wired.  Added data-expand-role to the row element too so the
  existing handler loop (querySelectorAll on the attribute) picks up
  both — clicking anywhere in the role row toggles the drawer.
  Edit/Delete handlers already stopPropagation so they aren't
  triggered by the row-level click.

* fix(migrations): rebase role_permission_overrides to 058

PR #560 mitigation #1 landed 057_output_assessments_llm_judge.py on
main in parallel; my migration claimed the same number, forking
alembic's head and breaking postgres.  Renumbered to 058 and
re-pointed down_revision at 057 so the chain stays linear.

No behaviour change — same DDL.  Full sweep clean (6730 passed).

* fix(migrations): update 058 revision strings to match filename

Previous commit (ea86aefc) renamed 057_role_permission_overrides.py to
058_* but the in-file revision = "057" / down_revision = "056"
strings stayed — leftover from when the file shipped as 057.  Tests
pass because alembic walks the chain by revision string, and the
strings now correctly read revision = "058" / down_revision = "057"
to make the chain linear with main's 057_output_assessments_llm_judge.

Caught locally before re-running CI; my prior `git mv` + content edit
landed as a staged rename + unstaged modification on the previous
push.
2026-05-24 18:31:23 -07:00
Patrick Buckley f94db6e5a9 fix(model_registry): tuple-of-ints version sort in _select_best_model
`float("4.20") == 4.2`, so the old version-sort routed `grok-4.20`
under `grok-4.3` despite 4.20 being the newer dated-snapshot line.
Parsing each component as an int via `_version_tuple` makes
`(4, 20) > (4, 3)` as intended.

Applied symmetrically to the openai branch — same shape, same latent
bug against a future `gpt-5.10` vs `gpt-5.2` collision.

Locked in by four tests in `test_provider_xai.py::TestSelectBestModel`.

Spotted by Copilot review on #586.
2026-05-24 18:26:54 -07:00
Patrick Buckley f4f7944832 feat(providers): xAI/Grok provider via OpenAIResponsesProvider subclass
Adds xAI as a first-class commercial provider through the officially-
documented server-to-server API-key path against https://api.x.ai/v1.

XAIProvider is a thin subclass of OpenAIResponsesProvider; xAI's
Responses surface is OpenAI-shaped, so the only override needed is
_build_kwargs, which merges <tool>_call_output strings into include[]
so xAI's server-side tool outputs (hidden by default) become visible.
GROK_CAPABILITIES covers the five documented chat models; aliases
like grok-4.3-latest resolve via the existing longest-prefix lookup.

Two narrow base-class generalisations earn their keep beyond Grok:

- ModelCapabilities.server_side_tools: tuple[str, ...] drives the
  Responses-surface tool injection (previously hardcoded to
  web_search).  resolve_server_side_tools folds in the legacy
  supports_web_search boolean for backward compat.
- extra_headers: dict[str, str] | None threaded through
  LLMProvider.create_streaming / create_completion so callers can
  pass x-grok-conv-id: <ws_id> for prompt-cache hit-rate.  Session-
  side population is a follow-up; the plumbing lands here.

Out of scope:

- OAuth (SuperGrok / X-Premium+) — not officially documented.
- Chat Completions surface — deprecated on xAI's comparison page.
- Image / voice / video models.
- argparse --provider xai in cli.py / server.py — Google isn't
  there either; both providers configure via config.toml.

Closes #583.
2026-05-24 18:26:54 -07:00
Patrick Buckley 59ccbcddd8 fix(judge): address Copilot review on #579
Validated each of the 7 substantive Copilot findings + 5 CodeQL
findings via source spike; applied 7 (CP2-CP7, C3-C5), refuted 2
(CP1, C1+C2) with citation.

* CP2 (session.py:3551) — pre-truncation budget was reused per
  output, allowing N parallel tool results to each claim the full
  remaining context budget and collectively overflow.  Maintain a
  running budget that shrinks as each output is sized.

* CP3 (ratelimit.py) — TokenBucket.consume() mutated tokens /
  last_refill without a lock; ChatSession._batch_evaluate_outputs
  invokes it concurrently from up to 4 worker threads, so the rate
  limiter's stated 60-call/min cap was best-effort.  Added an
  internal threading.Lock that protects consume() and retry_after.
  RateLimiter's outer lock still holds for the bucket-dict it owns;
  the new lock just makes the class safe-by-default for direct
  consumers.

* CP4 (session_ui_base.py:1642) — docstring claimed "llm" rows
  were only persisted on success; the session actually persists a
  failure row too (with reasoning=error_reason) for audit.  Match
  the docstring to behavior.

* CP5 (migration 057) — header said "Revises: 055" but
  down_revision was "056".  Fix header.

* CP6 (_protocol.py:1761) — :func:`intent_verdicts` is a table,
  not a callable.  Plain reference.

* CP7 (test_output_guard_judge.py:271) — test_client_created_once
  was self-contradictory: header comment claimed caching was being
  verified, body asserted the patched fake was called once per
  evaluate (no caching).  The next test
  (test_real_lazy_init_caches_real_client) covers actual caching;
  drop the misleading one.

* C3-C5 (_extract_json) — three empty except-pass blocks now carry
  a justifying one-liner explaining each strategy's expected failure
  mode and what falls through.

REFUTED:

* CP1 (output_guard_judge.py:453 catch of TimeoutError vs
  concurrent.futures.TimeoutError) — in Python 3.11+ (our minimum,
  per pyproject.toml requires-python = ">=3.11") the two are the
  same class.  Verified at runtime: ``cf.TimeoutError is
  TimeoutError`` is True; MRO is (TimeoutError, OSError, Exception,
  BaseException, object).  The polling loop catches the right
  exception.

* C1/C2 (Protocol method bodies and test stubs using ``...``) —
  `...` is the idiomatic Python pattern for Protocol method bodies
  and one-line stub functions; the surrounding code in
  test_model_registry.py uses ``...`` consistently across all stub
  methods.  CodeQL is flagging a single instance while ignoring
  identical patterns nearby.
2026-05-24 17:49:27 -07:00
Patrick Buckley 3233719856 feat(judge): output_guard LLM stage with capability gate (#560 mitigation #1)
Adds a second, LLM-driven stage to the output guard so domain-camouflaged
prompt-injection payloads that the regex stage misses (arXiv:2605.22001 —
Llama 3.1 8B evades the existing regex set on ~90% of camouflaged
prompts) get caught before the tool output lands in the assistant's
context.

## Surface

* New `OutputGuardJudge` in `turnstone/core/output_guard_judge.py` —
  synchronous, single-shot LLM call.  Inlines the alias-resolution +
  client-config + JSON-parsing helpers (copied verbatim from
  `IntentJudge` at `judge.py:917-969` / `1604-1659`) rather than going
  through a shared module — when `IntentJudge` lifts its own helpers,
  both copies move together.

* JSON-in-content verdict with a 3-strategy parser (direct / markdown
  fence / balanced braces).  `IntentJudge` ships a 4th regex-field
  fallback; OutputGuardJudge deliberately doesn't, because strategy-4
  hits on broken LLM output can extract a "verdict" from the model's
  reasoning quote that lands in storage looking identical to a clean
  strategy-1 result.  Failure of all three returns
  `error="unparseable_verdict"` and the heuristic stage stands.

* `OutputJudgeVerdict` is a frozen dataclass with:
  `risk_level` (none/low/medium/high — normalises `critical`→`high`
  and `info[rmational]`→`low` for IntentJudge-echo safety),
  `flags: tuple[str, ...]`, `reasoning`, `confidence: float`
  (0.0-1.0, parsed + clamped from the LLM's self-report;
  pass-through to audit, no threshold gating), `judge_model`,
  `latency_ms`, `error`.

* Real wall-clock timeout via `ThreadPoolExecutor.shutdown(wait=False,
  cancel_futures=True)` on the timeout/cancel path — `with ... as ex:`
  would block return until the worker drained.  1s `cancel_event`
  poll mirrors `IntentJudge._run_judge` at `judge.py:1117-1118`.

* HTTP client lazy-init + reuse for the judge instance's lifetime.
  Session-side model swap drops the entire judge, dropping the client
  with it.

* Untrusted tool output wrapped in per-call random-nonced
  `<tool_output_NONCE>...</tool_output_NONCE>` fence.  Closing-tag
  substrings in the raw text are case-insensitively backslash-escaped
  first (`</tool_output` → `<\/tool_output`) so an attacker can't
  break out even if they guess the nonce.  System prompt classifies
  the fenced region as UNTRUSTED DATA so directives inside are
  evaluated as content, not obeyed.

* Judge user prompt carries the heuristic verdict (risk + flags +
  annotations), the tool description (looked up from the session's
  tools registry), and the tool args (truncated to 500 chars, also
  classified UNTRUSTED in the system prompt since they may be
  caller-supplied).  Lets the judge defer to the regex on credential
  leaks and focus on injection signals the regex set misses; also
  enables output-vs-request plausibility reasoning.

## Session integration

* `_evaluate_output(call_id, output, func_name, *, tool_args="")` —
  heuristic always runs; LLM stage runs when `judge.output_guard_llm`
  is enabled.  When the LLM produces a usable verdict and the
  heuristic didn't detect credentials, the LLM verdict is acted on;
  otherwise the heuristic stands.

* Credential redaction is a regex-only signal.  When `heuristic.
  sanitized` is non-None, the heuristic owns the acted assessment
  regardless of what the LLM said — an LLM asked about prompt-
  injection can correctly label a credential-bearing output as
  "none" risk for injection, but the secret still needs redaction.

* `_batch_evaluate_outputs` runs the per-tool guard concurrently
  (4-worker pool) when LLM is enabled and there are ≥2 string
  outputs — collapses N×LLM-latency to ⌈N/4⌉×latency on the common
  5-20 tool-calls-per-turn turn.

* Per-session `TokenBucket(rate=1.0, burst=60)` caps adversarial
  LLM-fan-out cost at 60 calls/min/session.

* Pre-truncation: the per-tool loop truncates output before the
  judge sees it, so the judge evaluates exactly what enters the
  assistant's context (no wasted tokens on text that won't land).

* Both heuristic and LLM tier rows persisted to `output_assessments`
  when the LLM ran (audit completeness); heuristic-only rows skip
  when matched-clean to keep the table focused.

## Storage

Migration 057 extends `output_assessments` with five LLM-tier
columns: `tier` (`heuristic` / `llm`, backfilled to `heuristic`),
`reasoning`, `judge_model`, `latency_ms`, `confidence`.  Tie-break
on `(created DESC, tier='llm' first)` so downstream consumers see
the acted verdict first when the two rows tie at second resolution.

`StorageBackend.record_output_assessment` + sqlite/pg implementations
+ `SessionUIBase.record_output_assessment` + `SessionUI` protocol +
the test stub overrides (cli, eval, 9 test files) all take the new
LLM-tier kwargs.

## Config surface

Three new judge.* settings in `settings_registry`:

* `judge.output_guard_llm` (bool, default False) — capability gate.
  Default off; operators opt in once a small/fast model is pointed
  at `output_guard_model`.

* `judge.output_guard_model` (str, default "") — alias for the LLM
  stage.  Empty inherits the session model (same fallback shape as
  `judge.model`).

* `judge.output_guard_llm_timeout` (float, default 30.0, min 1.0) —
  wall-clock budget per call.

Both `server.py` and `console/session_factory.py` wire these into
the `JudgeConfig` they hand to `ChatSession`.

## Notes

* No backwards-compatibility shims — the LLM stage is purely additive.

* No reasoning/threshold gating on confidence; it rides as an
  audit-only signal per maintainer direction.  Surface it in the
  `on_output_warning` dict so live UI / cluster broadcast can sort
  flagged outputs by judge certainty.

* Tests: 392 lines of judge-only coverage (`test_output_guard_judge.
  py`) + 629 lines of session-integration coverage in `test_session.
  py`, plus the storage and stub-shape updates.
2026-05-24 17:49:27 -07:00
Patrick Buckley 06e16de066 chore(skills): drop Anthropic attribution from SKILL.md spec references
Two related cleanups landed together because they touch the same surface
(skill-spec uplift PRs #569/#570/#571/#572):

  1. Wording: replace "Anthropic spec" / "Anthropic Claude Code skill spec"
     with "SKILL.md spec" across admin UI tooltips, code comments, test
     docstrings, migration 056's module docstring, and the user-facing
     `arguments` description in tools/skills.json.  Renames a parser test
     `test_anthropic_tags` -> `test_nested_metadata_tags` and consolidates
     a parse-API test of the same shape; fixture author renamed
     `Anthropic` -> `Acme` to keep the fixture neutral.  Legitimate
     provider/SDK/API references (provider name, api.anthropic.com,
     `_anthropic.py`, capability comments) are intentionally untouched.

  2. Admin UX: in the Create + Edit Skill modals, six fields per modal
     (Compatibility, Paths, Hide-from-skill-picker, Arguments, Argument
     hint, Activation) had long uppercase label-hint spans crammed into
     the visible label.  Migrated each to the existing
     `.settings-help-btn` + `.settings-help-popover` pattern already used
     in the Settings tab — short label + inline `?` button that opens a
     styled popover with proper `<code>` formatting for technical tokens.

     Pattern reuse required two small generalisations in admin.js:

       * `_toggleSettingsHelp` now looks up the popover via a new
         `data-help-target="<id>"` attribute first, falling back to the
         settings-tab `.settings-label-col` ancestor lookup.
       * `_closeAllSettingsHelp` mirrors the same dual-path lookup when
         resetting `aria-expanded`, so modal buttons don't get stuck on
         `aria-expanded="true"` after another popover opens.
       * Added a document-delegated click handler that fires only for
         buttons with `data-help-target`; existing per-button binding
         in the settings-tab render path is unchanged.

     CSS: `.settings-help-btn` now paints its `?` via `::after` with the
     button's own `font-size: 0`, so prettier-introduced whitespace
     inside the new HTML buttons can't off-center the glyph.  The same
     rule applies to existing admin.js-generated buttons (text content
     hidden, pseudo identical).  Small additions for
     `.settings-help-popover code` / `strong` styling so technical
     tokens render with the same monospace pill treatment used elsewhere
     in skill UI.

Known follow-ups (intentionally NOT in this PR):
  * Migrate the settings-tab `_renderSettingRow` button assembly to the
    empty-`<button>` + `data-help-target` form so the per-button
    addEventListener loop can be dropped in favour of pure document
    delegation, and the `font-size: 0` rule stops being a workaround for
    two markup styles.
  * The 12 new popover blocks are duplicated verbatim between the
    Create and Edit modals (same as the rest of the create/edit modal
    pair).  A small renderer that emits popovers from a shared data
    object would eliminate the drift risk but is unrelated cleanup.
2026-05-24 14:56:43 -07:00
Patrick Buckley e1c2a05467 fix(coord): strip intent-judge verdicts from inspect_workstream output (#580)
* fix(coord): strip intent-judge verdicts from inspect_workstream output

Coordinator LLMs repeatedly misread `user_decision="policy"` (the label
meaning "auto-approved by an admin policy allow rule") as "blocked,
waiting for policy review" — combined with `recommendation="review"`
(the heuristic judge's risk class, not a workflow state) the verdict
fields read end-to-end as "stuck on policy review" and produced
incorrect cancel-and-respawn reasoning against healthy children.

The blocking signal already lives on `state` (`"attention"`) and the
`live.pending_approval` block, both still in the result. Verdict
history remains queryable through admin / audit surfaces — only the
LLM-facing inspect surface drops them.

Also drops `verdict_count` / `verdicts_by_risk` from the tier-3
skeleton fallback, deletes the now-dead `_serialize_verdicts` helper,
and clears the now-stale `"verdicts": []` keys from 10 fixture sites
that fed `_format_inspect_tiered` test cases.

* fix(coord): correct comment pointer — inline comment, not docstring
2026-05-23 19:23:58 -07:00
Patrick Buckley aa9812f2a6 chore: bump version to 1.6.0a4 2026-05-23 18:11:24 -07:00
Patrick Buckley 9309162ac0 feat(skills): wire \$ARGUMENTS / \$N / \$<name> / \${CLAUDE_*} substitution (#572)
Implements the Anthropic Claude Code skill spec's placeholder
substitution end to end.  The renderer in ``_substitute_skill_args``
handles every spec form except ``\${CLAUDE_SKILL_DIR}`` (deferred):

* ``\$ARGUMENTS`` — full args string as the user/model typed it
* ``\$ARGUMENTS[N]`` / ``\$N`` — Nth positional arg, ``shlex.split``-parsed
* ``\$<name>`` — named arg from the SKILL.md ``arguments:`` list
* ``\${CLAUDE_SESSION_ID}`` / ``\${CLAUDE_EFFORT}`` — session state

Substitution is single-pass (one combined regex, one ``re.sub``).
Append rule: when args are passed but the body has no bare
``\$ARGUMENTS``, append ``ARGUMENTS: …`` at the end.

## Surface

* Parser: ``arguments:`` (list/space-delim) + ``argument-hint:`` (str)
  extracted into ``ParsedSkill``.
* Install: persists both to the pre-allocated columns from migration
  056 (PR #574).  Install path clamps ``argument_hint`` to 128 chars
  to match the admin-create cap (untrusted upstream source).
* Admin: ``CreateSkillRequest`` / ``UpdateSkillRequest`` accept both
  fields; create + edit modals get inputs; parse-preview echoes.
* Renderer: ``_substitute_skill_args`` runs AFTER ``_render_template``
  in ``_load_skills`` so user-supplied args containing ``{{var}}`` can't
  be re-expanded by the legacy renderer.
* Session: ``_skill_arguments`` plumbed through ``__init__``,
  ``set_skill``, and ``_save_config`` so a resumed workstream re-renders
  with the original arg payload.
* Model tool: ``skills(action='load')`` accepts an ``arguments`` string.
  Approval label includes a SHA-256 digest of the args so a once-
  approved skill name can't grant cover for a future payload; preview
  surfaces the args inline.

## ``/review`` findings (addressed)

* ``\${CLAUDE_EFFORT}`` referenced ``self._reasoning_effort`` — wrong
  attribute; the real one is ``self.reasoning_effort``.  Always rendered
  empty.  Fixed.
* Two-pass layering let user args containing ``{{var}}`` re-expand.
  Render order reversed.
* ``_skill_arguments`` wasn't in ``_save_config`` — resumed workstreams
  silently lost their payload.  Added.
* Approval label omitted ``arguments``.  Digest + preview added.
* Install path didn't bound ``argument_hint``.  Clamped.
* Added ``_skill_arg_names`` decode tests + "load same skill,
  different args → re-render" invariant test.

## Copilot review findings (addressed)

* ``skills.json`` tool description was inaccurate about ``shlex``
  stripping quotes and "empty string disables substitution".  Rewrote
  to match actual behaviour.
* Named-argument regex was stricter than parser/storage contract.
  ``arguments: [issue-number]`` would partial-match ``\$issue-number``
  as ``\$issue``, leaving ``-number`` as stray text.  Broadened the
  regex to ``[A-Za-z_][A-Za-z0-9_]*`` AND added validation at
  ``_skill_arg_names`` decode time so names not matching the regex
  are dropped with a warning.

## Tests

* ``tests/test_substitute_skill_args.py`` — placeholder forms,
  single-pass guarantee, append-at-end rule, shell-quoted input,
  unbalanced-quote fallback, uppercase + underscore-prefix names
* ``tests/test_skill_parser.py::TestArgumentsAndHint`` — parser
  extraction
* ``tests/test_skill_parse_api.py`` — HTTP parse-preview echoes
  both fields
* ``tests/test_skill_discovery_api.py::test_install_seeds_arguments_and_argument_hint``
  — install round-trip
* ``tests/test_skills_tool.py::test_load_forwards_arguments_to_set_skill``
  + ``test_load_same_skill_different_args_triggers_resub`` —
  wire path through prepare → exec → set_skill
* ``tests/test_skills_tool.py::TestSkillArgNames`` — storage decode
  helper including the hyphen/dot/leading-digit filter
2026-05-23 17:48:16 -07:00
Patrick Buckley 66400999dd fix(skills): address Copilot review on #577
Two findings from Copilot's review of PR #577:

* ``_extract_bool`` int branch: Copilot flagged that ``bool(raw)``
  treats any non-zero int as True, so ``disable-model-invocation: 2``
  silently disables model invocation without warning the author about
  the typo.  Tightened to accept only ``0`` and ``1`` as integer
  boolean forms — anything else falls back to *default*.  Matches
  spec (which mentions only 0/1) and the broader principle that
  ambiguous input should not coerce silently.

* ``hidden_from_menu`` admin body parse: Copilot flagged that
  ``bool(body.get("hidden_from_menu", False))`` treats non-empty
  strings via Python truthiness, so a malformed client sending
  ``"false"`` would flip the flag to ``True`` — opposite to obvious
  intent.  Extracted a ``_parse_strict_bool`` helper that accepts
  only Python ``bool`` or int ``0``/``1`` and returns a 400 on
  anything else.  Applied at both admin create and admin update
  sites; the install path remains untouched because it derives the
  flag from the typed ``ParsedSkill.user_invocable`` field (not raw
  HTTP body).

## Tests

* ``test_other_ints_fall_back_to_default`` — ``2`` and ``-1`` no
  longer silently coerce
* ``test_create_skill_hidden_from_menu_string_rejected`` — string
  ``"false"`` returns 400
* ``test_create_skill_hidden_from_menu_int_zero_and_one_accepted``
  — 0 / 1 accepted, 2 rejected with 400

No behaviour change to the main surface — both fixes close latent
type-coerce hazards a malformed input could have exploited.
2026-05-23 17:27:36 -07:00
Patrick Buckley 6d28afbe7a feat(skills): wire disable-model-invocation / user-invocable (#571)
The Anthropic Claude Code skill spec defines two invocation-control
axes Turnstone was parsing but not consuming:

* ``disable-model-invocation: true`` — model can't autoload this skill
  (only user can invoke by name).  Stored on ``ParsedSkill`` and
  echoed on the parse-preview UI; no install consumer because
  Turnstone hardcodes ``activation="named"`` on source-installs
  already.  The dataclass docstring spells out the no-op so a future
  reader doesn't try to wire a translation that's already implicit.
* ``user-invocable: false`` — skill stays available to the model but
  disappears from the user-facing picker.  Mapped to
  ``hidden_from_menu=true`` on ``prompt_templates`` (column
  pre-allocated by PR #574); consumed by ``list_skills_summary``
  (both the standalone-server and console-server impls).

## Surface

* Parser: new ``_extract_bool`` helper accepts every YAML 1.1
  boolean spelling (true/false/yes/no/on/off/1/0) plus their quoted
  variants — caught by ``/review`` as a real gap, since YAML's
  ``safe_load`` returns ``int`` for unquoted ``1``/``0`` and ``str``
  for the YAML 1.1 spellings when quoted.
* Install handler: derives ``hidden_from_menu`` from
  ``parsed.user_invocable`` on the source-install path.
* Admin: ``CreateSkillRequest`` / ``UpdateSkillRequest`` accept
  ``hidden_from_menu``; both modals get a checkbox; the parse-preview
  auto-fill flips it when the source SKILL.md sets
  ``user-invocable: false``.
* Runtime config: ``hidden_from_menu`` joined
  ``SKILL_RUNTIME_CONFIG_FIELDS`` so admin can override on installed
  (readonly) skills — same precedent as ``model`` / ``effort``.

## list_skills_summary shared helper

Two identical implementations of ``list_skills_summary`` had
accreted in ``turnstone/server.py`` and ``turnstone/console/server.py``.
Both needed the new ``hidden_from_menu`` filter, so extracted the
shared body to ``turnstone/core/web_helpers.skill_summary_rows``.
Future spec-uplift fields (e.g. #572's ``argument_hint`` for
autocomplete) only touch one place now.

## Tests

* ``TestInvocationControl`` — bool / quoted / YAML 1.1 / int variants
  across both fields
* ``test_install_user_invocable_false_sets_hidden_from_menu`` +
  default-unhidden case
* ``test_list_skills_summary_excludes_hidden_from_menu`` — picker
  filter, admin tab unaffected
* ``test_update_skill_readonly_hidden_from_menu_allowed`` — admin
  can hide/unhide installed skills via PUT (pins the runtime-config
  membership invariant)
* Existing parse-API fixture extended with both new fields plus
  default-case assertions
2026-05-23 17:27:36 -07:00
Patrick Buckley 4c8f5acd3e feat(skills): ingest when_to_use / model / effort from SKILL.md (#570)
The Anthropic Claude Code skill spec defines three frontmatter fields
the parser was previously dropping; this PR wires them through to the
existing storage shape so the SKILL.md author's intent survives the
import.

* ``when_to_use`` — concatenated into ``description`` at parse time
  with a ``\n\nWhen to use: `` separator.  Kept as its own field on
  ``ParsedSkill`` so the admin parse-preview UI can surface it
  separately.
* ``model`` — passed through to ``create_prompt_template(model=...)``
  on the source-install path, seeding the existing
  ``prompt_templates.model`` column.
* ``effort`` — same shape, translates to the existing
  ``reasoning_effort`` column at the install handler boundary.

Re-install short-circuits at the source_url dedup, so admin overrides
to either column survive an upstream re-install — covered by a new
``test_reinstall_preserves_admin_model_override`` test that pins the
load-bearing invariant.

## Description length cap

``_MAX_DESCRIPTION_LEN`` exported as ``MAX_SKILL_DESCRIPTION_LEN``
(public name) and raised from 1024 to 1536 to match the spec's
combined ``description`` + ``when_to_use`` listing budget.  All five
write surfaces now import the same constant rather than each carrying
their own magic number:

* ``skill_parser.MAX_SKILL_DESCRIPTION_LEN`` — parse-time cap
* ``console_schemas.CreateSkillRequest.description`` — Pydantic
* ``console_schemas.UpdateSkillRequest.description`` — Pydantic
* ``console/server.admin_create_skill`` — handler slice
* ``console/server.admin_update_skill`` — handler slice
* ``core/session._exec_skills_create`` — coordinator tool slice
* ``core/session._exec_skills_update`` — coordinator tool slice

The coordinator sites (last two) were the bug ``/review`` caught:
they still capped at 1024 after the rest of the surface bumped to
1536, so a model-issued ``skills(action='create')`` with a 1025-1536
char description would silently truncate.  Sharing the constant
closes that desync.

## when_to_use truncation guard

The ``when_to_use`` concat reserves room for the separator + at
least one character of the appended value; below that budget, the
addition is dropped entirely.  Previously the naive concat could
truncate mid-separator and leave the description ending in a
dangling ``\n\nWhen ``.

## Tests

* ``TestWhenToUse`` — concat semantics, no-description fallback,
  1536 truncation
* ``TestModelAndEffort`` — extraction + defaults
* ``test_install_seeds_model_and_effort_from_frontmatter`` — install
  path persists both columns
* ``test_install_no_model_or_effort_leaves_columns_empty`` — bare
  SKILL.md doesn't invent values
* ``test_reinstall_preserves_admin_model_override`` — admin edits
  survive an upstream re-install (dedup invariant)
* ``test_parses_full_frontmatter`` / ``test_parses_minimal_frontmatter``
  extended with the new field assertions
2026-05-23 15:11:56 -07:00
Patrick Buckley 21cf42904d fix(skills): address Copilot review on #574
Three findings from Copilot's review of PR #574:

* `update_prompt_template` (both backends) coerces every other
  INTEGER-as-bool field (`is_default`, `auto_approve`, `enabled`) but
  not `hidden_from_menu`.  Without coercion, a caller updating with
  ``hidden_from_menu=True`` writes a Python bool to a SQLAlchemy
  Integer column, which is driver-dependent on PostgreSQL and a
  consistency hazard.  Coerce to int alongside the existing trio.
  Added a focused round-trip test that updates with ``True`` /
  ``False`` and asserts the read-back bool transitions.
* `_canonicalize_skill_string_list` docstring describes its own
  ``None``-collapses-to-``"[]"`` rule but doesn't mention that
  `admin_update_skill` intercepts ``None`` before the helper is
  called.  Added a note documenting the layered contract: the helper
  defines normalization (create semantics), the update endpoint
  layers no-op semantics on top.
* HTML label hints used Markdown-style ``paths:`` backticks inside
  plain HTML, which render as literal backticks in the browser.
  Replaced with `<code>paths:</code>` on both the create and edit
  modal Paths inputs.

No behaviour change to PR #574's main surface — the bool coercion
addresses a latent bug a future consumer would have hit; the
docstring + HTML fix are purely cosmetic.
2026-05-23 14:20:36 -07:00
Patrick Buckley 93af031fc7 feat(skills): parse and store Anthropic spec paths + uplift columns
Implements PR1 of issue #569 — parser + storage + admin UI for the
Anthropic Claude Code skill spec `paths:` SKILL.md frontmatter field
(glob patterns gating model-initiated autoload).  The autoload filter
that consumes `paths` is deferred to a follow-up PR pending the
workstream-CWD design discussion.

Migration 056 bundles three additional columns whose consumer PRs are
filed but not yet implemented:

* `hidden_from_menu` (boolean) — backs the spec's `user-invocable:
  false` (issue #571).
* `arguments` (JSON list) — backs spec `arguments:` named arg slots
  (issue #572).
* `argument_hint` (string) — autocomplete display string (issue #572).

The deferred columns surface in `SkillInfo` (response) so consumers can
read them, but are deliberately absent from `CreateSkillRequest` and
`UpdateSkillRequest` — the create/update handlers don't yet read them
and advertising a writable field the handler would silently ignore
would be an OpenAPI lie.

Surface
- Parser: `ParsedSkill.paths` populated from frontmatter; accepts the
  spec's YAML-list-or-CSV-string shape via the existing
  `_extract_list` machinery.
- Storage: 4 new columns on `prompt_templates`; `SKILL_MUTABLE`
  extended; `_row_to_dict` calls extended to cast the new bool;
  protocol + SQLite + PostgreSQL `create_prompt_template` signatures
  threaded.
- HTTP: admin create/update/install/parse handlers plumb `paths`
  through.  Pydantic schemas extended accordingly.
- Admin UI: `skill-paths` and `etm-paths` inputs on the create + edit
  modals; field map and read/write helpers wired across paste-parse,
  reset, create-send, edit-load, edit-send, and the readonly-disable
  list.

Notable
- `_canonicalize_skill_string_list` collapses the list-or-CSV-or-JSON-
  string normalization shared between admin_create_skill and
  admin_update_skill.  Treats `None` as no-value so a body containing
  `{"paths": null}` doesn't CSV-split through `str(None)` and store
  the literal `["None"]`.  Will back `arguments` once #572 wires its
  consumer.

Tests
- Parser: TestPaths covers YAML list, CSV string, empty, full-
  frontmatter integration (tests/test_skill_parser.py).
- Storage: round-trip suite covers create + read + update for each
  of the four new columns on both backends
  (tests/test_storage_skill_spec_uplift.py).
- Helper: focused unit tests for the canonicalizer including the
  regression-net case for the null-corruption bug
  (tests/test_canonicalize_skill_string_list.py).
- HTTP boundary: extended test_parses_full_frontmatter +
  test_parses_minimal_frontmatter to assert `paths` survives the
  admin parse endpoint.
2026-05-23 14:20:36 -07:00
Patrick Buckley bf14fc8b45 fix(output_guard): harden against domain-camouflaged injection (#560) (#573)
* fix(output_guard): harden against domain-camouflaged injection (#560)

Three layered mitigations against the camouflage attack class described in
arXiv:2605.22001 (Pai, May 2026), which demonstrates 90.3% evasion on Llama
3.1 8B and 44.4% on Gemini 2.0 Flash against pattern-based detectors:

- Sub-agent synthesis is now scanned by output_guard at the sub-agent
  boundary in _run_agent, in addition to the existing scan at the parent's
  tool-result loop.  Covers all four return paths (clean exit, truncation,
  context-limit recovery, turn-limit forced synthesis), closing the
  cross-workstream summary laundering surface.

- Adds pair-of-signals camouflage detection: imperative recommendation
  phrase combined with either an authority frame ("consistent with our
  risk framework") or a caps action verb (SELL/BUY/TRANSFER/...).  New
  flag camouflaged_injection at medium risk; deliberately partial — the
  paper's augmented-detector approach recovers only ~10% on Llama-class
  models, so this is duct-tape pending a semantic-evaluator follow-up.

- Bumps output_guard's wall-clock budget default from 5s to 30s and
  exposes it as judge.output_guard_budget_seconds in ConfigStore, so the
  expanded regex set has headroom on large tool outputs.

* Fix test_budget_kwarg_is_honored to exercise deadline logic path

The test previously passed an empty string which short-circuited
evaluate_output() before budget_seconds was used. Now uses a non-empty
input and monkeypatches time.monotonic() to deterministically verify
the deadline path is exercised.
2026-05-23 13:53:32 -07:00
Patrick Buckley ad16de001d fix(renderer): drop single-$ inline math to stop false positives in prose
Single-$ inline math is too ambiguous in conversational text: currency
amounts ("$5 and $10 each"), shell variables ("$HOME and $PATH"), and
shell prompts all produced false-positive KaTeX spans because the
regex matched any non-$/non-newline span between two dollar signs.

Inline math now requires the unambiguous \(...\) form, which is what
GPT-5 / o-series / Claude with reasoning effort emit by default anyway.
Display math ($$...$$ and \[...\]) is unchanged — the doubled
delimiter has enough mass that ambiguity is not a practical problem.

Three former positive tests are inverted into regression guards so a
future regex change can't quietly resurrect the bug, and new tests
name the currency and env-var cases explicitly. The web env prompt is
updated to advertise \(...\) and to tell the model why $...$ is gone.
2026-05-23 11:43:14 -07:00
github-actions[bot] 7404ae46db chore: download vendored JS files 2026-05-23 11:33:36 -07:00
renovate[bot] 747ee99220 chore(deps): update dependency katex to v0.17.0 2026-05-23 11:33:36 -07:00
renovate[bot] 291edd005c chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.16 2026-05-23 11:22:55 -07:00
renovate[bot] ce30df2e97 chore(deps): lock file maintenance 2026-05-23 10:34:11 -07:00
renovate[bot] 7ab78e4edf chore(deps): update github actions 2026-05-23 10:32:37 -07:00
renovate[bot] 1a4cbb90a8 chore(deps): update dependency vitest to v4.1.7 (#564)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 12:53:06 +00:00
Patrick Buckley cdba6dea7a chore: bump version to 1.6.0a3 2026-05-22 20:05:29 -07:00
Patrick Buckley 830305555d fix(sse): address PR #561 review round 2
5 follow-up comments from Copilot, all valid:

1. **CRITICAL — snap_seq race with split writer** (concurrency, 001).
   Round-1's fix lifted snapshot capture into
   register_listener_with_replay under nested locks, but the
   WRITER side (on_content_token / on_reasoning_token) still
   released _ws_lock before calling _enqueue (which bumps
   _event_id under _listeners_lock).  A reader could
   interleave between writer's release and writer's _enqueue:
   capture inflight WITH the new text, read STALE _event_id,
   return snap_seq < new_event_id.  The new event's live emit
   then has _seq > snap_seq, slips past the dedup filter, and
   double-renders text the snapshot already contained.

   Fix: move self._enqueue(...) INSIDE the with self._ws_lock:
   block in both token writers.  The inflight mutation and the
   _event_id advancement are now atomic against any snapshot
   reader.  Lock order _ws_lock (outer) → _listeners_lock
   (inner via _enqueue) matches the snapshot helpers, so no
   deadlock.  Fan-out's put_nowait calls happen under
   _ws_lock for token writers — microsecond cost per listener,
   acceptable for the correctness guarantee.

2. **NIT — stale comment ref to buffered[-1]._event_id** (docs, 002).
   The comment referenced a local var (buffered) that lives in
   register_listener_with_replay, not in the events handler.
   Reworded to describe the cutoff in terms of the last replayed
   event id and the atomic-against-writers registration.

3. **MODERATE — 401 branch leaves reconnect loop** (bug, 003).
   The coord's onerror schedules a 5 s CLOSED-state recovery timer
   unconditionally.  In the 401-expired-session branch we close
   evtSource and showLogin — but the timer still fires 5 s later,
   observes !evtSource, and calls scheduleReconnect(),
   which opens a new EventSource that 401s again → infinite
   reconnect loop while the login overlay is up.  Fix: cancel
   reconnectTimer in the 401 branch.

4. **MODERATE — race test was vacuous** (test_coverage, 004).
   The previous regression test drained the listener queue after
   register_listener_with_replay returned, but the helper
   doesn't backfill buffered events into the queue, so the loop
   was almost always a no-op and the assertion never executed.
   Rewrote with a monkey-patched _enqueue that sleeps 50 ms
   before bumping _event_id — widens the race window
   deterministically.  Verified: the test FAILS on pre-fix code
   (snap.content has marker but snap.seq=0 < final_event_id=1)
   and PASSES on post-fix code (writer holds _ws_lock through
   _enqueue, so the reader blocks until writer fully done).
   Also pinned the no-backfill contract so a future change adding
   listener-queue backfill remembers to keep snap_seq the
   high-water mark.

5. **NIT — except Exception too broad in test** (best_practices, 005).
   Tightened except Exception: to except queue.Empty: so
   unexpected exceptions aren't silently swallowed in the drain
   loop.

Tests:
- 86 tests in test_sse_reconnect_replay.py + test_session_ui_base.py
  pass (existing 84 + 2 new race regressions).
- Full non-live suite: 6347 passed, 15 skipped, no regressions.
- Ruff + mypy clean on changed .py files; JS parses.
2026-05-22 19:34:52 -07:00
Patrick Buckley 5b449e502d fix(sse): address PR #542 review findings
Four issues raised on the merged PR #542, evaluated and fixed:

1. **Truncated-path snap_seq bug (Copilot low-confidence, VALID).**
   make_events_handler's truncated branch set snap_seq = 0,
   disabling the live-drain _seq <= snap_seq dedup filter.  Any
   token writer racing between register_listener_with_replay
   returning and the live drain's first read would land in BOTH the
   listener queue AND the captured snapshot text (the snapshot is
   emitted via in_progress_snapshot as the recovery floor), so
   the client double-renders.  Fix lifts the snapshot capture INTO
   register_listener_with_replay under the same nested-lock
   acquire as the listener registration + buffer slice + counter
   read, so the returned snapshot["seq"] is the exact
   high-water mark the snapshot text corresponds to.  Handler now
   uses snapshot["seq"] as snap_seq on truncated, dropping
   any token event with _seq <= snap_seq from the live emit.

2. **Lock-held string join in truncated path (Copilot, VALID).**
   "".join(ui_base._ws_inflight_content) ran inside the
   with ui_base._ws_lock: block, holding the lock for the
   duration of the join and blocking on-token writers.  Fix (folded
   into #1's refactor): the new register_listener_with_replay
   copies the inflight lists under lock and joins outside, matching
   the existing pattern in
   register_listener_with_in_progress_snapshot.

3. **_strip_js_comments docstring misclaim (Copilot, VALID).**
   Docstring claimed the helper preserves "string/regex literals"
   but the implementation only tracks string delimiters.  Fix:
   docstring updated to call out the regex-literal limitation
   explicitly + note that current callers don't scan regions
   containing regex literals.  Extending the tracker is left for
   a future caller that needs it.

4. **Coord scheduleReconnect dead-code regression (Copilot, VALID).**
   After the PR-D refactor, scheduleReconnect() had no remaining
   call sites — which meant reconnectAttempts never incremented,
   wasReconnecting was always false, AND there was no
   fallback when the browser transitioned the source to CLOSED
   (hard 4xx after retries, intermediary tearing the connection
   down with prejudice, etc.).  The first failure mode silently
   broke the post-gap replace-mode refresh of children / tasks /
   wait indicator / live-badge cache; the second left the coord
   permanently disconnected on non-transient failures.  Fix:
   - Introduce disconnectedSinceLastOpen flag set in onerror,
     cleared in onopen.  wasReconnecting reads it (with the
     legacy reconnectAttempts > 0 fallback for the
     scheduleReconnect-driven case), so the post-gap refresh fires
     after every reconnect including the common native-reconnect
     path.
   - Re-introduce CLOSED-state recovery: onerror schedules a 5 s
     delayed check via reconnectTimer; if the source is still
     CLOSED at that point, call scheduleReconnect(), which
     opens a new EventSource (threading the saved
     lastEventId via the URL query param so replay still works
     across the manual reconnect).  Cancel/replace successive
     timers so onerror floods don't pile up multiple checks for
     the same source.

Tests:
- New test_truncated_path_snapshot_captures_real_snap_seq pins
  the snap_seq fix at the helper boundary.
- New test_truncated_path_filters_already_in_snapshot_tokens
  pins the end-to-end dedup invariant — would have caught the
  double-render under the old code.
- Existing test_sse_reconnect_replay.py call sites updated for
  the new 6-tuple return of register_listener_with_replay.
- All 86 tests in those two files pass; full non-live suite (6347
  tests) passes; ruff + mypy clean on changed files; both JS files
  parse-check.
2026-05-22 19:34:52 -07:00
Patrick Buckley 83a602fef7 fix(skills): address /review on flatten — kind validation + stale text
Four /review findings collapsed to one code chokepoint + two
documentation fixes:

1. find's `kind` arg now validated against ``SkillKind`` (matching
   create / update's existing pattern at session.py:8298 / :8512).
   Closes two failure modes that shared the same root:
   - typos (`kind="interactivee"`) silently produced
     `kinds=["interactivee", "any"]` filtering to literal-`any` rows
     only and masquerading as a narrowed catalog — now returns an
     explicit "kind must be one of: ..." error;
   - the documented enum value `kind="any"` degenerated to
     `kinds=["any", "any"]` which narrowed to literal-`any` rows
     instead of returning "every kind" — now collapses to ``None``
     so the documented semantic holds.

2. docs/coordinator-skills.md "two-surface model" section rewritten
   to reflect the post-flatten reality: kind is metadata, not an
   enforcement boundary. The line-67 tools-table row updated from
   the long-dead `list_skills` to `skills (action=find)` with the
   opt-in kind-filter framing.

3. Three stale "interactive-only" comments in session.py
   (:5514, :7857, :8210) that directly contradicted the
   `_prepare_skills_load` docstring ("Both kinds can load") — drop
   the qualifier so future grep-and-encode hazards don't reintroduce
   the rejection.

Tests:
- test_find_kind_invalid_errors — typo case (replaces the silent
  degenerate to literal-any-only)
- test_find_kind_any_means_no_filter — documented enum value matches
  documented semantic (collapses to None at prepare)
- test_find_kind_narrow_passes_through — valid narrowing values
  reach exec as expected

Deferred to release notes (no code change, intentional policy shift):
- skills(action='get') / load can now read full content + scan_report +
  allowed_tools on cross-kind rows from any session. Operators with
  pre-existing kind=coordinator skills authored under the prior
  implicit visibility contract should audit those bodies for
  sensitive content (allowed_tools allowlists, embedded credentials,
  internal hostnames in examples) before upgrade.
2026-05-22 17:57:26 -07:00
Patrick Buckley 9126c2f368 refactor(skills): flatten SkillKind enforcement at tool / HTTP layer
Closes #557. SkillKind was authored audience metadata that the
discoverability filter dressed up as a runtime visibility gate. Real
access control is allowed_tools + auto_approve, which apply identically
across kinds. The kind-scoping chokepoints scaled linearly with every
new model-write surface for zero security payoff.

Drop kind consultation from:
- ChatSession._skills_kinds (deleted) and ._lookup_visible_skill
  (deleted; callers inlined to storage.get_prompt_template_by_name).
- _exec_skills_find: no longer auto-threads kinds=. The opt-in `kind`
  arg is a passable filter (threads [<kind>, "any"]) so the
  discoverability win survives without enforcement.
- _exec_skills_get / _exec_skills_load: row lookup is name-only.
  Disabled-row gate stays on load (admin quarantine is the actual
  boundary). _prepare_task already uses unscoped get_skill_by_name;
  session_routes.py already calls storage directly with no kind
  check. Both confirmed by the spike, no source change needed.
- tools/skills.json: drop "Coord sessions see / interactive sees"
  language; kind arg description re-cast as opt-in discoverability
  narrowing.
- storage Protocol docstring + console_schemas.py kind field
  description: refresh to reflect passive-metadata role.

Keep:
- SkillKind enum, kind column on prompt_templates, admin Skills tab
  editing, kind field in skills.find / skills.get projection. The
  field is useful for sorting/grouping at the model layer and as
  authored intent.
- storage.list_skills_filtered(kinds=...) parameter — admin-filter
  only now; docstring updated to note it's no longer auto-threaded
  from the model-tool path.

Design calls:
1. find accepts opt-in `kind` arg: YES. ~5 lines on prepare + exec.
   Threads kinds=[<kind>, "any"] only when supplied. Preserves the
   model's ability to narrow a browse without enforcing.
2. kind field stays in find/get projection: YES. Already pulled
   directly from the row dict in _skills_project_row (session.py
   line 8187); the projection survives the flatten unchanged.

Tests:
- Delete TestLookupVisibleSkill (helper gone), the two
  TestExecSkillsLoadKindScoping cross-kind reject branches, the two
  test_find_kind_scoping_* tests, and test_get_cross_kind_returns_not_found
  — the rejections those pinned are gone.
- Add test_find_default_threads_no_kind_filter (kinds=None by default
  for both session kinds), test_find_returns_all_kinds_for_session
  (interactive sees both interactive- and coord-tagged rows),
  test_find_filters_by_kind_when_supplied (opt-in narrowing works),
  test_get_returns_row_across_kinds (cross-kind get succeeds),
  test_load_works_across_kinds (cross-kind load succeeds in both
  directions — the flatten contract), test_load_rejects_missing_skill
  (missing-row hint coverage). Keep test_load_rejects_disabled_skill
  (admin quarantine still applies), test_load_works_for_coord_on_*
  (coord-side load still works on more kinds now).

Storage tests untouched: tests/test_storage_skills_filtered.py
keeps its kinds= coverage (the parameter still works, just no longer
auto-threaded from the model-tool path).

Closes review findings from PR #555 that motivated the rethink:
sec-1 (_prepare_task unscoped) moot, sec-2 (HTTP create unscoped)
moot, sec-3 (no audit on cross-kind probes) moot — there is no
cross-kind concept anymore.

Boundary spike (verified against fresh main at 9a98d07d):
- _skills_kinds defined at session.py:7933, callers exactly two:
  _lookup_visible_skill (7978) + _exec_skills_find (8057, 8109).
  Verified via grep across turnstone/.
- _lookup_visible_skill defined at session.py:7943, callers exactly
  two: _exec_skills_get (8213) + _exec_skills_load (8277). Verified
  via grep.
- _skills_project_row reads kind directly from the row dict
  (r.get("kind") or "any") at session.py:8187 — no helper call;
  projection survives flatten.
- _prepare_task at session.py:6353 calls unscoped get_skill_by_name
  — no kind check, no change needed.
- session_routes.py:1985 calls storage.get_prompt_template_by_name
  directly with no kind check — already flat.
- storage.list_skills_filtered(kinds=...) parameter is identical
  across _sqlite.py:2986, _postgresql.py:2827, and _protocol.py:1364.
- tests/test_skills_tool.py: TestLookupVisibleSkill (5 tests, lines
  481-536) and TestExecSkillsLoadKindScoping (5 tests, lines 539-639)
  pre-flatten. Total: 61 collected → 57 collected post-flatten.

Net production LOC: -15.
2026-05-22 17:57:26 -07:00
Patrick Buckley 821108310f fix(ui): drop local escapeHtml in renderer (Copilot review on #553)
The local-escape posture from the prior commit double-encoded values
that inlineMarkdown had already escaped: leading escapeHtml(text)
turns `&` into `&amp;`, the local escapeHtml(url) then turned that
into `&amp;amp;`, which breaks query-string URLs after browser parse
+ getAttribute + new URL round-trip.

Switch to convention-rename: regex callback params renamed to
safeAlt / safeUrl / safeLabel to signal the upstream-escape
invariant. The attribute-context lint enforces all future
attribute-context concat sites maintain the safe* convention or
call escapeHtml explicitly — defense-in-depth preserved without
the regression. Two added pin tests verify `&` survives with
single (not double) entity encoding through image data-src and
link href.

Also addresses two test issues from the same review:
- Docstring listed `safe[A-Z_]…` but code only checked isupper().
  Drop the underscore option (JS uses camelCase anyway).
- `_all_attr_names` only recorded attribute-bearing tags, so a
  bare `<script>` injection would have false-negatived the link-
  label pin test. Refactored to `_parse_renderer_html` returning
  both start tags and (tag, attr) pairs.
2026-05-22 17:56:28 -07:00
Patrick Buckley 849364c49e fix(ui): renderer local-escape + attribute-context CI lint (#553)
inlineMarkdown's image and link renderers now escapeHtml each
interpolated value (url, alt, label, domain) at the call site
instead of relying on the upstream escape pass. Defence-in-depth:
a future refactor calling those renderers from outside
inlineMarkdown would otherwise silently regress.

New CI lint scans renderer.js for `attr="' + ident` patterns; ident
must be escapeHtml(...), safe*, or in the reviewer-approved
allowlist. Four pin tests use html.parser.HTMLParser to verify
attacker URLs and labels don't materialize event-handler attributes
on rendered DOM.
2026-05-22 17:56:28 -07:00
Patrick Buckley 9a98d07d87 fix(skills): address /review findings 2/3/4 from PR 555
Three independent fixes flagged by Copilot's review on PR #555:

2. ``update`` auto_approve self-escalation warning false-positive
   (turnstone/core/session.py:_prepare_skills_update)
   - The warning was computed against ``existing_auto_approve or
     proposed_auto_approve`` — meaning an update that explicitly
     turned auto_approve OFF still triggered the warning because the
     existing row had it ON.  Now computes against the *final state*
     (``updates["auto_approve"]`` if present, else
     ``existing.get("auto_approve")``) combined with the final
     ``allowed_tools`` value.  False-positives gone; the inverse case
     (existing auto_approve=False, update turns it ON without
     touching allowed_tools) now correctly fires the warning against
     the inherited allowlist.

3. ``temperature`` validator silent-coerce → explicit error
   (turnstone/core/skill_field_validation.py:parse_skill_session_config)
   - Non-numeric temperature input silently coerced to ``None``,
     unlike ``max_tokens`` / ``token_budget`` which return an error.
     Numeric-field consistency: temperature now errors on
     unparseable input with "temperature must be a number between 0
     and 2".  Range check unchanged; blank / None still → None.

4. Version-snapshot uses max+1, not count+1
   (turnstone/core/session.py:_exec_skills_update)
   - ``count_skill_versions + 1`` re-uses version numbers when any
     row has been deleted via the existing
     ``storage.delete_skill_versions`` method, and the schema has no
     ``(skill_id, version)`` unique constraint to catch the
     collision.  Switched to ``max(list_skill_versions)`` + 1,
     matching the ``storage.unlock_skill`` pattern.  A storage-side
     atomic allocator is the right architectural fix and is tracked
     for a future PR.

Tests cover both the false-positive and inverse-positive auto_approve
cases, the new temperature error path, and the version-numbering edge
case where prior versions have been deleted (max diverges from count).
2026-05-22 16:25:52 -07:00
Patrick Buckley 7085c24530 refactor(skills): unify single-row lookup + allow coord-side load
Two changes that share the same kind-scoping touch point.

Lookup unification (closes the bypass Copilot flagged on _exec_skills_load):
- New ChatSession._lookup_visible_skill(name) — single source of truth for
  "find me a skill by name, if it's visible to this session". Combines
  storage.get_prompt_template_by_name with the kind filter in one call;
  returns None for both the missing-row and out-of-kind cases so callers
  don't have to branch on the reason.
- _exec_skills_get refactored from inline two-step to one helper call.
- _exec_skills_load refactored from the unscoped memory.get_skill_by_name
  to the new helper — the kind-scoping bypass it had (interactive could
  load a kind=coordinator skill by name) goes away by construction
  because the unscoped path no longer exists on the model-tool surface.
- memory.get_skill_by_name stays available for admin / sub-agent /
  rehydrate paths that need full-catalog visibility — those are
  deliberate cross-kind callers, not bypass surfaces. Storage exceptions
  now propagate from _lookup_visible_skill by design (distinct from the
  legacy swallow-and-return-None) so the operator gets a clear signal on
  DB outage rather than a misleading "not found".

Coord-side load support:
- _prepare_skills_load no longer rejects coordinator sessions. Parity
  with the admin / HTTP create path that already accepts a `skill` body
  field on kind=coordinator workstreams — what the operator can do at
  create time, the model can now do on its own session. Visibility is
  still kind-scoped via _lookup_visible_skill at exec (a coord can only
  load {coordinator, any}-tagged skills; interactive can only load
  {interactive, any}), matching what `find` / `get` enforce.

The kind-scoping itself is queued for a separate cleanup PR: the marker
turned out to be a discoverability hint that never gated runtime
capability, and the combinatorial complexity (every new model-tool /
HTTP path needs kind awareness) isn't worth the squeeze at this team
size. Follow-up issue to land.

Test coverage:
- TestLookupVisibleSkill — 5 cases: visible / cross-kind / missing /
  storage-unavailable / kind=any-on-both-surfaces.
- TestExecSkillsLoadKindScoping — kind-rejection from both directions
  (interactive→coord-only, coord→interactive-only), disabled-skill
  caller-side gate, and the two new positive coord-load cases (coord
  loads kind=coordinator and kind=any).
- Removed test_load_on_coord_session_errors (the rejection it pinned
  is gone).

Plus the /review-suggested doc fixes that came with the unification:
- Comment in _exec_skills_load now correctly attributes the disabled
  collapse to the caller's enabled check rather than implying the
  helper handles it.
- _lookup_visible_skill docstring documents the deliberate
  exception-propagation behavior.
2026-05-22 16:25:52 -07:00
Patrick Buckley 471d48abd9 feat(skills): unify skill + list_skills into dual-kind action-multiplexed tool
Replaces the legacy `skill` (load + search) and `list_skills` tools with a
single `skills(action=...)` tool serving both interactive and coordinator
sessions.  Stacks on the model.skills.write permission introduced in PR 1.

Tool surface
- `find`: filter by category/tag/risk_level/enabled_only/limit with
  optional BM25 query ranking; auto-approved on both kinds; kind-scoped at
  the storage filter (interactive sees interactive+any, coord sees
  coordinator+any).
- `get`: fetch a single skill including content; cross-kind misses
  collapse to "not found" so a model can't enumerate the other surface
  by name-probing.
- `load`: activate a skill in the current session (interactive-only;
  coord sessions get an explicit hint pointing at spawn_workstream).
- `create`/`update`/`enable`/`disable`: require approval AND
  model.skills.write; permission re-checked at exec time to catch a
  revocation between approval and write.
- No `delete` — hard-delete stays admin-UI exclusive; tool description
  documents the soft-delete-via-disable pattern.

Defenses on the write surface
- Approval cards surface projected risk_level (scanner re-run against
  the proposed final state) and warn explicitly when allowed_tools +
  auto_approve combine (auto-fire-on-load consequence is spelled out,
  not just shown as raw field values).
- Toggle preview surfaces existing risk_level + allowed_tools count so
  re-enabling a critical-tier skill is never a one-click bypass.
- Update path now re-fetches the row at exec to catch a readonly flip
  between approval and write, filters updates back to the runtime-only
  set if so, refuses if no fields survive.
- Update path rejects empty content (hollow-out via emptying bypassed
  the soft-delete-via-disable invariant), non-list tags, and empty
  category — failures are loud rather than silent.
- Permission denials audit `skill.write_denied` with actor_source=model
  so probing the permission state leaves a trail.  Audit failures log
  at error (not warning) — a successful write without a row is the
  exact gap the trail exists to surface.
- `_skill_hint` routes both message and system_reminder through
  escape_wrapper_tags so caller-controlled values can't close the
  <system-reminder> envelope and let the model fabricate directives in
  its own future context.

Shared validation
- `parse_skill_session_config` lifted from console/server.py to
  turnstone/core/skill_field_validation.py; both the HTTP admin path and
  the model-tool path consume it.  Single source of truth so field rules
  can't drift between layers.
- `SKILL_RUNTIME_CONFIG_FIELDS` lifted similarly (was duplicated as
  _SKILL_RUNTIME_CONFIG_FIELDS in server.py and _SKILLS_READONLY_FIELDS
  on ChatSession).
- `notify_on_complete` validator now accepts list input from the JSON
  schema's `array` type — previously rejected because str() of a list
  yields Python repr that json.loads then refuses.

Performance
- Update prepare skips the projected-risk scan when neither content nor
  allowed_tools is changing (storage re-scans on write authoritatively).
  Metadata-only updates no longer pay the ~25 regex-pass scan cost.

Cleanup
- CoordinatorClient.list_skills deleted (-91 lines); model-tool path
  talks to storage directly via list_skills_filtered.
- Roles admin UI gains a Model section exposing model.skills.write.
- tests/test_load_skill.py renamed to tests/test_skills_tool.py and
  rewritten for the new tool — 48 tests covering registration, prepare
  dispatch, permission gating (including TOCTOU-revoked exec deny),
  audit actor_source on create + disable + permission-denied probe,
  BM25 ranking, invalid-kind branches, audit-failure swallow, and
  <system-reminder> envelope injection resistance.
2026-05-22 16:25:52 -07:00
Patrick Buckley ecae0f8778 feat(auth): add model.skills.write permission and user_has_permission helper
In-process permission check for model-facing tool exec paths that need
to gate a write capability without HTTP middleware in the loop. Foundation
for the upcoming skills tool refactor: the merged
skills(action=create|update|enable|disable) tool will gate on
model.skills.write before reaching storage.

- Add model.skills.write to _VALID_PERMISSIONS (default-ungranted on every
  role including builtin-admin — operators opt themselves in explicitly)
- Add user_has_permission(user_id, permission, *, storage=None) helper
  that fails-closed on storage outages and short-circuits on empty user_id
- Document service-scope asymmetry with require_permission (no AuthResult
  in the model-tool path → no bypass; explicit guidance if a legitimate
  service-scope caller ever needs to reach here)
- Pin the "no implicit cache" contract with a regression test asserting
  every helper call hits storage (call_count == 2 after two calls)
- Lock the "builtin-admin default-ungranted" invariant with an alembic
  migration test that drives the chain to head and asserts the role's
  permission string omits model.skills.write
- Plus the role-create end-to-end test proving the constant flows through
  the admin endpoint's validator

Roles admin UI changes deferred to the PR that lands the gated tool — no
operator action needed until the capability exists.

Per-call DB hit + warning-log spam on outage deferred to a follow-up PR;
the helper is dead code in this commit, so cache TTL would be sized
against guesswork — better to wait for a real call-rate signal from the
first caller.
2026-05-22 15:37:49 -07:00
Patrick Buckley 03afb82369 chore(deps): raise starlette floor to 1.0.1 (PYSEC-2026-161)
Starlette 1.0.0 reconstructs request URLs without validating the Host
header, allowing path-injection that can bypass authentication on apps
comparing reconstructed URL paths instead of `request.url.path`.  Fixed
in 1.0.1.

- pyproject.toml: bump `starlette>=0.45` to `starlette>=1.0.1` so the
  CVE floor is explicit at the dependency declaration, not just in the
  lockfile.  Annotated with the advisory ID so the rationale survives
  a future floor relax.
- uv.lock: regenerated via `uv lock --upgrade-package starlette`;
  starlette 1.0.0 -> 1.0.1, no transitive bumps.

Locally verified `pip-audit --strict` returns clean after the bump and
the auth + service-boundary test suites (250 tests covering the URL/
host-header reconstruction surface) continue to pass.
2026-05-22 15:30:23 -07:00
Patrick Buckley 79eeb25e3f feat(sse): raise default event buffer cap 2000 -> 50000
2000 was sized for the cloud-provider regime (50–200 events/sec)
and was too small for the two regimes that actually shape PR-D's
recovery floor:

1. **Local inference**: vLLM / llama.cpp hit 500–2000 tok/s per
   active stream.  Each token is an _enqueue call, so a single
   busy workstream burns through 2000 events in ~1 s.  Reconnects
   after any disconnect longer than a network blip immediately
   fall through to the replay_truncated recovery path on a
   stream that was supposed to be transparently resumable.
2. **Backgrounded tabs**: Chrome (and Firefox to a lesser extent)
   throttle the SSE-drain microtask aggressively when a tab isn't
   visible — Chrome's background-tab budget drops to ~1 wake/min
   after ~5 min hidden, so a backgrounded pane can legitimately
   sit on tens of seconds of un-drained events.  PR-G (drop-pings-
   let-it-die) deliberately closes those connections on hide and
   re-opens on focus return; reconnect-with-replay is the only
   recovery path, and if the buffer evicted in the interim, the
   snapshot floor is all that's left for past-turn structural
   events (tool calls, state changes, approvals).

50000 at the 2000-tok/s local-inference rate buys ~25 s of pure
token streaming before truncation; at cloud rates it's minutes of
coverage.  Memory cost is ~200–500 bytes per event (deque node +
dict + payload), so 50000 × 100-ws design ceiling caps at roughly
2.5 GB worst-case — and practically nowhere close because the cap
is per-ws ceiling, not per-ws steady-state.  Operators on heavier
workloads can raise via TURNSTONE_SSE_EVENT_BUFFER_MAX.

Considered and rejected: in-buffer coalescing of consecutive
content/reasoning tokens.  A naive text-merge breaks the replay-
slice semantic — a coalesced entry has the latest _event_id
but text that includes content the client already received under
an earlier id, so any consumer with last_event_id falling
INSIDE the coalesced span would double-render on replay.  A
correctness-preserving coalesce would need a per-consumer high-
water tracker we deliberately don't maintain.  Bigger cap +
simple per-event storage avoids the trap; the rationale is
captured inline in _resolve_event_buffer_max.
2026-05-22 15:13:39 -07:00
Patrick Buckley 2dd71fb869 feat(ui): browser onerror preserves native EventSource reconnect
The browser-side completion of PR-D reconnect-with-replay.  Today's
`onerror` handlers on `Pane.connectSSE`, `connectGlobalSSE`, and
the coordinator's `connectSSE` all explicitly call
`evtSource.close()` on the transient-error path — that forces the
source into the terminal CLOSED state, defeating EventSource's
native auto-reconnect (which would otherwise reconnect with the
`Last-Event-ID` header that PR-D commit 1 now honours server-side).

Three handler refactors share the same shape:

- Remove the unconditional `close()` from the transient-error
  branch.  Native EventSource handles CONNECTING -> CONNECTING ->
  OPEN with replay automatically.
- Keep UI updates (status bar dim, Reconnecting… text) — those
  are orthogonal visualizations of the disconnected state.
- Keep terminal-branch closes: a 401 expired-session still does an
  explicit close + showLogin (the user must re-authenticate); a
  workstream-reassignment to a different ws still disconnects +
  connects on the new wsId (it's a different stream, not a same-
  stream replay).
- Capture `lastEventId` in `onmessage` BEFORE `JSON.parse` so a
  malformed event doesn't desync the manual-reconnect fallback
  from native auto-reconnect.
- Thread `?last_event_id=N` on the URL when constructing a fresh
  `new EventSource(url)` — the constructor can't set custom
  headers so the query-param fallback covers the manual-reconnect
  path (initial connect with a saved id, scheduleReconnect after
  an explicit close, etc.).

For `Pane.connectSSE`, the long focused-pane workstream-refetch
body inside `onerror` is lifted to a dedicated
`_refetchWorkstreamsAndReassign` method so it survives the
refactor as an orthogonal trigger (handles the workstream-evicted-
during-disconnect recovery case, which is independent of the SSE
reconnect mechanics).  The reassignment branch's existing
`disconnectSSE + connectSSE(newWsId)` sequence stays — different
workstream genuinely needs a fresh stream.  When reassigning, the
saved `_lastEventId` is dropped because replay is per-ws and an
id from ws-A is meaningless against ws-B.

Tests in `tests/test_app_js.py` add 3 static lint guards that
fail loudly if any future refactor reintroduces a naked
`evtSource.close()` in a transient-error path of any of the three
handlers.  The guards understand the allowed terminal-branch
exceptions (401, login overlay, reassignment) and ship with an
escape hatch (functions that explicitly reference `last_event_id`
have taken explicit responsibility for the replay header and are
exempt).  A small `_strip_js_comments` helper handles the
apostrophe-in-comment hazard that pre-existing
`_slice_balanced_body` doesn't (comments are stripped before
brace-walking; offsets preserved by space substitution).
2026-05-22 15:13:39 -07:00
Patrick Buckley 6b6c8eb263 feat(console): forward Last-Event-ID through SSE proxy
The console SSE proxy (`_proxy_sse`) is the inbound SSE path for
multi-node deployments — every browser EventSource that targets a
per-node route traverses it.  Today's proxy strips client request
headers (only `Accept`, `Cache-Control`, and the re-minted auth
token make it upstream), so the per-ws / global SSE handlers'
`Last-Event-ID` resume (PR-D commit 1) never sees the header in
the multi-node shape — every reconnect would be a fresh connect
and silently drop events from the disconnect window.

Builds the upstream headers dict conditionally: copy `Last-Event-ID`
from the incoming request when present, omit otherwise (no
fabricated value on fresh connects).  Starlette's header dict is
case-insensitive so the `request.headers.get("last-event-id")`
lookup catches both the spec-recommended capitalization and any
intermediary normalisation.

The query-param fallback (`?last_event_id=N`) needs no proxy
change — `request.url.query` is already forwarded verbatim at the
top of the function.

Tests in `tests/test_service_auth_boundary.py::TestProxySseLastEventIdForwarding`:
- Positive: browser header → upstream header (value preserved).
- Negative: browser sends nothing → upstream gets nothing (no
  fabricated value).
2026-05-22 15:13:39 -07:00
Patrick Buckley 08f6f146bc feat(sse): per-ws ring buffer + Last-Event-ID replay foundation
Adds the server-side foundation for SSE reconnect-with-replay (PR-D
in issue #540's sequencing): a per-ws monotonic ring buffer that
holds the last N events for replay against a client's
`Last-Event-ID` header (or `?last_event_id=N` query-param
fallback for manual reconnect paths that can't set custom headers).

Per-ws lane (SessionUIBase + make_events_handler):
- `_event_buffer` deque (cap 2000, env-overridable via
  `TURNSTONE_SSE_EVENT_BUFFER_MAX`) holds (event_id, event_dict)
  tuples; `maxlen` evicts the oldest automatically.
- Existing `_ws_inflight_seq` renamed to `_event_id` and lifted
  to live alongside the listeners — one monotonic counter drives
  both the new replay slice AND the existing `_seq`/`snap_seq`
  snapshot dedup (byte-identical contract on token events).
- `_enqueue` now stamps every event with `_event_id` (and `_seq`
  on `content`/`reasoning` token events) under
  `_listeners_lock`, so the buffer append + listener fan-out + new
  listener registration are all atomic against each other.
- New `register_listener_with_replay` returns
  (queue, replay_events, status, lost_count, earliest_id) where
  status ∈ {replay_ok, truncated}.  `make_events_handler` reads
  `Last-Event-ID` (header or query), branches three ways
  (fresh / replay_ok / truncated), and emits the SSE `id:` field
  on every event sourced from the buffer.  On `replay_ok` the
  in-progress snapshot is skipped (the buffered events already
  cover it); on `truncated` an explicit envelope precedes the
  fresh-style recovery path.
- Every events stream emits a jittered `retry:` in [2500, 4500] ms
  on first yield so 6-pane reconnects don't lockstep on
  EventSource's default ~3 s interval.

Global lane (server.py / _global_fanout_thread / global_events_sse):
- Parallel buffer + counter on `app.state.global_event_buffer` and
  `app.state.global_event_id_holder`; fanout thread stamps each
  event with `_event_id` and appends to the buffer under
  `global_listeners_lock`.  `global_events_sse` branches on
  `Last-Event-ID` with the same three shapes.

Tests:
- 16 new tests in `tests/test_sse_reconnect_replay.py` cover the
  ring buffer semantics (empty-listeners hold, last_event_id
  slicing, truncation, atomic registration), the counter
  invariants (monotonic under concurrent writers, no skip on
  queue.Full, persists across turn boundaries, cross-thread
  consistency), and the handler branching (retry on first yield,
  id: on buffered events, snapshot-skip on replay_ok, envelope on
  truncated, query-param fallback, malformed header → fresh).
- Existing `tests/test_session_ui_base.py` updated for the
  `_ws_inflight_seq` → `_event_id` rename and the new
  `_event_id` field on enqueued events.

Backward-compat: all consumers that don't send `Last-Event-ID`
(today's browser, Python SDK, TypeScript SDK, channel adapter) see
behaviour identical to pre-PR — the server change is purely
additive on the request side.
2026-05-22 15:13:39 -07:00
454 changed files with 70221 additions and 34343 deletions
+45 -33
View File
@@ -1,49 +1,61 @@
# =============================================================================
# Turnstone Environment Variables
# Copy to .env and adjust values for your deployment.
# Turnstone environment overrides — ALL OPTIONAL for the dev stack.
#
# Usage:
# Single node: docker compose --profile production up
# 10-node cluster: docker compose --profile cluster up
# `docker compose up` from a clone works with zero config: every value below
# has a built-in (insecure) default. Copy this file to `.env` only to override.
#
# The PRODUCTION stack (turnstone/deploy/compose.yaml) has no baked-in secrets
# and DOES require TURNSTONE_JWT_SECRET and POSTGRES_PASSWORD.
#
# Note: for a turnstone process running on bare metal (not in a container),
# put secrets in ~/.config/turnstone/config.toml (chmod 0600), not the
# environment. See docs/docker.md "Join a bare-metal host".
# =============================================================================
# -- LLM Backend --------------------------------------------------------------
LLM_BASE_URL=http://host.docker.internal:8000/v1
OPENAI_API_KEY=dummy
# ANTHROPIC_API_KEY=sk-ant-...# Set instead of OPENAI_API_KEY for Anthropic
# TAVILY_API_KEY=tvly-... # Web search fallback (local models only)
# MODEL=# Override default model alias
# -- LLM backend --------------------------------------------------------------
# Optional: nodes boot without an LLM. Add real model backends from the console
# UI (Models tab). These only set the bootstrap default a node starts with.
# LLM_BASE_URL=http://host.docker.internal:8000/v1
# OPENAI_API_KEY=dummy
# ANTHROPIC_API_KEY=sk-ant-... # set instead of OPENAI_API_KEY for Anthropic
# TURNSTONE_SEARXNG_URL=http://searxng:8080 # web_search backend (default: bundled service; set to an external SearxNG)
# MODEL= # default model alias
# -- Authentication (required) ------------------------------------------------
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
TURNSTONE_JWT_SECRET=changeme-to-32-bytes-of-hex
# -- Secrets ------------------------------------------------------------------
# The dev stack defaults these to INSECURE values. Always set real ones for
# anything reachable beyond localhost. Generate the JWT secret with:
# python -c "import secrets; print(secrets.token_hex(32))"
# TURNSTONE_JWT_SECRET=
# POSTGRES_PASSWORD=
# -- Database ------------------------------------------------------------------
# Single-node default is SQLite (zero config). Set these for PostgreSQL:
# -- Database -----------------------------------------------------------------
# Defaults to the bundled PostgreSQL (shared by every service — required for
# the console to discover nodes). Override to point at an external database:
# TURNSTONE_DB_BACKEND=postgresql
# POSTGRES_USER=turnstone
# POSTGRES_PASSWORD=changeme
# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:<pw>@postgres:5432/turnstone
# -- Ports ---------------------------------------------------------------------
# SERVER_PORT=8080
# CONSOLE_PORT=8090
# -- Ports / networking -------------------------------------------------------
# The dashboard is reached via Caddy only (HTTP/2 avoids the browser's
# 6-connection cap on the console's SSE streams). Both stacks expose the same
# two host ports; everything else is proxied through the console.
# CONSOLE_HTTPS_PORT=8443 # Caddy (dashboard HTTPS)
# POSTGRES_PORT=5432 # exposed for bare-metal host joins
# POSTGRES_BIND=127.0.0.1 # set 0.0.0.0 to let another machine join
# -- Workspace -----------------------------------------------------------------
# Bind-mount a host directory into the container at /workspace.
# The model can read/write files here. Default: empty Docker volume.
# -- Workspace ----------------------------------------------------------------
# Bind-mount a host directory the model can read/write at /workspace:
# WORKSPACE_MOUNT=/path/to/your/project
# -- Agent behavior ------------------------------------------------------------
# SKIP_PERMISSIONS=true # Auto-approve all tool calls (dev only)
# MCP_CONFIG=/workspace/mcp.json# MCP server configuration file
# -- Agent behavior -----------------------------------------------------------
# SKIP_PERMISSIONS=true # auto-approve all tool calls (dev only)
# MCP_CONFIG=/workspace/mcp.json # MCP server config file
# -- Discord channel gateway ---------------------------------------------------
# -- Channel gateway (Discord / Slack) ----------------------------------------
# TURNSTONE_DISCORD_TOKEN=
# TURNSTONE_DISCORD_GUILD=0
# TURNSTONE_SLACK_TOKEN=xoxb-...
# TURNSTONE_SLACK_APP_TOKEN=xapp-...
# -- Cluster (profile: cluster) -----------------------------------------------
# These are set per-node in compose.yaml; only override for custom topologies.
# TURNSTONE_NODE_ID=node-1
# TURNSTONE_ADVERTISE_URL=http://server-1:8080
# -- Production image tag ------------------------------------------------------
# TURNSTONE_IMAGE_TAG=latest # pin the ghcr.io image (production stack)
+11 -11
View File
@@ -14,7 +14,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
@@ -25,7 +25,7 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
@@ -39,7 +39,7 @@ jobs:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ matrix.python-version }}
@@ -75,14 +75,14 @@ jobs:
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
- run: pip install -e ".[test,postgres]"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
env:
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
@@ -90,7 +90,7 @@ jobs:
wheel-completeness:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
@@ -137,8 +137,8 @@ jobs:
lock-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
uv-version: "0.9.18"
- run: uv lock --check
@@ -146,8 +146,8 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
uv-version: "0.9.18"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -174,7 +174,7 @@ jobs:
run:
working-directory: sdk/typescript
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
+4 -4
View File
@@ -24,7 +24,7 @@ jobs:
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
@@ -43,7 +43,7 @@ jobs:
- name: Log in to GHCR
if: steps.tag.outputs.skip == 'false'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -67,12 +67,12 @@ jobs:
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
if: steps.tag.outputs.skip == 'false'
- name: Build and push
if: steps.tag.outputs.skip == 'false'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
push: true
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
fi
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ steps.ref.outputs.head_ref }}
+5
View File
@@ -9,6 +9,11 @@ build/
.venv/
venv/
.env
# Local compose overrides (e.g. run.sh's node-count limiter, bootstrap output)
compose.override.yaml
compose.override.yml
docker-compose.override.yaml
docker-compose.override.yml
*.so
.mypy_cache/
.ruff_cache/
+207 -16
View File
@@ -6,32 +6,223 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) for
version numbers (`X.Y.Z`, with `X.Y.ZaN` / `bN` / `rcN` for pre-releases).
Three release tracks are maintained:
Three release tracks are maintained — the current stable, one prior
stable, and the experimental line:
- **`stable/1.4`** — patch-only (`v1.4.x`)
- **`stable/1.5`** — patch-only (`v1.5.x`)
- **`stable/1.6`** — patch-only (`v1.6.x`)
- **`main`** — experimental (next major)
## [Unreleased]
## [1.6.0]
The first stable release of the 1.6 line — and the first under Apache 2.0.
> **⚠️ Before upgrading from 1.5.x:** 1.6.0 changes the internal
> conversation storage schema (Alembic migration `060`, applied
> automatically on first start). The migration converts existing
> workstreams and attachments in place — **back up your storage before
> upgrading** (`pg_dump` for PostgreSQL; copy the database file for
> SQLite). Background: discussion
> [#631](https://github.com/turnstonelabs/turnstone/discussions/631).
**Breaking changes at a glance** (details in the sections below):
`web_search` backend overhaul (Tavily/DuckDuckGo removed, `topic`
`category`), the `man` / `math` / `plan_agent` built-in tools and the
plan-review protocol removed, and the body-keyed `/v1/api/command`
endpoint replaced by path-keyed workstream verbs.
### License
- **Relicensed to Apache 2.0** — from BUSL-1.1, effective with this
release (#546, contributor assent record in #548). Versions 1.5.x and
earlier remain under BUSL-1.1 as shipped, and the `stable/1.5` branch
keeps its original LICENSE. New `NOTICE` and
`CONTRIBUTORS.md` files; `THIRD-PARTY-NOTICES` refreshed to match the
bundled library versions.
### Added
- **`turnstone-admin` reads `config.toml`** — the admin CLI now honors
the same `[database]` section that `turnstone-server` does, with the
same precedence (`CLI / config.toml > TURNSTONE_DB_* env > defaults`).
Operators with DB credentials in `config.toml` no longer need to
re-export `TURNSTONE_DB_URL` before every admin invocation. Newly
plumbed through to `init_storage`: `pool_size`, `sslmode`,
`sslrootcert`, `sslcert`, `sslkey` — previously the admin CLI
silently dropped these. A new `--config PATH` flag mirrors the
one already on `turnstone-server`.
- **Mid-conversation system messages** — advisories, watch results,
skill hints, and operator interjections are now first-class
`role=system` turns in the trajectory instead of ad-hoc reminder
envelopes. Models with native mid-conversation system support receive
them verbatim; for everything else they fold into a nonce-fenced
wrapper. The one-shot `_reminders` side-channel is gone.
- **Self-hosted SearxNG web search** — the `web_search` backend for
local/vLLM models is now a bundled [SearxNG](https://searxng.org)
service (in both compose stacks; internal network only). Configure via
`tools.searxng_url` / `tools.searxng_engines`. Commercial providers
keep their native server-side search; the model can target a corpus by
passing `category` (`general`, `news`, `it`, `science`). Operators
exposing the bundled SearxNG publicly: see the AGPL-3.0 §13 note in
[docs/docker.md](docs/docker.md).
- **Endpoint-backed reranking** — a reranker is now a per-model
definition (Cohere/Jina-compatible wire: vLLM, TEI, llama.cpp, or a
commercial endpoint), disabled by default. When configured it scores
`web_search` results and the BM25 retrieval surfaces (deferred tools,
skills, memory) behind a `tools.rerank_bm25` toggle with a relevance
floor; a calibration CLI (and calibrate-on-detect) tunes the floor
per model.
- **Proactive memory relevance** — injected memories are selected by
BM25 + reranker against the recent user messages instead of recency
alone, and first composition defers to the first user turn so fresh
sessions select against a real query.
- **Smart Approvals** — opt-in (default off): high-confidence `approve`
verdicts from the intent judge auto-approve the tool call instead of
waiting for a human, with a confidence threshold and verdict
bookkeeping designed so a denied or reset judge never auto-fires.
- **Early-painted tool calls** — committed tool calls render immediately
as pending cards (both UIs upgrade the card in place by `call_id`)
instead of waiting for the judge verdict, so big parallel batches no
longer sit invisible during judging.
- **Voice I/O v1** — speech-to-text and text-to-speech as model roles
speaking the OpenAI audio wire protocol (#618); the interactive
composer grows a mic button.
- **Rewind / retry / edit-first-message** — full UX in both the
interactive UI and the coordinator pane, backed by shared path-keyed
verb handlers (#549).
- **Workstream export** — download a conversation as OpenAI-format
messages JSON.
- **Skills platform round** — `SKILL.md` ingestion learns
`when_to_use` / `model` / `effort` / `paths`; prompt substitution
supports `$ARGUMENTS`, `$N`, `$<name>`, and `${CLAUDE_*}` (#572);
per-skill `disable-model-invocation` and `user-invocable` flags
(#571); `skill` + `list_skills` unify into one dual-kind tool; new
`model.skills.write` permission.
- **Coordinator hardening for small models** — workstream references in
coordinator tool calls are validated with did-you-mean recovery, and
`wait_for_workstream` fails fast with uniform `not_found` entries
instead of hanging on a hallucinated `ws_id`.
- **Provider support** — Claude Fable 5 and Claude Opus 4.8; xAI/Grok
via the OpenAI Responses lane; vLLM reasoning-field replay completes
the reasoning-persistence work (#537).
- **Cluster-by-default deployment** — the compose stack fronts
everything with Caddy and supports bare-metal node join; a one-line
`curl | bash` installer bootstraps a node; nodes with no configured
models boot into a degraded state instead of crash-looping; channel
gateways stand by when no adapter token is set.
- **MCP OAuth tokens encrypted at rest**.
- **`turnstone-admin` reads `config.toml`** — same `[database]` section
and precedence as the server (`CLI / config.toml > TURNSTONE_DB_* env
> defaults`), including `pool_size` and the `ssl*` knobs it previously
dropped; new `--config PATH` flag.
### Changed
- **Conversation storage and the provider wire are rebuilt around a
canonical trajectory** (migration `060` — see the upgrade note).
Internally a conversation is now a provider-neutral `Turn` sequence
lowered to each provider's wire format at send time; provider-specific
tool-call metadata rides an opaque producer-tagged lane (replayed
verbatim to the producing provider, rebuilt for others); attachments
become content-addressed, reference-counted rows resolved at the
provider boundary; orphan tool-call repair happens once, at send time.
Wire-visible behavior is unchanged for OpenAI-compatible providers;
histories are preserved across the migration.
- **The console and web UI share one L-shell** — a left glyph rail, a
tab bar, and a pane host now frame interactive chats, coordinator
sessions, dashboards, and the admin panel as tabs in a single window;
the standalone web UI adopts the same shell and the old split-pane
layout is retired. Coordinator and interactive conversations render
through shared `.conv-*` card builders, the rail collapses to a glyph
strip (remembered per browser), mobile gets an off-canvas drawer, and
the frontend is now ES modules end to end.
- **Admin panel modals → the Service Hatch shelf** — all ~35 admin
modals are replaced by pane-scoped shelves plus a small dialog tier
for confirmations. Schedules gain a cron builder with a next-3-runs
preview endpoint, model capabilities render as an LED tile matrix, and
the legacy modal machinery is deleted.
- **SSE delivery is resumable end to end** — per-workstream ring buffer
with `Last-Event-ID` replay (cap raised 2,000 → 50,000), fresh-connect
and reconnect unified on one event-id cursor (in-flight tool batches
included), persisted `last_error` replays on connect, the console
proxy forwards `Last-Event-ID`, and panes close their connections on
`beforeunload` to stop multi-pane refresh from exhausting the
browser's per-host connection cap (#539).
- **Workstream verbs are path-keyed** *(BREAKING)*`rewind` / `retry`
/ `edit-first-message` live at
`/v1/api/workstreams/{ws_id}/<verb>` alongside the other session
verbs; the body-keyed `/v1/api/command` endpoint is removed (#549).
- **`/history` is projected server-side** — both UIs consume the same
REST-first wire shape instead of re-deriving it client-side.
- **Saved workstreams & coordinators: card grid → sortable table** with
model/skill/context columns, pagination, and a unified selector across
both dashboards.
- **`tools.web_search_backend` accepted values** *(BREAKING)* — now `""`
(auto), `"searxng"`, or `"mcp:server:tool"`. The old `"tavily"` and
`"ddg"` values are gone; a config still set to either disables web
search and logs a warning. Auto-detect resolves to SearxNG when
`searxng_url` is set.
- **`web_search` tool: `topic``category`** *(BREAKING)* — renamed
LLM-facing parameter; values map to SearxNG categories. The Tavily-era
`finance` topic is gone.
- **Core install includes what most deployments use** — `anthropic`,
`postgres`, `console`, and `tls` are core dependencies rather than
extras.
- **NODES table → bottom-bar node picker** in the console.
### Fixed
- **Cluster mTLS actually survives operations** — certificate identity
keys on the advertised host rather than the container ID, renewals are
scoped per node, reloaded certs hot-swap into the live SSL context,
and healthchecks/boot retries are mTLS-aware.
- **Intent-verdict lifecycle** — history replay ships risk-none verdict
rows (live/replay parity), late verdicts persist as `superseded` for
the audit trail instead of vanishing, bulk verdict insert tolerates
per-row conflicts, and cancel-on-approval honors its run-to-completion
contract.
- **Usage accounting** — dashboard totals were under-counting; auxiliary
LLM spend (judge, rerank, memory) is now recorded.
- **Concurrent first-boot migrations** no longer deadlock on the
advisory lock.
- **Output renderer** — single-`$` inline math no longer false-positives
in prose; `strip_html` preserves block structure and drops a ReDoS
risk.
- **Model registry** orders versions numerically (no more `1.10 < 1.9`
selection).
### Removed
- **Tavily and DuckDuckGo `web_search` backends** *(BREAKING)*
replaced by the bundled SearxNG service. Removed:
`tools.tavily_api_key`, `$TAVILY_API_KEY`, `[api].tavily_key`, and the
`ddg` install extra. Point `TURNSTONE_SEARXNG_URL` at an existing
instance or use the bundled one; no database migration required.
- **`man`, `math`, and `plan_agent` built-in tools** *(BREAKING)*
`man`/`math` duplicated `bash`; planning is better expressed as a
`task_agent` running a planning skill. Also removed: the `math`
sandbox executor, the read-only `AGENT_TOOLS` sub-agent set, the
plan-review protocol (`/v1/api/plan`, `plan_review`/`plan_resolved`
SSE events, `on_plan_review` hooks), and the `model.plan_*` settings.
Interactive built-in tool count: 19 → 16.
- **`stable/1.4` track retired** — the maintenance policy is now the
current stable plus one prior (`stable/1.6` + `stable/1.5` as of this
release). 1.4's final release was `v1.4.0`; its tags and released
artifacts remain available, under BUSL-1.1 as shipped.
### Security
- **Permissive `config.toml` now warns** — `turnstone.core.config.load_config`
logs a single warning when the resolved config file is group- or
world-readable (any bit in `0o077`). DB password and TLS key paths
live in `[database]`; operators usually want the file at `0600`.
- **Zero direct-HTML frontend** — every `innerHTML` sink across the
console and web UI is replaced with DOM construction or `setSafeHtml`,
inline handlers became delegated bindings, and CI lints pin the
invariant (plus `var`-free and const-reassign checks) across all
swept bundles.
- **Output guard grows an LLM stage** — merged with the heuristics as
escalate-only (an LLM verdict can raise but never lower a heuristic
positive), with annotated findings, a capability gate, and hardening
against domain-camouflaged injection (#560, #573).
- **One trust-fence primitive** — operator and judge envelopes share a
nonce-fenced wrapper (64-bit nonces, host-escaping); the output guard
flags nonce forgery, and skill hints no longer echo model-controlled
filter values into trusted text.
- **RBAC** — built-in role overrides get an editor, and several
under-enforced permission gates are tightened (#585).
- **Permissive `config.toml` warns** — a single startup warning when the
resolved config file is group- or world-readable; operators usually
want `0600`.
- **Dependency floors** — `starlette>=1.0.1` (PYSEC-2026-161 host-header
path injection) and `aiohttp>=3.14.0` (security release).
## [1.5.17]
+1 -1
View File
@@ -56,4 +56,4 @@ Open an issue at https://github.com/turnstonelabs/turnstone/issues with:
## License
By contributing, you agree that your contributions will be licensed under the
project's [Business Source License 1.1](LICENSE).
project's [Apache License 2.0](LICENSE).
+13
View File
@@ -0,0 +1,13 @@
# Contributors
Turnstone is written and maintained by Patrick Buckley
([@eous](https://github.com/eous)).
The following people have contributed code to the project — thank you:
- Burhan ([@Burhan-Q](https://github.com/Burhan-Q))
- chrismuzyn ([@chrismuzyn](https://github.com/chrismuzyn))
- daoxley ([@daoxley](https://github.com/daoxley))
- Robert DeAngelis ([@OriginalOrangeXD](https://github.com/OriginalOrangeXD))
- William ([@sillyWillieBilly](https://github.com/sillyWillieBilly))
- [@pizzaandcheese](https://github.com/pizzaandcheese)
+2 -2
View File
@@ -8,7 +8,7 @@ FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
COPY --from=ghcr.io/astral-sh/uv:0.11.14 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /usr/local/bin/uv
# Remove the slim image's man page exclusion so man-db has actual content
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
@@ -33,7 +33,7 @@ RUN useradd --create-home --shell /bin/bash turnstone
WORKDIR /app
# Install dependencies first (cached layer — only re-runs when deps change)
COPY pyproject.toml uv.lock README.md LICENSE ./
COPY pyproject.toml uv.lock README.md LICENSE NOTICE THIRD-PARTY-NOTICES ./
RUN uv sync --frozen --no-install-project --no-dev \
--no-compile --extra all
+187 -48
View File
@@ -1,62 +1,201 @@
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Parameters
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Licensor: Patrick Buckley
Licensed Work: Turnstone 0.2.0. The Licensed Work is (c) 2025-2026 Patrick Buckley.
Additional Use Grant: You may make production use of the Licensed Work, provided
your use does not include providing the Licensed Work to third
parties as a hosted or managed service, where the service
provides users with access to any substantial set of the
features or functionality of the Licensed Work.
Change Date: 2030-03-01
Change License: Apache License, Version 2.0
1. Definitions.
For information about alternative licensing arrangements for the Licensed Work,
please contact buckleypm@gmail.com.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
Notice
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
Business Source License 1.1
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
Terms
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited production use.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+7
View File
@@ -0,0 +1,7 @@
Turnstone
Copyright 2025-2026 Patrick Buckley
Licensed under the Apache License, Version 2.0; see the LICENSE file.
Third-party software bundled with this distribution is listed in the
THIRD-PARTY-NOTICES file; each component remains under its own license.
+6 -6
View File
@@ -42,13 +42,13 @@ That's it — no flags, no arguments. The wizard prompts for everything.
## Deployment Modes
The wizard supports two deployment modes:
- **Single-node production** — `docker compose up` against the bundled
`turnstone/deploy/compose.yaml`: 1 server + console + channel + PostgreSQL,
pulled from ghcr.io. Good for most deployments.
- **Local multi-node cluster** — clone the repo and run `docker compose up` at
the root for a 10-node fleet + console + Caddy + channel, built locally.
- **Single-node production** (`docker compose --profile production up`) —
1 server + console + PostgreSQL. Good for most use cases.
- **Multi-node cluster** (`docker compose --profile cluster up`) —
10-node server fleet + console + PostgreSQL. For high-throughput or
HA deployments.
See [docs/docker.md](docs/docker.md) for both.
## Example Session
+35 -13
View File
@@ -3,9 +3,10 @@
[![CI](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml/badge.svg)](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/turnstone)](https://pypi.org/project/turnstone/)
[![Python](https://img.shields.io/pypi/pyversions/turnstone)](https://pypi.org/project/turnstone/)
[![License](https://img.shields.io/badge/license-BSL--1.1-blue)](LICENSE)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/Nh3bWMacaq)
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers with direct HTTP routing, interactive interfaces, and enterprise governance.
Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real tools — shell, files, search, web — and run them across your own cluster with direct HTTP routing and interactive interfaces. Your code, your models, your data stay on hardware you control: no telemetry, no phone-home.
<p align="center">
<img src="docs/assets/hero.png" alt="Turnstone coordinator — parallel tool batches with judge-graded approval and child workstream tracking" width="960"/>
@@ -26,12 +27,13 @@ See [docs/releasing.md](docs/releasing.md) for the full release process.
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports.
- **Local-first & private** — runs entirely on hardware you control, with no telemetry and no phone-home. Point it at local models (vLLM, llama.cpp, Ollama) or commercial APIs you hold the keys to — your prompts and data never transit a third party you didn't choose.
- **Bring your own models** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM), the Anthropic Messages API, and Google Gemini, mixed freely per role
- **Interactive sessions** — terminal CLI or browser UI with parallel workstreams
- **Cluster dashboard** — real-time view of all nodes and workstreams with console routing proxy
- **Intent validation** — LLM judge evaluates every tool call with risk assessments and evidence
- **Governance** — RBAC, OIDC SSO, tool policies, skills, usage tracking, audit logs
- **Multi-provider** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM), Anthropic Messages API, and Google Gemini
- **Cluster dashboard** — real-time view of every node and workstream, with a rendezvous routing proxy
- **Intent validation** — an LLM judge (your model) grades every tool call with a risk assessment and evidence before it runs
- **MCP support** — external tool servers with native deferred loading (Anthropic/OpenAI) or BM25 fallback
- **Team controls when you need them** — optional RBAC, SSO, tool policies, and audit logs, all stored in your own database
<p align="center">
<img src="docs/diagrams/architecture-overview.svg" alt="Turnstone system architecture" width="960"/>
@@ -49,14 +51,12 @@ turnstone --base-url http://localhost:8000/v1
turnstone-server --port 8080 --base-url http://localhost:8000/v1
# Cluster dashboard
pip install turnstone[console]
turnstone-console --port 8090
```
For PostgreSQL (recommended for production):
```bash
pip install turnstone[postgres]
export TURNSTONE_DB_BACKEND=postgresql
export TURNSTONE_DB_URL="postgresql+psycopg://user:pass@localhost:5432/turnstone"
turnstone-server --port 8080 --base-url http://localhost:8000/v1
@@ -64,12 +64,29 @@ turnstone-server --port 8080 --base-url http://localhost:8000/v1
### Docker
One-line install — autodetects Ubuntu/Debian, Fedora/RHEL, Arch, and WSL,
installs git + Docker if missing, generates secrets, and starts the stack:
```bash
cp .env.example .env # edit LLM_BASE_URL, OPENAI_API_KEY, etc.
docker compose --profile production up
curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
```
See [QUICKSTART.md](QUICKSTART.md) for the bootstrap wizard and [docs/docker.md](docs/docker.md) for Docker configuration and profiles.
Or, if you already have Docker, clone the repo and run it yourself:
```bash
docker compose up
```
That builds one image and brings up a full local cluster — PostgreSQL, console,
Caddy, channel gateway, and 10 server nodes — with no `.env` required (it ships
with insecure dev defaults). Open the dashboard at https://localhost:8443 (Caddy
serves it over TLS with its own local CA — trust it once). Nodes boot without an
LLM; add model backends from the console UI.
For production (released images from ghcr.io, real secrets required), use the
bundled stack: `docker compose -f turnstone/deploy/compose.yaml up`.
See [QUICKSTART.md](QUICKSTART.md) for the bootstrap wizard and [docs/docker.md](docs/docker.md) for Docker configuration.
### Programmatic (SDK)
@@ -142,9 +159,14 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
- Python 3.11+
- An OpenAI-compatible API endpoint, Anthropic API key, or Google Gemini API key
- Optional: PostgreSQL (`pip install turnstone[postgres]`), Anthropic (`pip install turnstone[anthropic]`)
- Optional: Discord / Slack channel integrations (`pip install turnstone[discord,slack]`)
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
## Community
Questions, ideas, or want to show what you're building? Join us on Discord:
**[discord.gg/Nh3bWMacaq](https://discord.gg/Nh3bWMacaq)**.
## License
[Business Source License 1.1](LICENSE) — free for all use except hosting as a managed service. Converts to Apache 2.0 on 2030-03-01.
[Apache License 2.0](LICENSE), as of version 1.6.0. Versions 1.5.x and earlier remain under the Business Source License 1.1 they shipped with.
+4 -4
View File
@@ -2,11 +2,11 @@ Turnstone — Third-Party Notices
This file contains the licenses and notices for third-party software bundled
with Turnstone. Each bundled dependency retains its original license; the
Turnstone BUSL-1.1 license does not apply to these components.
Turnstone Apache-2.0 license does not apply to these components.
================================================================================
KaTeX 0.16.38
KaTeX 0.17.0
https://katex.org/
https://github.com/KaTeX/KaTeX
@@ -70,7 +70,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
Mermaid 11.13.0
Mermaid 11.15.0
https://mermaid.js.org/
https://github.com/mermaid-js/mermaid
@@ -98,7 +98,7 @@ SOFTWARE.
================================================================================
hls.js 1.6.15
hls.js 1.6.16
https://github.com/video-dev/hls.js
Copyright 2017 Dailymotion
+204 -141
View File
@@ -1,16 +1,50 @@
# =============================================================================
# Turnstone Docker Compose Stack — Development
# Turnstone — local cluster stack (docker compose)
#
# This file is for local development from a git clone. It builds images
# locally from the Dockerfile. If you installed via pip/pipx, run
# `turnstone-bootstrap` instead — it writes a production compose.yaml
# that pulls pre-built images from ghcr.io.
# Clone the repo and run:
#
# Usage:
# Infra only: docker compose up
# Single node: docker compose --profile production up
# Production (PG): TURNSTONE_DB_BACKEND=postgresql docker compose --profile production up
# 10-node cluster: docker compose --profile cluster up
# docker compose up
#
# That builds one image and brings up a complete, console-visible cluster:
# PostgreSQL + console + Caddy + channel gateway + 10 server nodes (node-1…10).
#
# Dashboard: https://localhost:8443 (Caddy's local CA — trust it once)
#
# Access is via Caddy only — the console's plain-HTTP port is intentionally not
# published (HTTP/2 from Caddy avoids the browser's 6-connection cap on the
# dashboard's SSE streams). Trust Caddy's root once:
# docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt
#
# It works out of the box with INSECURE dev defaults (see the secret/password
# values below) so there's nothing to configure first. A .env file still
# overrides any value. For a real deployment use the bundled production stack
# at turnstone/deploy/compose.yaml — it pulls released images from ghcr.io and
# requires you to set real secrets.
#
# Bring your own LLM: nodes boot without one and show up in the console
# immediately. Add model backends (OpenAI / Anthropic / local vLLM) from the
# console UI's Models tab, or point LLM_BASE_URL / OPENAI_API_KEY (below) at an
# OpenAI-compatible endpoint.
#
# Fewer nodes (lighter machines):
# docker compose up postgres console caddy channel node-1 node-2 node-3
#
# Join a bare-metal host: Postgres is published on 127.0.0.1:5432, so a
# turnstone-server running directly on this machine (e.g. to use a local GPU)
# can join the same cluster. Keep the secret + connection settings in
# ~/.config/turnstone/config.toml (chmod 0600 — the loader warns otherwise):
# [auth]
# jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments"
# [database]
# backend = "postgresql"
# url = "postgresql+psycopg://turnstone:turnstone@localhost:5432/turnstone"
# [api]
# base_url = "http://localhost:8000/v1"
# api_key = "dummy"
# then run (node identity isn't a secret, so it stays on the command line):
# TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
# turnstone-server --host 0.0.0.0 --port 8080
# It registers in Postgres and the console reaches it back via host.docker.internal.
# =============================================================================
name: turnstone
@@ -23,16 +57,30 @@ volumes:
turnstone-data:
workspace:
postgres-data:
caddy-data:
caddy-config:
searxng-cache:
# -- Shared values (scalar anchors) -------------------------------------------
# Defined once here, referenced (*alias) by every service so the dev defaults
# can't drift. All `${VAR:-default}` values are still overridable via .env.
x-shared:
# INSECURE dev default. Every service MUST share ONE secret — the console
# mints its own service token (signed with this) to reach the nodes. Override
# TURNSTONE_JWT_SECRET in .env for anything that isn't a local sandbox.
jwt-secret: &jwt-secret "${TURNSTONE_JWT_SECRET:-dev-only-insecure-jwt-secret-change-me-for-real-deployments}"
db-backend: &db-backend "${TURNSTONE_DB_BACKEND:-postgresql}"
# All services point at the same Postgres. Node discovery REQUIRES a shared
# DB: each server registers + heartbeats into a `services` table that the
# console polls. (SQLite-per-container can't see other containers.)
db-url: &db-url "${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}"
services:
# -------------------------------------------------------------------
# PostgreSQL — production database (profile: production)
# PostgreSQL — the shared database that ties the cluster together.
# -------------------------------------------------------------------
postgres:
image: pgautoupgrade/pgautoupgrade:18-alpine
profiles:
- production
- cluster
command:
- postgres
- -c
@@ -42,8 +90,16 @@ services:
environment:
POSTGRES_DB: turnstone
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile}
# INSECURE dev default — override POSTGRES_PASSWORD in .env for real use.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-turnstone}
PGDATA: /var/lib/postgresql/data
# Published on localhost so a bare-metal turnstone-server running on THIS
# host can join the cluster (see "Join a bare-metal host" in the header).
# Bound to 127.0.0.1 by default; set POSTGRES_BIND=0.0.0.0 to let another
# machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose
# a database with the insecure default password to your network.
ports:
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
@@ -57,65 +113,21 @@ services:
deploy:
resources:
limits:
memory: 4G
cpus: '4.0'
memory: 2G
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-server — Web UI + chat workstreams + LLM interaction
# -------------------------------------------------------------------
server:
image: turnstone:local
profiles:
- production
command:
- sh
- -c
- >-
turnstone-server
--host 0.0.0.0
--port 8080
--base-url "$${LLM_BASE_URL}"
--api-key "$${OPENAI_API_KEY}"
$${MODEL:+--model $$MODEL}
$${SKIP_PERMISSIONS:+--skip-permissions}
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
ports:
- "${SERVER_PORT:-8080}:8080"
volumes:
- turnstone-data:/data
- ${WORKSPACE_MOUNT:-workspace}:/workspace
environment:
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- MODEL=${MODEL:-}
- MCP_CONFIG=${MCP_CONFIG:-}
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-}
- TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-}
- TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080}
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- turnstone-net
depends_on:
postgres:
condition: service_healthy
required: false
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 60s
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-console — Cluster dashboard
# turnstone-console — cluster dashboard. Reach it ONLY through Caddy at
# https://localhost:8443 (see the caddy service below).
#
# The console port (8090) is deliberately NOT published to the host: a plain
# HTTP/1.1 origin caps the browser at 6 connections, which starves the
# dashboard's per-pane SSE streams. Caddy serves the browser over HTTP/2
# (multiplexed) and proxies to console:8090 internally, so the cap is gone.
#
# The single `build:` here produces the turnstone:local image every other
# service reuses. extra_hosts lets the console reach a bare-metal server
# advertising http://host.docker.internal:8080 (see "Join a host" below).
# -------------------------------------------------------------------
console:
image: turnstone:local
@@ -126,16 +138,18 @@ services:
- turnstone-console
- --host=0.0.0.0
- --port=8090
ports:
- "${CONSOLE_PORT:-8090}:8090"
environment:
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-}
- TURNSTONE_CONSOLE_URL=http://console:8090
TURNSTONE_JWT_SECRET: *jwt-secret
TURNSTONE_DB_BACKEND: *db-backend
TURNSTONE_DB_URL: *db-url
TURNSTONE_CONSOLE_URL: http://console:8090
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- turnstone-net
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"]
interval: 10s
@@ -145,14 +159,33 @@ services:
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-channel — Channel gateway (Discord, Slack, etc.)
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
# caddy — browser TLS for the console dashboard.
# Terminates HTTPS (Caddy's own local CA, see turnstone/deploy/Caddyfile) → console:8090.
# Dashboard over TLS: https://localhost:${CONSOLE_HTTPS_PORT:-8443}
# -------------------------------------------------------------------
caddy:
image: caddy:2.11
depends_on:
- console
ports:
- "${CONSOLE_HTTPS_PORT:-8443}:443"
# SearxNG web UI — localhost-only (it has no auth). Browse https://localhost:8444.
- "127.0.0.1:${SEARXNG_HTTPS_PORT:-8444}:8444"
volumes:
- ./turnstone/deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data # persist Caddy's local CA across restarts
- caddy-config:/config
networks:
- turnstone-net
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-channel — channel gateway (Discord and/or Slack).
# Runs HTTP-only with no adapters until you set a token, so it's safe
# to leave running. See docs/channels.md.
# -------------------------------------------------------------------
channel:
image: turnstone:local
profiles:
- production
- cluster
command:
- sh
- -c
@@ -161,36 +194,59 @@ services:
--http-host=0.0.0.0
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
environment:
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-postgresql}
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
TURNSTONE_JWT_SECRET: *jwt-secret
TURNSTONE_DB_BACKEND: *db-backend
TURNSTONE_DB_URL: *db-url
TURNSTONE_DISCORD_TOKEN: ${TURNSTONE_DISCORD_TOKEN:-}
TURNSTONE_DISCORD_GUILD: ${TURNSTONE_DISCORD_GUILD:-0}
TURNSTONE_SLACK_TOKEN: ${TURNSTONE_SLACK_TOKEN:-}
TURNSTONE_SLACK_APP_TOKEN: ${TURNSTONE_SLACK_APP_TOKEN:-}
TURNSTONE_CHANNEL_ADVERTISE_URL: http://channel:8091
networks:
- turnstone-net
depends_on:
postgres:
condition: service_healthy
required: false
restart: unless-stopped
# -------------------------------------------------------------------
# searxng — self-hosted metasearch backing the web_search tool.
# Internal-network only (no published port): nodes reach it at
# http://searxng:8080. Config (JSON output on, limiter off) lives in
# turnstone/deploy/searxng/settings.yml, mounted read-only. Commercial
# models use native provider search and never hit this; it serves
# local/vLLM models. Override the tag with SEARXNG_IMAGE_TAG in .env.
# -------------------------------------------------------------------
searxng:
image: searxng/searxng:${SEARXNG_IMAGE_TAG:-latest}
volumes:
- ./turnstone/deploy/searxng:/etc/searxng:ro
- searxng-cache:/var/cache/searxng # favicon + internal SQLite cache (survives restarts)
networks:
- turnstone-net
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
# ===================================================================
# 10-node cluster (profile: cluster)
# Server fleet — node-1 … node-10
#
# All nodes share the same PostgreSQL instance.
# Access via console at :8090.
# Each node registers itself in Postgres on boot (unique
# TURNSTONE_NODE_ID + TURNSTONE_ADVERTISE_URL) and the console
# discovers it automatically — no static node list anywhere.
#
# Start: docker compose --profile cluster up
# node-1 carries the shared definition (&node / &node-env); node-2…10
# inherit it and override only their identity.
# ===================================================================
# -- cluster servers ------------------------------------------------
server-1: &cluster-server
node-1: &node
image: turnstone:local
build: { context: ., dockerfile: Dockerfile }
profiles: [cluster]
build:
context: .
dockerfile: Dockerfile
command:
- sh
- -c
@@ -206,23 +262,30 @@ services:
volumes:
- turnstone-data:/data
- ${WORKSPACE_MOUNT:-workspace}:/workspace
environment: &cluster-server-env
environment: &node-env
TURNSTONE_JWT_SECRET: *jwt-secret
TURNSTONE_DB_BACKEND: *db-backend
TURNSTONE_DB_URL: *db-url
# Bootstrap LLM defaults — real backends are configured in the console UI.
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
# web_search backend. Defaults to the bundled searxng service; point at an
# external SearxNG by setting TURNSTONE_SEARXNG_URL in .env (empty disables).
TURNSTONE_SEARXNG_URL: ${TURNSTONE_SEARXNG_URL:-http://searxng:8080}
MODEL: ${MODEL:-}
MCP_CONFIG: ${MCP_CONFIG:-}
TURNSTONE_DB_BACKEND: ${TURNSTONE_DB_BACKEND:-postgresql}
TURNSTONE_DB_URL: ${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
TURNSTONE_NODE_ID: node-1
TURNSTONE_ADVERTISE_URL: http://server-1:8080
extra_hosts: ["host.docker.internal:host-gateway"]
networks: [turnstone-net]
TURNSTONE_ADVERTISE_URL: http://node-1:8080
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- turnstone-net
depends_on:
postgres: { condition: service_healthy }
postgres:
condition: service_healthy
searxng:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
interval: 10s
@@ -231,34 +294,34 @@ services:
start_period: 60s
deploy:
resources:
limits: { memory: 4G, cpus: '4' }
limits:
memory: 4G
restart: unless-stopped
server-2:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-2, TURNSTONE_ADVERTISE_URL: "http://server-2:8080" }
server-3:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-3, TURNSTONE_ADVERTISE_URL: "http://server-3:8080" }
server-4:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-4, TURNSTONE_ADVERTISE_URL: "http://server-4:8080" }
server-5:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-5, TURNSTONE_ADVERTISE_URL: "http://server-5:8080" }
server-6:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-6, TURNSTONE_ADVERTISE_URL: "http://server-6:8080" }
server-7:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-7, TURNSTONE_ADVERTISE_URL: "http://server-7:8080" }
server-8:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-8, TURNSTONE_ADVERTISE_URL: "http://server-8:8080" }
server-9:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-9, TURNSTONE_ADVERTISE_URL: "http://server-9:8080" }
server-10:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://server-10:8080" }
node-2:
<<: *node
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-2, TURNSTONE_ADVERTISE_URL: "http://node-2:8080" }
node-3:
<<: *node
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-3, TURNSTONE_ADVERTISE_URL: "http://node-3:8080" }
node-4:
<<: *node
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-4, TURNSTONE_ADVERTISE_URL: "http://node-4:8080" }
node-5:
<<: *node
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-5, TURNSTONE_ADVERTISE_URL: "http://node-5:8080" }
node-6:
<<: *node
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-6, TURNSTONE_ADVERTISE_URL: "http://node-6:8080" }
node-7:
<<: *node
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-7, TURNSTONE_ADVERTISE_URL: "http://node-7:8080" }
node-8:
<<: *node
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-8, TURNSTONE_ADVERTISE_URL: "http://node-8:8080" }
node-9:
<<: *node
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-9, TURNSTONE_ADVERTISE_URL: "http://node-9:8080" }
node-10:
<<: *node
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://node-10:8080" }
-40
View File
@@ -1,40 +0,0 @@
# Bare-metal overlay — expose PostgreSQL and let the console reach
# a turnstone-server running outside Docker on the host machine.
#
# Requires TURNSTONE_HOST_IP set to the host's routable IP address.
#
# Usage:
# export TURNSTONE_HOST_IP="$(hostname -I | awk '{print $1}')"
# docker compose --profile production \
# -f compose.yaml -f deploy/docker-compose.bare-metal.yml up
#
# Then on the host:
# export TURNSTONE_JWT_SECRET="<same as .env>"
# export TURNSTONE_DB_BACKEND=postgresql
# export TURNSTONE_DB_URL="postgresql://turnstone:<pw>@localhost:5432/turnstone"
# export TURNSTONE_NODE_ID="bare-metal-1"
# export TURNSTONE_ADVERTISE_URL="http://${TURNSTONE_HOST_IP}:8080"
# python -m turnstone.server --host 0.0.0.0 --port 8080 \
# --base-url http://localhost:8000/v1 --api-key "$OPENAI_API_KEY"
services:
postgres:
ports:
- "${POSTGRES_PORT:-5432}:5432"
console:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
# Console needs to reach the bare-metal server on the host
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
channel:
ports:
- "${CHANNEL_PORT:-8091}:8091"
environment:
# Channel gateway advertises with host-routable IP so the
# bare-metal server can reach it for schedule notifications
TURNSTONE_CHANNEL_ADVERTISE_URL: "http://${TURNSTONE_HOST_IP}:${CHANNEL_PORT:-8091}"
# Channel needs to reach the bare-metal server on the host
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
+5 -4
View File
@@ -1,7 +1,8 @@
# TLS overlay — enables mTLS across the turnstone cluster.
# TLS overlay — enables mTLS across the turnstone deployment.
#
# Usage (requires base compose.yaml with production profile):
# docker compose -f compose.yaml -f deploy/docker-compose.tls.yml --profile production up
# Layers on the production stack (it patches the `server`, `console`, and
# `channel` services that file defines):
# docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up
#
# The tls-init service bootstraps a CA and issues certs.
# All turnstone services auto-provision their own certs via the
@@ -12,7 +13,7 @@ services:
# Runs as root to create directories in the volume, then chowns
# to turnstone:turnstone with restrictive perms (keys 0600).
tls-init:
build: .
image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}
user: root
command:
- sh
+1 -1
View File
@@ -7,6 +7,6 @@ appVersion: "0.3.0"
dependencies:
- name: postgresql
version: ~18.6.0
version: ~18.7.0
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
+10 -5
View File
@@ -103,13 +103,18 @@ network_policies:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- Web search fallback (Tavily) ---
# --- Web search (SearxNG) ---
# Turnstone talks only to its SearxNG instance over HTTP; SearxNG itself makes
# the outbound calls to search engines (and is NOT governed by this policy —
# it runs as a separate service). The host/port below is the bundled compose
# service name; if your SearxNG runs elsewhere, set it to match
# TURNSTONE_SEARXNG_URL.
tavily_api:
name: tavily-search
searxng:
name: searxng-search
endpoints:
- host: api.tavily.com
port: 443
- host: searxng
port: 8080
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
+75 -11
View File
@@ -2,13 +2,69 @@
"""Health check for turnstone containers.
Usage: healthcheck.py <url>
Exit 0 if the endpoint returns {"status": "ok"}, exit 1 otherwise.
Uses only stdlib — no pip dependencies required.
Exit 0 if the endpoint returns {"status": "ok"} or {"status": "degraded"},
exit 1 otherwise. Uses only stdlib — no pip dependencies required.
When the node serves mTLS (tls.enabled), a plain-HTTP probe is rejected at
the socket, so on failure this script retries over HTTPS, presenting the
node's own certificate as the client cert and pinning the cluster CA. The
PEM files are the ones the server writes at boot under
$TURNSTONE_TLS_PEM_DIR (default: <tmpdir>/turnstone-tls). The host is
rewritten to "localhost" for the TLS attempt because the internal CA issues
DNS SANs only — certificate verification rejects a literal-IP dial.
When mTLS is disabled (the default), the plain probe succeeds and nothing
here changes: the PEM directory is never consulted.
"""
import json
import os
import ssl
import sys
import tempfile
import urllib.request
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
def _check(url: str, context: ssl.SSLContext | None = None) -> None:
"""Probe one URL; raise if unreachable or the payload is unhealthy."""
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=5, context=context) as resp:
data = json.loads(resp.read().decode())
if data.get("status") not in ("ok", "degraded"):
raise RuntimeError(f"unhealthy payload: {data}")
def _pem_root() -> Path:
"""PEM runtime root.
Must mirror turnstone.core.tls.tls_pem_runtime_dir — this script is
standalone stdlib and cannot import turnstone; a drift-guard test in
tests/test_docker_healthcheck.py pins the two together.
"""
root_env = os.environ.get("TURNSTONE_TLS_PEM_DIR")
return Path(root_env) if root_env else Path(tempfile.gettempdir()) / "turnstone-tls"
def _find_pem_dir() -> Path | None:
"""Locate the newest complete PEM dir written by the server at boot."""
root = _pem_root()
candidates = [
d
for d in root.glob("lacme-pem-*")
if all((d / name).is_file() for name in ("fullchain.pem", "key.pem", "ca.pem"))
]
if not candidates:
return None
return max(candidates, key=lambda d: d.stat().st_mtime)
def _tls_url(url: str) -> str:
"""Rewrite scheme to https and host to localhost, keeping port and path."""
parts = urlsplit(url)
netloc = f"localhost:{parts.port}" if parts.port else "localhost"
return urlunsplit(("https", netloc, parts.path, parts.query, parts.fragment))
def main() -> None:
@@ -18,16 +74,24 @@ def main() -> None:
url = sys.argv[1]
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode())
if data.get("status") in ("ok", "degraded"):
sys.exit(0)
print(f"Unhealthy: {data}", file=sys.stderr)
_check(url)
sys.exit(0)
except Exception as plain_exc:
pem_dir = _find_pem_dir()
if pem_dir is None:
print(f"Health check failed: {plain_exc}", file=sys.stderr)
sys.exit(1)
try:
context = ssl.create_default_context(cafile=str(pem_dir / "ca.pem"))
context.load_cert_chain(str(pem_dir / "fullchain.pem"), str(pem_dir / "key.pem"))
_check(_tls_url(url), context=context)
sys.exit(0)
except Exception as tls_exc:
print(
f"Health check failed: plain: {plain_exc}; mtls: {tls_exc}",
file=sys.stderr,
)
sys.exit(1)
except Exception as exc:
print(f"Health check failed: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
-37
View File
@@ -455,13 +455,6 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) |
| `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) |
**`plan_review`** -- the model is proposing a plan and wants feedback. The
client must respond via `POST /v1/api/plan`.
```json
{"type": "plan_review", "content": "Step 1: ...\nStep 2: ..."}
```
**`info`** -- an informational message (e.g. command output).
```json
@@ -779,36 +772,6 @@ automatically approved without prompting.
---
### `POST /v1/api/plan`
Responds to a plan review dialog. The SSE stream must have previously sent a
`plan_review` event for the given workstream.
**Request body:**
```json
{"feedback": "", "ws_id": "abc123"}
```
| Field | Type | Required | Description |
|------------|--------|----------|---------------------------------------------------------|
| `feedback` | string | yes | Feedback text; empty string means approval |
| `ws_id` | string | yes | Target workstream ID |
To approve the plan, send an empty string for `feedback`. To reject or request
changes, send a non-empty feedback string (e.g. `"reject"` or specific
revision instructions).
**Response:**
```json
{"status": "ok"}
```
**Error:** `404` with `{"error": "Unknown workstream"}` if `ws_id` is invalid.
---
### `POST /v1/api/command`
Executes a slash command in the given workstream.
+92 -53
View File
@@ -3,8 +3,8 @@
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
Anthropic's native Messages API via pluggable provider adapters, and gives the
model 19 built-in tools plus external tools via MCP (Model Context Protocol) for
reading, writing, searching, planning, and executing code.
model 16 built-in tools plus external tools via MCP (Model Context Protocol) for
reading, writing, searching, and executing code.
The core design principle is a **UI-agnostic engine with pluggable frontends**.
The engine (`ChatSession`) drives the conversation loop -- streaming, tool
@@ -61,7 +61,6 @@ turnstone/
ratelimit.py Per-IP token-bucket rate limiter (RateLimiter, TokenBucket)
edit.py File edit utilities (find_occurrences, pick_nearest)
safety.py Command safety validation (blocked patterns, sanitization)
sandbox.py Math code sandboxing (AST validation, subprocess execution)
web.py Web utilities (HTML stripping, SSRF prevention)
api/
schemas.py Shared Pydantic v2 models (auth, errors, WorkstreamState)
@@ -91,7 +90,7 @@ turnstone/
discord/ Discord adapter (bot, cog, views, streaming, config)
slack/ Slack adapter (Socket Mode bot, DM routing, approval buttons)
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
katex-0.16.47/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
katex-0.17.0/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
ui/
colors.py ANSI color constants with NO_COLOR support
markdown.py Streaming terminal markdown renderer (line-buffered)
@@ -102,7 +101,7 @@ turnstone/
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
tools/
*.json 19 tool schemas (OpenAI function-calling format + turnstone metadata)
*.json 16 tool schemas (OpenAI function-calling format + turnstone metadata)
```
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
@@ -190,7 +189,6 @@ Phase 3: EXECUTE (parallel)
(cancel_event also checked per line — kills process group on cancel)
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
call_id links tool_info items → streaming chunks → final result
For plan tool: post-execution gate via ui.on_plan_review()
```
### State Transitions
@@ -209,7 +207,7 @@ The engine emits state changes via `_emit_state()` which calls
"running" ---> tool execution
|
v
"attention" ---> waiting for user approval / plan review
"attention" ---> waiting for user approval
|
v
"running" ---> executing approved tools
@@ -231,7 +229,7 @@ The engine emits state changes via `_emit_state()` which calls
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 16
Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 15
methods. Every frontend must implement all of them.
```python
@@ -247,7 +245,6 @@ class SessionUI(Protocol):
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
def on_plan_review(self, content: str) -> str: ...
def on_info(self, message: str) -> None: ...
def on_error(self, message: str) -> None: ...
def on_state_change(self, state: str) -> None: ...
@@ -269,7 +266,7 @@ the per-workstream events stream in
| Class | Module | Notes |
|-------|--------|-------|
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval/plan. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
### WorkstreamTerminalUI
@@ -281,10 +278,9 @@ awareness:
are appended to `_output_buffer` instead of written to stdout. When the user
switches to this workstream, `flush_buffer()` replays them.
- **Approval blocking**: `approve_tools()` and `on_plan_review()` call
`_fg_event.wait()` when in background, blocking the worker thread until the
workstream is foregrounded. This ensures the user sees the approval prompt
in the correct context.
- **Approval blocking**: `approve_tools()` calls `_fg_event.wait()` when in
background, blocking the worker thread until the workstream is foregrounded.
This ensures the user sees the approval prompt in the correct context.
- **Foreground/background toggle**: `set_foreground(bool)` sets or clears
`_fg_event` (a `threading.Event`). The manager calls this during `/ws <N>`
@@ -421,7 +417,6 @@ turnstone metadata keys:
| Metadata Key | Type | Meaning |
|-------------|------|---------|
| `agent` | `bool` | Include this tool when running as a plan/task sub-agent |
| `task_agent` | `bool` | Include this tool when running as a task sub-agent |
| `auto_approve` | `bool` | Tool is read-only; skip user approval |
| `primary_key` | `str` | Fallback argument name for bare-string JSON recovery |
@@ -441,7 +436,6 @@ Example (`read_file.json`):
},
"required": ["path"]
},
"agent": true,
"task_agent": true,
"auto_approve": true,
"primary_key": "path"
@@ -452,19 +446,17 @@ At import time, `turnstone.core.tools._load_tools()` strips the metadata keys
from each schema and builds:
- `TOOLS` -- list of `{"type": "function", "function": {...}}` dicts for the API
- `AGENT_TOOLS` -- subset with `agent: true`
- `TASK_AGENT_TOOLS` -- subset with `task_agent: true`
- `AGENT_AUTO_TOOLS` / `TASK_AUTO_TOOLS` -- sets of tool names with `auto_approve: true`
- `TASK_AUTO_TOOLS` -- set of tool names with `auto_approve: true`
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
### 19 Tools by Category
### 16 Tools by Category
**Read-only (auto-approve)**:
- `read_file` -- read file contents with optional offset/limit
- `diff_file` -- show diff between two files / versions
- `search` -- ripgrep-based codebase search
- `man` -- read man pages
- `recall` -- search conversation history
- `read_resource` -- read an MCP resource by URI
@@ -472,23 +464,21 @@ from each schema and builds:
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
- `write_file` -- create or overwrite a file
- `edit_file` -- string replacement in an existing file (requires prior `read_file`)
- `math` -- execute Python in sandboxed subprocess (via `turnstone.core.sandbox`)
- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`)
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, Tavily fallback for local models)
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, self-hosted SearxNG fallback for local models)
- `notify` -- send a user-facing notification (Discord/Slack, optional reply routing)
- `watch` -- schedule a recurring poll with condition DSL
**Agent (delegated sub-sessions)**:
- `task_agent` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
- `plan_agent` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
**Memory / skills / prompts**:
- `memory` -- save, search, delete, or list memories (typed and scoped)
- `skill` -- invoke a skill (governed, versioned procedure)
- `use_prompt` -- fetch and apply a prompt template
Tool names are `plan_agent` / `task_agent` (not `plan` / `task`); bare words
collide with chat-template channels on some local models.
The tool name uses the `_agent` suffix — bare `task` collides with
chat-template channels on some local models.
### Prepare / Execute Pattern
@@ -507,17 +497,11 @@ separation allows the UI to show previews before any side effects occur.
### Agent Tools
`task_agent` and `plan_agent` invoke `_run_agent()`, which runs a multi-turn
loop with a subset of tools and its own system prompt. The sub-agent runs
independently, then returns the final content as the tool result.
`task_agent` invokes `_run_agent()`, which runs a multi-turn loop with a
subset of tools and its own system prompt. The sub-agent runs independently,
then returns the final content as the tool result.
- **task_agent**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
- **plan_agent**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
to `.plan-<ws_id>.md` — unique per `ChatSession` so concurrent workstreams
don't collide. On repeat invocations the prior `plan_agent` tool call and its result
are forwarded from `self.messages` so the agent refines the existing plan rather
than starting over. Planning instructions are injected as a developer message
prepended to the agent's conversation.
- **Turn limit**: controlled by `agent_max_turns` (default: `-1`, unlimited).
When a limit is set and reached, the agent is forced to synthesize a final
response without tools. When unlimited, the loop only exits when the model
@@ -564,8 +548,8 @@ adds, removes, or reconnects servers as needed.
When tools change, `_rebuild_tools()` creates new `_tools`/`_tool_map` objects
(copy-on-write for thread safety) and notifies listener callbacks. Each `ChatSession`
rebuilds its merged tool lists and reconstructs `ToolSearchManager` (preserving
expanded tools).
rebuilds its `_tools` and `_task_tools` lists and reconstructs `ToolSearchManager`
(preserving expanded tools).
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
at connection time (server names with `__` are rejected).
@@ -650,7 +634,7 @@ annotations are formatted as footnotes. Extended prompt cache retention
(`prompt_cache_retention: "24h"`) is enabled for GPT-5.x models at no
additional cost. Cached token counts are extracted from
`usage.prompt_tokens_details.cached_tokens`. Unknown models (local servers) get
permissive defaults with `supports_vision=False` and use Tavily for web search.
permissive defaults with `supports_vision=False` and use SearxNG for web search.
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
Anthropic content blocks, maps `system`/`developer` roles to the `system`
@@ -668,9 +652,8 @@ display). Automatic prompt caching is enabled via top-level `cache_control:
cacheable block and advances it as conversations grow (90% input cost
reduction on cache hits, 1.25x write on first turn). Cache metrics
(`cache_creation_input_tokens`, `cache_read_input_tokens`) are extracted from
both streaming and non-streaming responses. The `anthropic` SDK is imported
lazily so it remains an optional dependency (`pip install
turnstone[anthropic]`).
both streaming and non-streaming responses. The `anthropic` SDK is a core
dependency — the Anthropic provider is first-class alongside OpenAI.
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
the Gemini `/v1beta/openai/` endpoint. Uses a single default
@@ -719,7 +702,7 @@ agent_model = "claude"
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
and `"openai-compatible"`.
`"openai-compatible"`, and `"anthropic-compatible"`.
**Per-model sampling overrides:** Each model can specify `temperature`,
`max_tokens`, and `reasoning_effort` to override the global defaults from
@@ -782,6 +765,63 @@ model = "qwen-3.5-vl"
supports_vision = true
```
**Anthropic-compatible local servers (vLLM `/v1/messages`):** the
`"anthropic-compatible"` provider drives local servers that expose
Anthropic's Messages API for arbitrary checkpoints — vLLM's
`/v1/messages` endpoint, which requires a release with thinking-block
support in the Anthropic endpoint (post-2026-02-28; verified against
v0.22.1rc1). The lane reuses `AnthropicProvider` in compat mode: same
wire translation as the real Anthropic lane, but every model resolves to
the `_ANTHROPIC_COMPAT_DEFAULT` capabilities (200K context, 64K output,
`token_param=max_tokens`, `thinking_mode=none`, no native
web_search/tool_search, no vision) — the static Claude table never
applies to local checkpoints. `base_url` is required — the server root
WITHOUT `/v1` (the Anthropic SDK appends `/v1/messages`); a trailing
`/v1` pasted out of openai-compatible habit is stripped automatically,
and an empty value fails at client construction rather than falling
back to the commercial endpoint. Set a
placeholder `api_key` (e.g. `"dummy"`) for unauthenticated servers. Tool calling
needs the server started with `--enable-auto-tool-choice
--tool-call-parser <family>` plus the matching reasoning parser.
Per-model capability overrides opt in to what the checkpoint actually
supports:
```toml
[models.vllm-claude]
provider = "anthropic-compatible"
base_url = "http://localhost:8000" # no /v1 — the SDK appends /v1/messages
api_key = "dummy"
model = "deepseek-ai/DeepSeek-V4-Flash"
[models.vllm-claude.capabilities]
supports_vision = true # multimodal checkpoints only
supports_mid_conversation_system = true # template-dependent
context_window = 131072
```
The reasoning toggle does NOT use Anthropic's `thinking` request param.
Toggle it through the chat template instead: set `{"chat_template_kwargs":
{"thinking": false}}` as extra body params in the admin Models
server-compat section (for this provider the section shows only the
extra-body field — server type, API surface, and thinking mode are
openai-compatible-only knobs); the provider forwards it via the SDK's
`extra_body`.
Verified quirks of vLLM's Anthropic endpoint:
* The `thinking` request param is silently dropped — use
`chat_template_kwargs` (above) to control reasoning.
* `stop_sequences` cut the raw stream wherever the text appears —
including inside thinking — and report `end_turn` with
`stop_sequence=None`. Turnstone does not send stop sequences from
this provider.
* No cache telemetry: `usage` carries input/output token counts only
(no `cache_creation_input_tokens` / `cache_read_input_tokens`).
* Images require a multimodal checkpoint — text-only models return a
500 on image blocks, so `supports_vision` stays opt-in per model.
* Mid-conversation `role: "system"` turns are template-dependent —
opt in per model via `supports_mid_conversation_system`.
**Database model definitions:** On server entry points, models can also be
defined in the `model_definitions` table (admin Models tab). DB models support
the same per-model sampling overrides. Config.toml models override DB models
@@ -803,7 +843,7 @@ with the same alias in-memory (the DB rows are never modified).
parameters
6. `_create_stream_with_retry()` tries the primary model, then each fallback
alias in order if the primary is unreachable
7. `_run_agent()` resolves `registry.agent_model` (if set) for plan/task
7. `_run_agent()` resolves `registry.agent_model` (if set) for task
sub-agents, allowing a cheaper model for autonomous loops
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
@@ -812,7 +852,7 @@ which can override the model before workstream creation.
### Tool Output Truncation
Tool execution results (bash, read_file, search, math, man) are truncated by
Tool execution results (bash, read_file, search) are truncated by
`_truncate_output()` when they exceed `tool_truncation` characters. Truncation
preserves the first half and last half of the output, with a message in
between:
@@ -1058,11 +1098,12 @@ warns if the summary was truncated.
unhandled promise rejections
- **Pending approval across tab switches**: `WebUI._pending_approval` stores
the `approve_request` event payload while the session is blocked waiting
for user response. On SSE reconnect (e.g., switching back to the tab),
the event is re-injected after history replay. `_build_history` marks the
pending tool call as `"pending": true` so `replayHistory` skips the
false `✓ approved` badge; the live approval UI is rendered by the
re-injected event instead.
for user response. On tab switch / reconnect the pane reloads history via
REST `GET /history` and then reconnects SSE; the live approval event is
re-injected. The server-side `project_history_messages` projection marks
the trailing orphan tool-call turn `"pending": true` so `replayHistory`
skips the false `✓ approved` badge; the live approval UI is rendered by
the re-injected event instead.
- **Browser history integration**: `history.pushState` is called in
`switchTab()` with `{turnstone: 'workstream', wsId}`. The initial state is
seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The
@@ -1239,7 +1280,6 @@ Starlette ASGI app (served by uvicorn)
+-- Async request handlers (all under /v1/ prefix)
| POST /v1/api/workstreams/{ws_id}/send -> starts worker thread per workstream
| POST /v1/api/workstreams/{ws_id}/approve -> unblocks WebUI._approval_event
| POST /v1/api/plan -> unblocks WebUI._plan_event
| POST /v1/api/workstreams/new -> creates workstream + worker
| GET /v1/api/workstreams/{ws_id}/events -> SSE via EventSourceResponse (per workstream)
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
@@ -1249,7 +1289,7 @@ Starlette ASGI app (served by uvicorn)
|
+-- Worker thread per workstream (daemon)
| Runs session.send() synchronously -- ChatSession is fully blocking
| Blocks on WebUI._approval_event / _plan_event (threading.Event)
| Blocks on WebUI._approval_event (threading.Event)
|
+-- Background daemon threads
Global SSE fan-out: reads global_queue, copies to per-client queues
@@ -1273,7 +1313,7 @@ registry).
Each workstream's `WebUI` has:
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
- `_approval_event` / `_plan_event` (`threading.Event` for blocking)
- `_approval_event` (`threading.Event` for blocking)
- `_global_queue` (class variable, shared, for state broadcasts)
The SSE handlers bridge these sync queues to async via
@@ -1529,8 +1569,7 @@ implemented in `turnstone/core/judge.py`:
The judge is session-scoped (`IntentJudge`), lazy-initialized on first
approval, and configured via the `[judge]` config section or `--judge` CLI
flags. By default it uses self-consistency (same model), but supports
cross-model and cross-provider configurations. Sub-agents (plan, task)
are exempt. All verdicts are persisted to the `intent_verdicts` table
cross-model and cross-provider configurations. Task sub-agents are exempt. All verdicts are persisted to the `intent_verdicts` table
(migration 012) with the user's final decision, enabling future calibration.
The console exposes `GET /v1/api/admin/verdicts` for audit queries
(requires `admin.judge` permission).
+5 -15
View File
@@ -99,10 +99,10 @@ TURNSTONE_DISCORD_GUILD=123456789
Then start the stack:
```bash
docker compose --profile production up
docker compose up
```
The `channel` service starts automatically when
The `channel` gateway runs by default; the Discord adapter activates once
`TURNSTONE_DISCORD_TOKEN` is set.
### 3. Link User Accounts
@@ -179,7 +179,6 @@ both and the gateway hosts both adapters in one process.
see starts a per-user channel session.
- Tool approvals render as Slack **Block Kit** buttons; only the user
who owns the workstream can approve/reject.
- Plan reviews render as a modal with approve / request-changes actions.
- Notifications and reply routing work identically to Discord.
- Session recovery: persisted channel routes are re-subscribed when the
bot restarts, so existing Slack conversations keep flowing.
@@ -235,15 +234,6 @@ config, the bot auto-responds with approval and posts a
field (useful for allowing specific tools like `bash` or `read_file` while
still requiring manual approval for others).
### Plan Reviews
Plan review requests are displayed as a blue embed with:
- **Approve Plan** (green) button — approves the plan with empty feedback
- **Request Changes** (gray) button — opens a modal for feedback text
(up to 2000 characters)
- Feedback is forwarded to the server via HTTP
---
## Configuration Reference
@@ -431,9 +421,9 @@ message with a `ws_id` so that user replies can be routed back to the
originating workstream. Adapters must track the mapping from outgoing
message ID to `(ws_id, target_user_id)` and handle DM replies.
Platform-specific concerns — approval prompts, plan reviews, message
edits, thread creation — live inside the adapter implementation and are
not part of the protocol surface. Each adapter drives those via its
Platform-specific concerns — approval prompts, message edits, thread
creation — live inside the adapter implementation and are not part of
the protocol surface. Each adapter drives those via its
own `_on_ws_event` dispatcher using SDK-native APIs.
To add a new platform:
+12 -5
View File
@@ -237,14 +237,21 @@ Key properties:
tool with a fresh timeout.
- **Modes**`mode="any"` returns as soon as one child reaches a
real terminal state (`idle` / `error` / `closed` / `deleted`);
`mode="all"` waits for every polled child.
`mode="all"` waits for every polled child to reach a real
terminal state.
- **Progress throttling** — the poll loop runs every 500 ms but the
SSE emission is diff-on-state-change plus a 5-second heartbeat. A
600 s wait generates O(dozens) of progress events, not 1200.
- **Denied rows** — an id the caller doesn't own (cross-tenant) or a
missing row is reported as a `denied` state in the results dict;
`mode="any"` won't satisfy on a pure-denied list (the LLM should
treat it as a config error, not a completion).
- **Unresolvable ids** — ws_ids are validated up front (exactly
32 hex chars; copy them verbatim): a malformed id fails the call
immediately with did-you-mean suggestions and a roster of the
coord's children. An id the caller doesn't own, a missing row, or
a child hard-deleted mid-wait is reported as `state="not_found"`
and aborts the wait on the tick that observes it (top-level
`error` / `not_found` / `children` fields, `complete=false`) — the
LLM should fix the id and re-issue, not conclude the child died.
Foreign and missing collapse into one shape, so the wait can't be
used as an existence oracle.
Prefer `wait_for_workstream` over polling `inspect_workstream` in a
loop — a wait consumes one assistant turn regardless of how long the
+62 -34
View File
@@ -14,34 +14,46 @@ care about.
---
## The two-surface model
## `kind` — authored audience metadata
A row in `prompt_templates` carries a `kind` column (see
[`turnstone/core/skill_kind.py`](../turnstone/core/skill_kind.py);
migration 044 added the column). Three values:
| `SkillKind` enum | Stored as | Visible in |
|----------------------|-----------------------------|---------------------------------------------------------------------------|
| `SkillKind.INTERACTIVE` | `"interactive"` | Only the interactive-session activation path. `list_skills` on a coord won't show it. |
| `SkillKind.COORDINATOR` | `"coordinator"` | Only the coordinator's `list_skills` tool. Hidden from interactive activation pickers. |
| `SkillKind.ANY` | `"any"` | Both surfaces. Default for legacy rows predating the classifier. |
| `SkillKind` enum | Stored as | Meaning |
|-------------------------|-----------------|----------------------------------------------------------------------------|
| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker persona (single-workstream "do this"). |
| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator persona (delegate, monitor, synthesise). |
| `SkillKind.ANY` | `"any"` | Either surface (or audience-neutral). Default on create. |
The `kind` field is a `StrEnum` — drop-in ``str`` compatible — so
DB rows, JSON payloads, and `==` comparisons all work without
translation at the edge.
The `kind` field is a `StrEnum` — drop-in `str` compatible — so DB
rows, JSON payloads, and `==` comparisons all work without translation
at the edge.
When a coordinator calls `list_skills`, the SQL filter narrows to
`kind IN ('coordinator', 'any')`. When an interactive session picks
a skill at activation, the filter narrows to
`kind IN ('interactive', 'any')`. A skill author tags once at
creation; the two surfaces stay partitioned without any
per-call filtering on the LLM side.
**`kind` is metadata, not an enforcement boundary.** The model can
`skills(action='find')` across every kind from any session, `get` any
row by name, and `load` any visible skill regardless of session kind.
Actual runtime capability is gated by `allowed_tools` + `auto_approve`
on the skill and the operator's approval card on every `load` /
`spawn_workstream(skill=...)` decision — `kind` doesn't add or remove
any of that. It's a sorting / grouping / search-narrowing hint.
**Tagging a new skill as coordinator-only** — set `kind` to
`SkillKind.COORDINATOR` (or the literal string `"coordinator"`) when
you POST to `/v1/api/admin/skills`. Existing rows default to
`SkillKind.ANY`; bump them to `COORDINATOR` if you've rewritten the
prompt around the orchestrator toolset.
The opt-in filter is on `skills(action='find', kind='coordinator')`
(or `'interactive'`) — pass it when you want to narrow a catalog
browse to a specific authored audience. Omitting it (or passing
`kind='any'`) returns the full catalog. When supplied, the storage
filter widens to `[<kind>, 'any']` so audience-neutral rows remain
visible inside the narrowed view.
**Tagging a new skill as coordinator-targeted** — set `kind` to
`SkillKind.COORDINATOR` (or the literal `"coordinator"`) when you
`skills(action='create', kind='coordinator', ...)` or POST to
`/v1/api/admin/skills`. Use this to signal intent to other skill
authors and to make the orchestrator-targeted catalog easy to
browse — not to hide the skill from interactive sessions. Existing
rows default to `SkillKind.ANY`; bump them to `COORDINATOR` if
you've rewritten the prompt around the orchestrator toolset and
want the kind filter to surface them as such.
---
@@ -64,7 +76,9 @@ or MCP config can do adds to it. Current members:
| `cancel_workstream` | wind-down | Drop the in-flight generation; leaves child idle for a fresh send. |
| `delete_workstream` | wind-down | Hard-delete one child. Requires approval. |
| `list_nodes` | discover | Enumerate live cluster nodes + capabilities. |
| `list_skills` | discover | Coordinator-visible skills only (SkillKind filter above). |
| `skills` (action=find) | discover | Browse the skill catalog; opt-in `kind` filter narrows by audience. |
| `memory` | persist | Orchestration scratchpad keyed by the `coordinator` scope. |
| `notify` | broadcast | Post a status update to a human channel at a narrative beat. |
| `tasks` | plan | Orchestrator-only scratchpad. Children don't see it. |
Explicitly **not** in the coordinator set:
@@ -72,8 +86,8 @@ Explicitly **not** in the coordinator set:
- `bash` / `edit_file` / `write_file` / `append_file` / `diff_file` — no local FS.
- `read_file` / `search` — no local FS reads.
- `web_fetch` / `web_search` — no direct web access.
- `task_agent` / `plan_agent` — sub-agent tools are zeroed on coord sessions.
- `memory` / `recall` / `notify` / `watch` / `read_resource` / `use_prompt` / `skill` — the orchestrator's "memory" is its children's outputs; these UX / persistence tools belong to interactive sessions.
- `task_agent` — sub-agent tool is zeroed on coord sessions.
- `recall` / `watch` / `read_resource` / `use_prompt` — UX / persistence tools that belong to interactive sessions. The dual-kind `memory` / `skills` / `notify` tools are available on both kinds (see the table above).
If your skill needs a coordinator to "run a command" or "read a
file", write the delegate pattern instead: spawn a child with an
@@ -154,19 +168,33 @@ Every ws_id returned by `spawn_workstream` / `spawn_batch` is a
invent ws_ids — a model that hallucinates `"child-1"` or `"ws-abc"`
hits the tenant guard in `CoordinatorClient._is_own_subtree`, which
validates ws_id against `parent_ws_id=coord_ws_id` AND
`user_id=owner` in storage. The rejection shape varies by tool:
`user_id=owner` in storage. The rejection shape is uniform and
recovery-oriented:
- **Mutating ops** (`send_to_workstream`, `close_workstream`,
`cancel_workstream`, `delete_workstream`) return
`{"error": "workstream not in coordinator subtree: <ws_id>", "status": 404}`
— the skill should treat this as a tool error, not an empty result.
- **`inspect_workstream`** returns `{"error": "workstream not found", "ws_id": "<ws_id>"}`
(same shape as a genuinely missing row, so the guard can't be
used as an existence oracle).
- **`wait_for_workstream`** reports the offending id with
`state="denied"` in its `results` dict; `mode="any"` won't
satisfy on a pure-denied list, so a hallucinated id won't trick
the wait into reporting "complete".
`cancel_workstream`, `delete_workstream`) and
**`inspect_workstream`** return
`{"error": "no workstream matching '<ref>' among your children; …",
"status": 404, "ws_id": "<ref>", "did_you_mean": [...],
"children": [...], "children_truncated": bool}` — a did-you-mean
(edit distance ≤ 3 against the coord's own children, which catches
the garbled-hex incident class: a 32-char id whose `aaa` run
collapsed to `a`) plus a roster of the coord's children. A ref
that matches a child's display NAME is called out explicitly with
the right id (names are mutable labels, not addresses). Foreign
and nonexistent ids produce the same payload (no existence
oracle), every hint references only the coord's own children, and
near-miss ids are never auto-resolved — the skill should fix the
id and re-issue, not treat the child as dead.
- **`wait_for_workstream`** validates ids before waiting: a
malformed id fails the whole call immediately (`invalid_ws_ids`
carries the per-id payloads above, `elapsed=0`); a well-formed id
that is foreign, nonexistent, or hard-deleted mid-wait surfaces as
`state="not_found"` and aborts the wait on that tick with
top-level `error` / `not_found` / `children` fields.
`complete=true` therefore means every polled lane really finished
— an unobservable id can neither burn the timeout nor ride along
to a "complete" result.
Pattern: capture each spawn result in the next tool call's input.
The JSON tool-result carries `{"child_ws_id": "...", "name": "...",
-2
View File
@@ -34,7 +34,6 @@ package "turnstone/core/" <<Rectangle>> {
component [metrics.py\nPrometheus metrics] as metrics <<core>>
component [config.py\nTOML config] as config <<core>>
component [safety.py\nPath validation] as safety <<core>>
component [sandbox.py\nCommand sandbox] as sandbox <<core>>
component [edit.py\nFile editing] as edit <<core>>
component [web.py\nWeb helpers] as web <<core>>
component [auth.py\nAuthentication] as auth <<core>>
@@ -123,7 +122,6 @@ session --> tools
session --> memory
memory --> storage
session --> safety
session --> sandbox
session --> edit
session --> web
session --> healthcheck
+1 -5
View File
@@ -15,7 +15,6 @@ interface "SessionUI" as SessionUI <<Protocol>> {
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
+ on_tool_output_chunk(call_id: str, chunk: str)
+ on_status(usage: dict, ctx_window: int, effort: str)
+ on_plan_review(content: str) → str
+ on_info(message: str)
+ on_error(message: str)
+ on_state_change(state: str)
@@ -43,15 +42,13 @@ class "WorkstreamTerminalUI" as WsTermUI {
class "WebUI" as WebUI {
- _listeners: list[Queue]
- _approval_event: Event
- _plan_event: Event
- _ws_prompt_tokens: int
- _ws_tool_calls: dict
+ resolve_approval(approved, feedback)
+ resolve_plan(feedback)
--
Enqueues JSON events for SSE.
Blocks on threading.Event for
approval/plan review.
approval.
SSE handlers bridge Queue to
async via run_in_executor().
--
@@ -145,7 +142,6 @@ class "ChatSession" as ChatSession {
+ model_alias: str | None {property}
- _tools: list[dict]
- _task_tools: list[dict]
- _agent_tools: list[dict]
- _read_files: set[str]
- system_messages: list[dict]
--
+2 -3
View File
@@ -139,10 +139,9 @@ group loop [while tool_calls present]
read_file → open().read() or base64 image
search → grep subprocess
edit_file → string replace
task/plan → _run_agent() sub-loop
math → sandboxed subprocess
task → _run_agent() sub-loop
web_fetch → httpx + LLM summarize
web_search → provider-native or Tavily fallback
web_search → provider-native or SearxNG fallback
memory/recall → SQLite
end note
+2 -13
View File
@@ -24,7 +24,7 @@ partition "Phase 1: Prepare" #E8F5E9 {
:Dispatch to _prepare_{func_name}();
note right
**Dispatch table (19 built-in + tool_search):**
**Dispatch table (16 built-in + tool_search):**
┌───────────────┬──────────────────┐
│ Tool │ Needs Approval? │
├───────────────┼──────────────────┤
@@ -34,13 +34,10 @@ partition "Phase 1: Prepare" #E8F5E9 {
│ edit_file │ ✓ Yes │
│ search │ ✗ Auto-approve │
│ diff_file │ ✗ Auto-approve │
│ math │ ✗ Auto-approve │
│ man │ ✗ Auto-approve │
│ web_fetch │ ✗ Auto-approve │
│ web_search │ ✗ Auto-approve │
│ tool_search │ ✗ Auto-approve │
│ task_agent │ ✓ Yes │
│ plan_agent │ ✓ Yes │
│ memory │ ✗ Auto-approve │
│ recall │ ✗ Auto-approve │
│ notify │ ✗ Auto-approve │
@@ -110,13 +107,10 @@ partition "Phase 3: Execute" #E3F2FD {
├─ _exec_write_file: makedirs + write
├─ _exec_edit_file: find_occurrences + replace
├─ _exec_search: grep subprocess
├─ _exec_math: sandboxed subprocess
├─ _exec_man: man/info subprocess
├─ _exec_web_fetch: httpx.get + LLM summary
├─ _exec_web_search: Tavily API POST (fallback for local models)
├─ _exec_web_search: SearxNG JSON GET (fallback for local models)
├─ _exec_tool_search: BM25 search + expand_visible()
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
├─ _exec_notify: HTTP POST to channel gateway
├─ _exec_memory: structured memory save/search/delete/list
├─ _exec_recall: conversation history FTS5 search
@@ -131,11 +125,6 @@ partition "Phase 3: Execute" #E3F2FD {
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
:ui.on_tool_result(call_id, name, output, is_error) for each;
if (plan tool was executed?) then (yes)
:ui.on_plan_review(output);
:Block for user review/feedback;
endif
}
:Return (results, user_feedback);
+2 -4
View File
@@ -13,7 +13,7 @@ skinparam state {
state "IDLE" as idle <<idle>> : Waiting for user input.\nNo active LLM call or tool execution.
state "THINKING" as thinking <<thinking>> : LLM streaming response.\nTokens flowing (reasoning + content).
state "RUNNING" as running <<running>> : Tools executing.\nThreadPoolExecutor active.
state "ATTENTION" as attention <<attention>> : Blocked on user action.\nTool approval or plan review needed.
state "ATTENTION" as attention <<attention>> : Blocked on user action.\nTool approval needed.
state "ERROR" as error <<error>> : Exception occurred.\nRecoverable on next send().
[*] --> idle : Session created
@@ -34,8 +34,6 @@ attention --> running : User denies\n(denial recorded)\n_emit_state("running")
running --> thinking : Tool results appended,\nnext LLM call\n_emit_state("thinking")
running --> attention : Plan tool complete,\non_plan_review()\n_emit_state("attention")
running --> error : Exception during\ntool execution
error --> thinking : New send() call\n_emit_state("thinking")
@@ -44,7 +42,7 @@ thinking --> idle : cancel() called\nstream aborted\n_emit_state("idle")
running --> idle : cancel() called\n_emit_state("idle")
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
attention --> idle : cancel() unblocks\napproval wait\n_emit_state("idle")
note left of idle
**Cancel escalation:**
-1
View File
@@ -30,7 +30,6 @@ package "turnstone/sdk/ (Python)" {
+ close_workstream()
+ send(message, ws_id)
+ approve()
+ plan_feedback()
+ command()
+ cancel(ws_id)
+ stream_events(ws_id)
+1 -1
View File
@@ -202,7 +202,7 @@ note over Session, Judge
Cross-model: separate provider/client from [judge] config.
**Sub-agent exemption:**
Plan agent and task agent skip intent validation entirely.
Task sub-agents skip intent validation entirely.
**Output guard:**
Runs when judge_config.output_guard is true (default).
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c14dfbb2db8dcb22cd332b2cf0e53ba75141adb213dae47dd9dbfd389ed482fe
size 354799
oid sha256:d9c7769a600c38e6387390e6c42db8152e0f80c31d17b2218f7f636b71c7b868
size 355459
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e
size 281519
oid sha256:23ca090b5656baaf70820cbe4ab6c27f0a3a02e18b4db0695614cf9489c23980
size 281440
+213 -112
View File
@@ -1,165 +1,266 @@
# Docker Deployment
Docker Compose stack for running the full turnstone platform.
Turnstone ships two Docker Compose stacks:
## Quick Start
| Stack | File | Use it for |
|-------|------|------------|
| **Dev cluster** | `compose.yaml` (repo root) | Clone-and-run. Builds locally, zero config, full 10-node cluster. |
| **Production** | `turnstone/deploy/compose.yaml` | Pip/pipx installs. Pulls released images from ghcr.io, requires real secrets. |
## Quick start — local cluster
```bash
# Copy and edit environment config
cp .env.example .env
# Full stack (needs an LLM API on the host)
git clone https://github.com/turnstonelabs/turnstone
cd turnstone
docker compose up
```
Console dashboard: http://localhost:8090
That builds one image and brings up the whole stack: PostgreSQL, the console,
Caddy, the channel gateway, and **10 server nodes** (`node-1``node-10`). No
`.env` is required — it ships with insecure dev defaults so it just works.
> See also: [Deployment diagram](diagrams/png/12-deployment.png)
## Services
| Service | Port | Profile | Description |
|---------|------|---------|-------------|
| `server` | 8080 | default | Web UI + chat workstreams + LLM |
| `console` | 8090 | default | Cluster dashboard |
| `channel` | — | production | Channel gateway (Discord and/or Slack adapters) |
| `server-1``server-10` | — | cluster | 10-node server fleet (PostgreSQL required) |
## Profiles
**Default** (no flag) — starts `server` and `console`. Requires an OpenAI-compatible LLM API running on the host (default: `http://localhost:8000/v1`).
Open the dashboard at **https://localhost:8443**. It's served by Caddy with its
own local CA, so trust the root certificate once (or click through the browser
warning):
```bash
docker compose up
docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt
```
**Production** — adds PostgreSQL and the channel gateway. Requires `POSTGRES_PASSWORD` and (for Discord) `TURNSTONE_DISCORD_TOKEN`:
Create your first admin user (any node works — they share one database):
```bash
docker compose --profile production up
docker compose exec node-1 turnstone-admin create-user --username admin --name "Admin"
```
**Cluster** — 10-node server fleet sharing PostgreSQL. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`:
### Bring your own LLM
Nodes boot **without** an LLM and appear in the console immediately. Add real
model backends (OpenAI, Anthropic, or a local/vLLM endpoint) from the console
UI's **Models** tab. To set a node's bootstrap default instead, point
`LLM_BASE_URL` / `OPENAI_API_KEY` at an OpenAI-compatible endpoint in `.env`.
### Fewer nodes
Ten nodes is heavy on a laptop. Start a subset by naming the services (always
include `postgres`, `console`, and `caddy`):
```bash
docker compose --profile cluster up
docker compose up postgres console caddy channel node-1 node-2 node-3
```
## Why HTTPS-only?
The console's plain-HTTP port (8090) is **not** published to the host. A plain
HTTP/1.1 origin caps the browser at 6 connections, which starves the
dashboard's per-pane SSE streams. Caddy serves the browser over HTTP/2
(multiplexed) and proxies to `console:8090` on the internal network, so the cap
is gone. Everything goes through `https://localhost:8443`.
## Join a bare-metal host
PostgreSQL is published on `127.0.0.1:5432`, so a `turnstone-server` running
directly on the same machine — for example to use a local GPU — can join the
same cluster and show up in the console alongside the containerized nodes.
Put the secret and connection settings in `~/.config/turnstone/config.toml`
(secrets belong in this file, not the process environment — keep it `0600`,
the loader warns otherwise):
```toml
[auth]
jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments"
[database]
backend = "postgresql"
url = "postgresql+psycopg://turnstone:turnstone@localhost:5432/turnstone"
[api]
base_url = "http://localhost:8000/v1" # your local model endpoint
api_key = "dummy"
```
Then start the server. The node identity isn't a secret, so it stays on the
command line:
```bash
chmod 600 ~/.config/turnstone/config.toml
TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
turnstone-server --host 0.0.0.0 --port 8080
```
The host server registers itself in PostgreSQL; the console reaches it back via
`host.docker.internal`. The `jwt_secret` and DB credentials above are the
dev-stack defaults — match whatever you set in `.env` if you changed them. To
let a **different** machine join, start the stack with `POSTGRES_BIND=0.0.0.0`
and use the host's routable IP in the `url` and `TURNSTONE_ADVERTISE_URL`
but **set a strong `POSTGRES_PASSWORD` first**, or you'll expose a database with
the insecure default password (and every user account + API-token hash in it) to
your network.
## Production stack
For a real deployment use the bundled stack, which pulls released images
instead of building:
```bash
docker compose -f turnstone/deploy/compose.yaml up
```
It's the same shape as the dev stack — Caddy-fronted console, channel, and a
PostgreSQL all share one database so the console discovers the node — but it
pulls released images, runs a single server node, and has **no baked-in
secrets**. Set these in `.env` first (`turnstone-bootstrap` generates them):
```bash
TURNSTONE_JWT_SECRET=<python -c "import secrets; print(secrets.token_hex(32))">
POSTGRES_PASSWORD=<a strong password>
```
The dashboard is at **https://localhost:8443** (Caddy, same as the dev stack);
the console's HTTP port isn't published. For a real domain and a publicly
trusted cert, edit `turnstone/deploy/Caddyfile` to point Caddy at Let's Encrypt
(see [tls.md](tls.md)). Pin the image with `TURNSTONE_IMAGE_TAG` (default:
`latest`).
### mTLS
Layer the TLS overlay on the production stack to enable mutual TLS between
services. A bootstrap container creates a CA and every service auto-provisions
certs via the console's ACME endpoint:
```bash
docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up
```
See [tls.md](tls.md) for details.
## Configuration
All configuration is via environment variables in `.env` (copy from `.env.example`):
Everything is configured with environment variables in `.env` (copy from
[`.env.example`](../.env.example)). The dev stack needs none of them — they're
overrides.
### LLM Backend
### LLM backend
| Variable | Default | Description |
|----------|---------|-------------|
| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | OpenAI-compatible API URL |
| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | Bootstrap OpenAI-compatible API URL (real backends go in the UI) |
| `OPENAI_API_KEY` | `dummy` | API key (`dummy` for local servers) |
| `TAVILY_API_KEY` | — | Web search API key (only needed for local/vLLM models; Anthropic and OpenAI search models use native search) |
| `TURNSTONE_SEARXNG_URL` | `http://searxng:8080` | SearxNG URL for the `web_search` tool (local/vLLM models only; Anthropic/OpenAI use native search). Defaults to the bundled `searxng` service; set to an external instance's URL. To turn web search off, clear `tools.searxng_url` in the admin Settings tab. |
| `SEARXNG_IMAGE_TAG` | `latest` | Tag for the bundled `searxng/searxng` image |
| `MODEL` | — | Override the default model alias |
### Server
### Auth & database
| Variable | Default (dev / prod) | Description |
|----------|----------------------|-------------|
| `TURNSTONE_JWT_SECRET` | insecure default / **required** | JWT signing secret. Every service must share one value. |
| `TURNSTONE_DB_BACKEND` | `postgresql` | `sqlite` or `postgresql`. Multi-node discovery requires `postgresql`. |
| `TURNSTONE_DB_URL` | bundled Postgres | SQLAlchemy URL. Override to use an external database. |
| `POSTGRES_USER` | `turnstone` | PostgreSQL username |
| `POSTGRES_PASSWORD` | `turnstone` / **required** | PostgreSQL password |
| `POSTGRES_MAX_CONNECTIONS` | `300` | `max_connections` for the bundled Postgres |
> **Discovery needs a shared database.** Each server registers and heartbeats
> into a `services` table that the console polls. All services in these stacks
> point at the same PostgreSQL by default; SQLite-per-container can't see other
> containers.
> **Large clusters:** each process keeps a small pool (5 max). Beyond ~50 nodes,
> put [PgBouncer](pgbouncer.md) (transaction pooling) between turnstone and
> PostgreSQL.
### Ports
Both stacks publish Caddy (dashboard) and PostgreSQL; the dev stack additionally
publishes the SearxNG UI on localhost. Everything else is reached through Caddy or
proxied by the console:
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_PORT` | `8080` | Host port mapping |
| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tools |
| `CONSOLE_HTTPS_PORT` | `8443` | Host port for Caddy (dashboard HTTPS) |
| `SEARXNG_HTTPS_PORT` | `8444` | Host port for the SearxNG UI via Caddy (dev: localhost-only; prod: opt-in) |
| `POSTGRES_PORT` | `5432` | Host port for PostgreSQL (for bare-metal joins) |
| `POSTGRES_BIND` | `127.0.0.1` | Interface PostgreSQL binds on; set `0.0.0.0` for LAN access |
### Console
### Channel gateway
| Variable | Default | Description |
|----------|---------|-------------|
| `CONSOLE_PORT` | `8090` | Host port mapping |
| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (enables the Discord adapter) |
| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to one guild (0 = all) |
| `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token `xoxb-…` |
| `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token `xapp-…` (with the Slack token) |
### Auth
The channel runs HTTP-only with no adapters until a token is set, so it's safe
to leave running. See [Channel Integrations](channels.md) for app setup.
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
### Web search (SearxNG)
The `web_search` tool for local/vLLM models is backed by a self-hosted
[SearxNG](https://searxng.org) metasearch service, bundled into both stacks as the
`searxng` service. The Turnstone nodes reach it over the internal docker network at
`http://searxng:8080` — its API port is **not** published. Its config —
[`turnstone/deploy/searxng/settings.yml`](../turnstone/deploy/searxng/settings.yml),
mounted read-only — enables the JSON API and leaves the rate limiter off (the
limiter would need a separate Valkey/Redis instance). A `searxng-cache` volume
persists its favicon + internal cache across restarts. Commercial providers
(Anthropic, OpenAI) use their own native search and never touch this service.
Point at an existing SearxNG instead of the bundled one with `TURNSTONE_SEARXNG_URL`,
or narrow the engines via `tools.searxng_engines` in the admin Settings tab (e.g.
`duckduckgo,wikipedia`).
**SearxNG web UI.** Caddy can also serve SearxNG's own search/Preferences UI on a
dedicated port. The dev stack publishes it at **`https://localhost:8444`** bound to
localhost only; the production stack does **not** publish it by default (uncomment
the `8444` port on the `caddy` service to opt in). Change the port with
`SEARXNG_HTTPS_PORT`. **SearxNG has no authentication** — never bind this to a public
interface, or anyone who can reach it can search through your instance.
> **AGPL note for operators.** SearxNG is licensed AGPL-3.0. Kept on the internal
> network (or bound to localhost), no external user interacts with it — so the AGPL
> §13 (remote network interaction) source-offer obligation does not attach. If you
> publish SearxNG to remote users (bind its port to a public interface, or front it
> with your own reverse proxy) you become the operator of a network-reachable AGPL
> service and must offer its corresponding source; that is trivially satisfied by
> linking to upstream <https://github.com/searxng/searxng>. Turnstone's own license is
> unaffected: it talks to SearxNG over HTTP as a separate process (mere aggregation),
> not by linking.
### Other
| Variable | Default | Description |
|----------|---------|-------------|
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
### Database
| Variable | Default | Description |
|----------|---------|-------------|
| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` |
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql+psycopg://user:pass@postgres:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` |
| `TURNSTONE_DB_LISTEN_URL` | (falls back to `TURNSTONE_DB_URL`) | Direct-to-PostgreSQL URL for the console's dedicated `LISTEN` connection. Set this when `TURNSTONE_DB_URL` points at PgBouncer in transaction pooling mode — LISTEN is session state and the transaction-pooled connection can't hold it. See [pgbouncer.md](pgbouncer.md). |
| `TURNSTONE_DB_POOL_SIZE` | `2` | PostgreSQL connection pool size per process (default: 2 base + 3 overflow = 5 max) |
| `POSTGRES_USER` | `turnstone` | PostgreSQL container username (used in default `TURNSTONE_DB_URL` for cluster/channel) |
| `POSTGRES_PASSWORD` | — | PostgreSQL container password (required for production and cluster profiles) |
The database stores workstream history, user accounts, and API tokens. When using JWT auth, a database backend is required for user storage.
> **Upgrading from <1.3.0a4:** Earlier versions used `DB_BACKEND` and `DATABASE_URL` in `.env`, which `compose.yaml` mapped to the `TURNSTONE_`-prefixed names internally. These short aliases have been removed. Rename `DB_BACKEND``TURNSTONE_DB_BACKEND` and `DATABASE_URL``TURNSTONE_DB_URL` in your `.env` file.
> **Large clusters:** Each turnstone process maintains a small connection pool (5 max). At hundreds of nodes this adds up — use [PgBouncer](pgbouncer.md) in transaction pooling mode between turnstone and PostgreSQL.
> **First-time setup:** After deploying with auth enabled, create an initial admin user by running `turnstone-admin create-user` inside the container:
>
> ```bash
> docker compose exec server turnstone-admin create-user --username admin --name "Admin"
> ```
>
> You will be prompted to set a password. Use it to log in via the UI or SDK, then create additional users through the admin API. Pass `--token --scopes read,write,approve` to also generate an initial API token.
### Channel Gateway
| Variable | Default | Description |
|----------|---------|-------------|
| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord adapter) |
| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to a single Discord guild (0 = all guilds) |
| `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token `xoxb-…` (required to enable Slack adapter) |
| `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token `xapp-…` (required with `TURNSTONE_SLACK_TOKEN`) |
| `TURNSTONE_SLACK_CHANNELS` | — | Comma-separated Slack channel IDs to allow (empty = all) |
| `TURNSTONE_SLACK_SLASH_COMMAND` | `/turnstone` | Slash command registered in the Slack app |
The channel service runs in the `production` profile. When
`TURNSTONE_DISCORD_TOKEN` or the Slack pair is set the gateway starts the
corresponding adapter; both can run in one process. See
[Channel Integrations](channels.md) for platform app setup and user
account linking.
## Scaling
For multi-node testing, use the `cluster` profile which provides 10 server instances with unique node IDs (`node-1` through `node-10`), resource limits, and shared PostgreSQL:
```bash
POSTGRES_PASSWORD=secret docker compose --profile cluster up
```
The default `server` also runs alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`.
For production clusters beyond ~50 nodes, add PgBouncer between turnstone services and PostgreSQL. See [PgBouncer Connection Pooling](pgbouncer.md) for Docker Compose and Helm configuration.
## Volumes
| Volume | Mount | Purpose |
|--------|-------|---------|
| `turnstone-data` | `/data` | SQLite database (`.turnstone.db`) |
| `WORKSPACE_MOUNT` | empty volume | Host directory bind-mounted at `/workspace` for the model to read/write |
| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tool calls (dev only) |
| `MCP_CONFIG` | — | Path to an MCP server config file |
| `TURNSTONE_IMAGE_TAG` | `latest` | ghcr.io image tag — production stack |
## Building
The image uses a multi-stage Dockerfile:
Both stacks install all entry points into a single image (`turnstone`,
`turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`,
`turnstone-eval`, `turnstone-bootstrap`):
```bash
# Build all services
docker compose build
# Rebuild without cache
docker compose build --no-cache
docker compose build # build the dev image
docker compose build --no-cache # rebuild from scratch
```
All entry points are installed in a single image: `turnstone`,
`turnstone-server`, `turnstone-console`, `turnstone-channel`,
`turnstone-admin`, `turnstone-eval`, and `turnstone-bootstrap`.
## Volumes
| Volume | Purpose |
|--------|---------|
| `postgres-data` | PostgreSQL data directory |
| `turnstone-data` | `/data` per node (SQLite fallback, local state) |
| `workspace` | `/workspace` (unless `WORKSPACE_MOUNT` is set) |
| `caddy-data` / `caddy-config` | Caddy's local CA and config (dev stack) |
## Cleanup
```bash
# Stop and remove containers
docker compose down
# Stop, remove containers and volumes
docker compose down -v
docker compose down # stop and remove containers
docker compose down -v # also remove volumes (database, certs)
```
+1 -2
View File
@@ -274,8 +274,7 @@ for iteration in 0..max_iterations:
### Phase 1: Analyst (`_run_analyst`)
A multi-turn agent with `math` (Python) and `bash` tools for computing
statistics. It receives per-case results with failure classifications and
A multi-turn agent with a `bash` tool for computing statistics. It receives per-case results with failure classifications and
produces a structured diagnosis:
- **Failure patterns**: Shared root causes across failing cases
+116 -13
View File
@@ -37,13 +37,31 @@ model = "" # empty = same as session model
provider = "" # empty = same as session provider
base_url = ""
api_key = ""
confidence_threshold = 0.7 # reserved for v2 smart approvals (not used in v1)
smart_approvals = false # auto-approve high-confidence "approve" LLM verdicts (opt-in)
confidence_threshold = 0.95 # Smart Approvals auto-approve bar (LLM recommendation=approve)
max_context_ratio = 0.5 # max % of judge context window for history
timeout = 60.0 # seconds (generous for local models)
read_only_tools = true # judge can use read_file/list_directory
cancel_on_approval = false # stop judging remaining tool calls once user decides
```
### Smart Approvals
With `smart_approvals = true` (off by default) a tool call is approved
automatically — no operator prompt — when the intent judge's **LLM** verdict
recommends `approve` with confidence at or above `confidence_threshold`. Every
other outcome still reaches a human: `review` / `deny` recommendations,
confidence below the threshold, judge errors or timeouts (`llm_fallback`), and
any call the deterministic heuristic rules explicitly flagged `deny` or
`critical`. That heuristic floor blocks only those explicit danger verdicts — it
is **not** a general "never lower the heuristic" rule: the heuristic's default
for an unmatched tool is `review`, and letting a confident LLM `approve` upgrade
a `review` is exactly what Smart Approvals is for. Only `deny` / `critical`
findings are off-limits to auto-approval. Requires the judge to be enabled;
auto-approved calls are tagged `smart_approval` in the dashboard and audit trail.
Smart Approvals applies to the web and coordinator surfaces, not the interactive
CLI.
All fields are optional. The judge is enabled by default; use `enabled = false`
(or `--no-judge` on the command line) to disable it.
@@ -54,9 +72,12 @@ All fields are optional. The judge is enabled by default; use `enabled = false`
--judge-model MODEL Model for judge
--judge-provider PROVIDER Provider for judge
--judge-timeout SECONDS LLM judge timeout (default: 60)
--judge-confidence FLOAT Confidence threshold (default: 0.7)
--judge-confidence FLOAT Confidence threshold, 0-1 (default: 0.95)
```
(Smart Approvals is configured via `[judge] smart_approvals` / the admin Judge
settings, not a CLI flag — the interactive CLI prompts for approval directly.)
CLI flags override `config.toml` values.
---
@@ -104,7 +125,7 @@ last) and returns the first matching rule. Each rule has:
| Critical | 0.90 | deny | `rm -rf /`, `mkfs`, `dd if=`, pipe-to-shell, chmod 777 on root, write/edit to `/etc/` or `.ssh/`, download-then-execute chains (`curl -o file && chmod +x && bash`) |
| High | 0.80 | review | `sudo`, `kill -9`, destructive git, DROP TABLE, write/edit secrets, HTTP mutations, `ssh`/`scp`, credential file access, browser automation + data export, transitive installs (`npx skills add`, `pip install git+`), control plane mutations (`crontab`, `systemctl enable/start/stop`) |
| Medium | 0.70 | review | Content ingestion pipelines (`curl \| python3`), interpreter execution (`python3 script.py`, `node build.js`), cloud CLI mutations (`az/gcloud/aws/kubectl/terraform` with create/delete/destroy verbs), package installs, `write_file`, MCP tools, Docker operations |
| Low | 0.85 | approve | `read_file`, `list_directory`, `search`, `recall`, `man`, `use_prompt`, `tool_search`, `read_resource`, `web_search`, read-only bash (`ls`, `cat`, `head`, `grep`, `find`, etc.) |
| Low | 0.85 | approve | `read_file`, `list_directory`, `search`, `recall`, `use_prompt`, `tool_search`, `read_resource`, `web_search`, read-only bash (`ls`, `cat`, `head`, `grep`, `find`, etc.) |
When no rule matches, the heuristic returns a default verdict: medium risk,
0.50 confidence, "review" recommendation.
@@ -210,6 +231,23 @@ calls for approval, it calls `_evaluate_intent()` which:
4. Attaches each heuristic verdict to its item as `_heuristic_verdict`
5. The daemon thread runs the LLM judge and delivers results via `ui.on_intent_verdict()`
The daemon evaluates items sequentially, so a large parallel batch can outlive
its approval gate. With `cancel_on_approval = false` (the default) the daemon
runs every item to completion: verdicts that land after the operator decided
still stream to the UI and persist, stamped with the decision. The daemon is
aborted only when the next tool batch supersedes it or the session closes —
then each unfinished item degrades to an `llm_fallback` verdict. With
`cancel_on_approval = true` the abort additionally fires the moment the gate
resolves, trading verdict completeness for inference savings — recommended
when the judge shares a single local inference backend with the session model,
where a large batch's remaining judge calls would otherwise compete with the
next turn's completion.
Verdicts that arrive after a *newer batch* has replaced the judge generation
are withheld from the live surfaces (a reused call_id must never ride a stale
`approve` into Smart Approvals) but still persist with
`user_decision = "superseded"` so the audit trail records the judge's answer.
Sub-agents (plan agent, task agent) are exempt from intent validation -- they
always get full tool visibility without judge evaluation.
@@ -221,7 +259,13 @@ All verdicts are persisted to the `intent_verdicts` table (migration 012):
- Heuristic verdicts are stored when the `approve_request` event is emitted
- LLM verdicts are stored when the `intent_verdict` event is delivered
- The `user_decision` column is updated when the user approves or denies
- The `user_decision` column is updated when the user approves or denies;
auto-approved rows carry the bypass reason (`policy`, `blanket`,
`auto_approve_tools`, `smart_approval`), and rows whose verdict landed only
after a newer batch replaced the judge generation carry `superseded`
- Every stored verdict — including the benign `risk_level = "none"` majority —
is re-attached to its tool call on history replay, so a reloaded workstream
shows the same verdict badges the live stream did
The console admin panel exposes verdict history via:
@@ -372,10 +416,36 @@ redact_secrets = true # auto-redact detected credentials (default)
Configurable at runtime via the admin Settings tab.
### Merge semantics (heuristic + LLM judge)
The chip is a **merge** of the two detectors (issue #560, "show, annotated"),
not a winner-take-all:
- `risk_level` = **max**(heuristic, llm) and `flags` = **union**. A positive
from either detector surfaces; a negative ("none") or failed/absent LLM
**never lowers** a heuristic positive. The judge reads adversarial tool
output, so it may raise the alarm but must not be able to hide a
deterministic regex finding — defeating the judge can't erase the tripwire.
- Credential **redaction** is a heuristic-only signal the LLM cannot override.
- When the judge returned a verdict, its OWN verdict rides along as
annotation (`judge_risk` / `confidence` / `reasoning` / `judge_model`) so
the operator sees the judge's opinion even when it disagrees with the
displayed (merged) risk.
The same merge runs live and on reconnect (both call
`output_guard.merge_guard_display_payload`), so the chip can't drift between
the two surfaces.
The MODEL on the other side of the conversation is shown the merged
`risk_level` + `flags` (via the `GuardAdvisory` spliced into the tool-result
envelope), but is **never** told the judge cleared a finding — a judge fooled
into "none" must not get to talk the model out of caution. The judge's
"benign" verdict is operator-facing only.
### SSE event: `output_warning`
When the output guard detects risk signals, an `output_warning` SSE event is
emitted to the frontend:
When the merged finding is non-clean (or credentials were redacted), an
`output_warning` SSE event is emitted to the frontend. A regex-only finding:
```json
{
@@ -386,17 +456,50 @@ emitted to the frontend:
"flags": ["credential_leak"],
"annotations": ["API key detected (sk-proj-...)"],
"output_length": 1024,
"redacted": true
"redacted": true,
"tier": "heuristic"
}
```
The web UI renders this as an inline warning after the tool result. The CLI
shows a colored terminal warning. The server forwards it as an
`OutputWarningEvent` for console subscribers.
When the LLM judge returned a verdict, `tier` is `"llm"` and the event carries
the judge's own verdict as annotation. Here the regex flagged MEDIUM but the
judge assessed the output benign — the finding still surfaces (`risk_level`
stays MEDIUM), annotated with the judge's dissent (`judge_risk: "none"`):
Assessments are persisted to the `output_assessments` table for v2
calibration. Raw tool output is never stored — only metadata (flags, risk
level, annotations, output length, redaction status).
```json
{
"type": "output_warning",
"call_id": "call_def456",
"func_name": "web_fetch",
"risk_level": "medium",
"flags": ["camouflaged_injection"],
"annotations": ["Authority-framed directive embedded in the document."],
"output_length": 8192,
"redacted": false,
"tier": "llm",
"judge_risk": "none",
"confidence": 0.92,
"reasoning": "Legitimate analyst commentary; no injection.",
"judge_model": "gpt-5-mini"
}
```
`judge_risk` (the judge's OWN risk verdict, which may differ from the merged
`risk_level`), `confidence` (0.01.0), `reasoning`, and `judge_model` are
present only on the `"llm"` tier. The identical shape is projected onto
history replay by `build_merged_output_assessment_payload`, so the inline chip
renders the same live and on refresh.
The web UI renders this as an inline warning after the tool result — the
`"llm"` tier adds a `⚖ LLM · NN%` badge (showing the judge's verdict when it
differs from the displayed risk, e.g. `⚖ LLM: none · 92%`) and the judge's
rationale. The CLI shows a colored terminal warning. The server forwards it as
an `OutputWarningEvent` for console subscribers.
Assessments are persisted to the `output_assessments` table (one row per
`(call_id, tier)`) for calibration. Raw tool output is never stored — only
metadata: flags, risk level, annotations, output length, redaction status,
and — for the LLM tier — confidence, reasoning, judge model, and latency.
### Session-level skill scan warning
+1 -1
View File
@@ -94,7 +94,7 @@ cannot bypass the proxy.
|--------|-------|---------|
| `openai_api` | `api.openai.com` | OpenAI LLM API |
| `anthropic_api` | `api.anthropic.com` | Anthropic LLM API |
| `tavily_api` | `api.tavily.com` | Web search fallback |
| `searxng` | `searxng:8080` (bundled service) | Web search backend |
| `skills_registry` | `skills.sh` | Skill discovery |
| `github_api` | `api.github.com` (read-only L7), `raw.githubusercontent.com` | Skill fetch, GitHub API |
| `mcp_registry` | `registry.modelcontextprotocol.io` (read-only L7) | MCP server discovery |
+1 -1
View File
@@ -108,7 +108,7 @@ pgbouncer:
maxClientConn: 5000
maxDbConnections: 80
```
:
---
## Configuration reference
+18 -17
View File
@@ -6,10 +6,9 @@ Turnstone ships several parallel release tracks from a single PyPI package.
| Track | Versions | Branch | Docker tags | PyPI install |
|-------|----------|--------|-------------|--------------|
| **Legacy 1.0** | `1.0.x` | `stable/1.0` | `:1.0.x`, `:1.0` | `pip install 'turnstone==1.0.*'` |
| **Stable 1.3** | `1.3.x` | `stable/1.3` | `:1.3.x`, `:1.3` | `pip install 'turnstone==1.3.*'` |
| **Stable 1.4** | `1.4.x` | `stable/1.4` | `:1.4.x`, `:1.4`, `:stable`, `:latest` | `pip install turnstone` |
| **Experimental** | `1.5.0aN` | `main` | `:1.5.0aN`, `:experimental` | `pip install turnstone --pre` |
| **Stable 1.5** | `1.5.x` | `stable/1.5` | `:1.5.x`, `:1.5` | `pip install 'turnstone==1.5.*'` |
| **Stable 1.6** | `1.6.x` | `stable/1.6` | `:1.6.x`, `:1.6`, `:stable`, `:latest` | `pip install turnstone` |
| **Experimental** | `1.7.0aN` | `main` | `:1.7.0aN`, `:experimental` | `pip install turnstone --pre` |
- **Stable** tracks receive bugfixes only. The most-recent stable minor
owns the `:stable` / `:latest` Docker tags and the default PyPI
@@ -17,8 +16,10 @@ Turnstone ships several parallel release tracks from a single PyPI package.
- **Experimental** (always on `main`) receives new features. May be
rough around the edges.
- When experimental matures, it is promoted to a new stable minor via
a `stable/X.Y` branch; older stable branches continue to receive
security fixes until explicitly retired.
a `stable/X.Y` branch. One prior stable track is maintained alongside
the current one; at each promotion the oldest track is retired — its
branch is deleted, while its tags and released artifacts remain
available.
## Version Scheme
@@ -33,17 +34,17 @@ Turnstone ships several parallel release tracks from a single PyPI package.
## Releasing an Experimental Version (from main)
```bash
scripts/release.sh 1.5.0a2 --push
scripts/release.sh 1.7.0a2 --push
```
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.5.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.7.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
## Releasing a Stable Patch (from stable/X.Y)
```bash
git checkout stable/1.4
git checkout stable/1.6
git cherry-pick <commit-hash> # bugfix from main
scripts/release.sh 1.4.1 --push
scripts/release.sh 1.6.1 --push
```
## Promoting Experimental to Stable
@@ -52,19 +53,19 @@ When `main` is ready for a stable release:
```bash
# 1. Tag the stable release on main
scripts/release.sh 1.5.0 --push
scripts/release.sh 1.6.0 --push
# 2. Create the stable maintenance branch from that tag
git branch stable/1.5 v1.5.0
git push origin stable/1.5
git branch stable/1.6 v1.6.0
git push origin stable/1.6
# 3. Start the next experimental cycle on main
scripts/release.sh 1.6.0a1 --push
scripts/release.sh 1.7.0a1 --push
```
The previous stable branch (`stable/1.4`) continues to receive
security-only patches; older tracks (`stable/1.0`, `stable/1.3`) are
retired when they fall out of support.
The previous stable branch continues to receive security-only patches;
the track before it is retired at each promotion (at 1.6.0:
`stable/1.5` stays maintained, `stable/1.4` is retired).
## CI/CD Pipeline
-2
View File
@@ -77,7 +77,6 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
| | `delete_attachment(ws_id, attachment_id)` | `StatusResponse` |
| **Chat** | `send(message, ws_id)` | `SendResponse` |
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
| | `command(*, ws_id, command)` | `StatusResponse` |
| | `cancel(ws_id, *, force=False)` | `StatusResponse` |
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
@@ -134,7 +133,6 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
| `plan_review` | `PlanReviewEvent` | `content` |
| `error` | `ErrorEvent` | `message` |
| `info` | `InfoEvent` | `message` |
| `stream_end` | `StreamEndEvent` | — |
+1 -1
View File
@@ -67,7 +67,7 @@ Scopes are hierarchical — higher scopes imply all lower ones.
| Method | Path pattern | Required scope |
|--------|-------------|----------------|
| GET | Any protected path | `read` |
| POST | `/api/plan`, `/api/command` | `write` |
| POST | `/api/command` | `write` |
| POST | `/api/workstreams/new`, `/api/cluster/workstreams/new` | `write` |
| POST | `/api/workstreams/{ws_id}/{send,cancel,close,delete,open,refresh-title,title,attachments}` | `write` |
| DELETE | `/api/workstreams/{ws_id}/send` (dequeue), `/api/workstreams/{ws_id}/attachments/{attachment_id}` | `write` |
+6 -9
View File
@@ -74,20 +74,17 @@ provider-side mechanics (Anthropic `thinking`, OpenAI Responses
`reasoning` + `include=["reasoning.encrypted_content"]`, synthetic
`reasoning_text` for Chat Completions / vLLM / llama.cpp / Gemini-compat).
### Plan / task agent overrides
### Task agent overrides
`plan_agent` and `task_agent` sub-sessions resolve independently from the
conversation model so operators can pick a cheaper/faster model for
autonomous loops:
`task_agent` sub-sessions resolve independently from the conversation model
so operators can pick a cheaper/faster model for autonomous loops:
| Setting | Purpose |
|---------|---------|
| `model.plan_alias` | Alias used for `plan_agent` sub-sessions. Falls back to `[model].plan_model` in config.toml, then `[model].agent_model`, then the session's active model. |
| `model.task_alias` | Alias used for `task_agent` sub-sessions. Same fallback chain as `plan_alias`. |
| `model.plan_effort` | Reasoning effort for `plan_agent` (`none` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max`). Defaults to `high`. |
| `model.task_alias` | Alias used for `task_agent` sub-sessions. Falls back to `[model].agent_model` in config.toml, then the session's active model. |
| `model.task_effort` | Reasoning effort for `task_agent`. Empty string means "inherit from the session". |
All four are live-editable from the Settings tab and take effect on the
Both are live-editable from the Settings tab and take effect on the
next sub-agent invocation — no restart required.
---
@@ -110,7 +107,7 @@ initialization:
| Section | Settings |
|---------|----------|
| `model` | default_alias, temperature, max_tokens, reasoning_effort, plan_alias, task_alias, plan_effort, task_effort |
| `model` | default_alias, temperature, max_tokens, reasoning_effort, task_alias, task_effort |
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
| `server` | workstream_idle_timeout, max_workstreams |
+104 -8
View File
@@ -8,7 +8,7 @@ inter-service communication, powered by [lacme](https://pypi.org/project/lacme/)
## Quick Start (Docker Compose)
```bash
docker compose -f compose.yaml -f deploy/docker-compose.tls.yml up
docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up
```
This:
@@ -19,6 +19,53 @@ This:
---
## Browser access (dashboard HTTPS)
The mTLS above secures **service-to-service** traffic (node↔node, collector and
routing proxy → nodes). The **console dashboard itself serves plain HTTP** — and
must, because it is the cluster's ACME bootstrap endpoint: new nodes fetch
`/acme/ca.pem` and provision their first cert over HTTP, before they have the CA
to verify TLS. So the console cannot be HTTPS-only on its port.
To put the **browser → console** hop on HTTPS, terminate TLS at a reverse proxy
in front of the console. The dev stack (root `compose.yaml`) ships a `caddy`
service that does exactly this — and it's the only published entry point, so the
dashboard is HTTPS by default:
```bash
docker compose up
# dashboard: https://localhost:${CONSOLE_HTTPS_PORT:-8443}
```
The production stack (`turnstone/deploy/compose.yaml`) bundles the same `caddy`
service, so the dashboard is HTTPS there too. For a real domain and a publicly
trusted cert, point Caddy at Let's Encrypt by editing `turnstone/deploy/Caddyfile`.
```
browser --h2 / HTTPS--> caddy:443 --h1.1 / HTTP--> console:8090
```
Caddy uses its **own local CA** (`tls internal`, see `turnstone/deploy/Caddyfile`), so the
setup is self-contained with no dependency on the console's ACME path. Trust the
local root once to silence the browser warning:
```bash
docker compose exec caddy \
cat /data/caddy/pki/authorities/local/root.crt # import into your OS/browser
```
**Can Caddy get its cert from the console's internal CA instead?** Technically
yes — the console exposes a real ACME directory (`/acme/directory`) with
auto-approval, so Caddy's `tls { ca http://console:8090/acme/directory }` would
mint a cert for any name. It's not recommended as the default: lacme's ACME
responder is built for turnstone's own client (interop with Caddy's client is
unverified), it couples Caddy startup to the console, and the browser must trust
a private CA either way — so it buys nothing over `tls internal`. For a publicly
trusted cert (no warning), point Caddy at Let's Encrypt with a real domain
instead.
---
## Architecture
```
@@ -41,6 +88,32 @@ Console (CA + ACME Server)
- **Frontend cert** (HTTPS): From an external ACME CA (e.g. Let's Encrypt)
if `tls.acme_directory` is set, otherwise self-issued from the internal CA.
### Boot, retry, and fallback
With `tls.enabled`, a node fetches the CA cert and requests its own cert
during startup, retrying with exponential backoff (6 attempts, ~31 s total)
— enough to absorb a whole-stack restart where every node races the console
for its listener. If all attempts fail, the node **falls back to plain
HTTP** (availability over confidentiality) and reports `"tls": "fallback"`
in `GET /health`; a node serving HTTPS reports `"tls": "active"`, and the
key is absent when TLS is disabled. Fallback persists until the next
restart — it is not upgraded in place.
### Container healthcheck under mTLS
An mTLS listener rejects plain-HTTP probes at the socket, so
`docker/healthcheck.py` falls back to HTTPS when the plain probe fails:
it presents the node's own cert as the client cert and pins the cluster
CA, using the PEM files the server writes at boot under
`$TURNSTONE_TLS_PEM_DIR` (default `<tmpdir>/turnstone-tls`). The probe
dials `localhost` for the TLS attempt — the internal CA issues DNS SANs
only, so a literal-IP URL would fail verification. Cert renewal rewrites
the PEM dir alongside the live listener swap, so the probe's client cert
never outlives the served cert. With TLS disabled the plain probe succeeds
and the PEM directory is never consulted. On bare metal with multiple
nodes per host, set `TURNSTONE_TLS_PEM_DIR` per node (each boot clears
stale `lacme-pem-*` dirs under its root).
---
## Configuration
@@ -173,8 +246,15 @@ const client = new TurnstoneServer({
1. Node starts, connects to shared database (plain connection)
2. Discovers console URL from `services` table
3. Fetches CA root cert from `http://console/acme/ca.pem` (plain HTTP, TOFU)
4. Requests service cert via ACME protocol (plain HTTP, JWS-signed)
5. Starts auto-renewal (24h interval, re-issues before expiry)
4. Requests a service cert via ACME (plain HTTP, JWS-signed). The cert's
primary domain / SAN is the node's **advertised host** (the host of
`TURNSTONE_ADVERTISE_URL`, e.g. `node-1`) — the name peers actually dial,
not the container hostname. This makes mTLS hostname verification succeed
and keys the cert by a stable name that survives container recreation.
5. Starts auto-renewal (24h interval, re-issues before expiry) **scoped to its
own certificate**. Each node renews only its own cert; the shared store is
never swept wholesale. Renewed certs are hot-swapped into the live HTTPS
listener with no restart.
6. All subsequent inter-service communication uses mTLS
### Console Startup Flow
@@ -183,7 +263,9 @@ const client = new TurnstoneServer({
2. Initialize CA (load from DB or generate new root key)
3. Mount ACME responder at `/acme` (serves `/ca.pem` natively)
4. Issue console certs (internal + optional frontend)
5. Start CA-direct auto-renewal (no network, signs directly)
5. Start CA-direct auto-renewal (no network, signs directly), scoped to the
console's own cert, plus a periodic GC that reclaims cert rows for
long-departed nodes
6. Register console URL in services table with heartbeat
---
@@ -195,17 +277,31 @@ const client = new TurnstoneServer({
Certs are valid for 48 hours. If auto-renewal stopped (e.g. console was down),
restart the service to re-request a cert.
### Collector/proxy can't reach a node (TLS hostname mismatch)
mTLS verifies a node's advertised host against the cert's SANs. Each node's
cert is issued for the host in its `TURNSTONE_ADVERTISE_URL`, so that name is
always a SAN automatically — you do **not** need to set `TURNSTONE_TLS_SANS`
per node. Only set `TURNSTONE_TLS_SANS` to add *extra* names (e.g. a node
fronted under a second hostname). Symptom if this is wrong: the console
dashboard shows nodes as unreachable and `openssl s_client` reports the served
cert's SANs don't include the dialed name.
### "No console service found"
The console registers itself in the `services` table on startup. If the console
hasn't started or the registration expired (1 hour TTL), nodes can't discover
it. Use `--console-url` explicitly.
### Let's Encrypt for console frontend
### Browser HTTPS to the console
Set `tls.acme_directory` to `https://acme-v02.api.letsencrypt.org/directory`
in the admin Settings tab. The console will request a publicly trusted cert
for its HTTPS endpoint. Internal mTLS still uses the private CA.
The console serves plain HTTP (it's the ACME bootstrap endpoint — see
[Browser access](#browser-access-dashboard-https)). Put browser traffic on
HTTPS by terminating TLS at a reverse proxy; the `cluster` profile's `caddy`
service does this with Caddy's local CA. For a publicly trusted cert, front the
console with a proxy pointed at Let's Encrypt using a real domain. The
`tls.acme_directory` setting only governs the console's internal/frontend cert
material — it does **not** make the console listen on HTTPS itself.
### Verifying the cert chain
+90 -117
View File
@@ -1,6 +1,6 @@
# Tools Reference
turnstone exposes 19 built-in tools plus any number of external MCP tools to the
turnstone exposes 16 built-in tools plus any number of external MCP tools to the
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
MCP tools are discovered from configured MCP servers at startup by
@@ -22,7 +22,6 @@ schema plus turnstone-specific metadata keys:
"properties": { ... },
"required": ["param1"]
},
"agent": true,
"task_agent": true,
"auto_approve": true,
"primary_key": "param1"
@@ -33,8 +32,7 @@ schema plus turnstone-specific metadata keys:
| Key | Type | Meaning |
|----------------|------|---------|
| `agent` | bool | Tool is available to plan/task sub-agents (read-only subset). |
| `task_agent` | bool | Tool is available to task sub-agents (broader subset). |
| `task_agent` | bool | Tool is available to task sub-agents. |
| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). |
| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
@@ -46,12 +44,10 @@ schema plus turnstone-specific metadata keys:
| Name | Description |
|---------------------|-------------|
| `TOOLS` | All 19 tool definitions (sent to the model). |
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. |
| `TOOLS` | All 28 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 19 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `TASK_AUTO_TOOLS` | Set of all tool names with `auto_approve: true` -- used by task-agent sub-sessions to skip confirmation for matching available tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 28 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
---
@@ -69,7 +65,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
- Parses the JSON arguments (with fallback for malformed JSON).
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
to the correct parameter.
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 19
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 16
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
the generic `_prepare_mcp_tool()` handler for MCP tools.
- Validates arguments and builds a preview dict containing:
@@ -111,9 +107,6 @@ Each item's `execute` callable is invoked:
denials are tracked separately. This removes the need for text-prefix heuristics.
Other tools deliver results atomically via
`ui.on_tool_result(call_id, name, output, is_error=...)` only.
- Special post-execution gate for `plan`: the plan output is shown to the user
for review, and the user can reject or annotate it.
---
## Tool Approval Flow
@@ -121,7 +114,6 @@ Each item's `execute` callable is invoked:
**Auto-approved** (no user confirmation needed at runtime):
- `read_file` -- reads files, no side effects
- `search` -- grep-style search, no side effects
- `man` -- reads man pages, no side effects
- `memory` -- structured persistent memory (save/search/delete/list)
- `recall` -- searches conversation history
- `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
@@ -130,16 +122,14 @@ Each item's `execute` callable is invoked:
- `bash` -- arbitrary command execution
- `write_file` -- creates or overwrites files
- `edit_file` -- modifies file content
- `math` -- sandboxed computation (confirmation required despite being sandboxed)
- `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests)
- `web_search` -- web search via Tavily API (makes network requests)
- `task` -- spawns an autonomous sub-agent
- `plan` -- spawns a planning sub-agent, plus post-execution review gate
- `web_search` -- web search via self-hosted SearxNG (makes network requests)
- `task_agent` -- spawns an autonomous sub-agent
Note: The JSON schema metadata key `auto_approve` controls membership in
`AGENT_AUTO_TOOLS`/`TASK_AUTO_TOOLS` (used for agent sub-sessions). The actual
runtime approval behavior is determined by the `needs_approval` field set in
each `_prepare_*` method on `ChatSession`. These two mechanisms can differ.
`TASK_AUTO_TOOLS` (used for task agent sub-sessions). The actual runtime
approval behavior is determined by the `needs_approval` field set in each
`_prepare_*` method on `ChatSession`. These two mechanisms can differ.
---
@@ -165,12 +155,9 @@ Every tool defines a `primary_key`. The mapping is:
| `write_file` | `content` |
| `edit_file` | `old_string`|
| `search` | `query` |
| `math` | `code` |
| `man` | `page` |
| `web_fetch` | `url` |
| `web_search` | `query` |
| `task_agent` | `prompt` |
| `plan_agent` | `goal` |
| `memory` | `name` |
| `recall` | `query` |
| `notify` | `message` |
@@ -194,7 +181,7 @@ Execute a bash command and return stdout + stderr.
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). Environment variables containing secrets are scrubbed (`*_KEY`, `*_SECRET`, `*_TOKEN`, etc.).
- **Output format**: Stdout is returned directly. Stderr lines are prefixed with `[stderr]` so the model can distinguish them. When the command itself redirects stderr to stdout (`2>&1`), no prefix is added. Output exceeding 256KB is truncated (head + tail preserved, middle replaced with a truncation notice).
- **Auto-approve**: No -- requires user confirmation.
- **Agent availability**: `task_agent` only (not available to plan sub-agents).
- **Agent availability**: `task_agent` only.
---
@@ -212,7 +199,7 @@ base64-encoded image data for supported image formats.
- **What it does**: For text files, reads and returns content with line numbers. For image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO), returns image data as multi-part content when the model supports vision, or a text description when it does not. SVG files are read as text. Images larger than 4 MB are rejected. Must be called before `edit_file` on the same path (the session tracks which files have been read).
- **Vision support**: Controlled by `ModelCapabilities.supports_vision`. All commercial OpenAI and Anthropic models have vision enabled. Local models (vLLM, llama.cpp, NIM) default to off — enable via `[models.*.capabilities] supports_vision = true` in config.toml.
- **Auto-approve**: Yes.
- **Agent availability**: `agent` and `task_agent`.
- **Agent availability**: `task_agent`.
---
@@ -268,7 +255,7 @@ Show a unified diff between two files, or between a file and a provided string.
- **What it does**: Returns unified diff output using Python's `difflib`. Binary files (containing null bytes) are rejected with a clear error. Files read through `diff_file` satisfy `edit_file`'s read guard — you can diff then edit without a separate `read_file` call. Large diffs are streamed with early cutoff at the tool truncation limit.
- **Auto-approve**: Yes (read-only).
- **Agent availability**: `agent` and `task_agent`.
- **Agent availability**: `task_agent`.
---
@@ -283,44 +270,12 @@ Search file contents for a regex pattern.
- **What it does**: Recursively searches for the pattern using `grep -rn`. Returns matching lines with file paths and line numbers.
- **Auto-approve**: Yes.
- **Agent availability**: `agent` and `task_agent`.
---
## Computation
### math
Execute Python code for math and computation in a sandbox.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `code` | string | yes | Python code to execute. Must use `print()` for output. |
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported. `pytest` is also available for import.
- **Installation**: `sympy`, `numpy`, `scipy`, and `pytest` require the `[sandbox]` extras group: `pip install turnstone[sandbox]` (included in `[all]`).
- **Auto-approve**: Yes.
- **Agent availability**: `agent` and `task_agent`.
- **Agent availability**: `task_agent`.
---
## Information
### man
Read a man page.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `page` | string | yes | The man page name (e.g. `grep`, `socket`, `printf`). |
| `section` | string | no | Manual section (e.g. `1` commands, `2` syscalls, `3` library). |
- **What it does**: Returns the full formatted manual entry. Preferred over `bash('man ...')` or `web_search` for command/API documentation.
- **Auto-approve**: Yes.
- **Agent availability**: `agent` and `task_agent`.
---
### web_fetch
Fetch a URL and extract specific information from it.
@@ -332,7 +287,7 @@ Fetch a URL and extract specific information from it.
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Protected against SSRF (blocks private/internal IPs).
- **Auto-approve**: No -- requires user confirmation (makes network requests).
- **Agent availability**: `agent` and `task_agent`.
- **Agent availability**: `task_agent`.
---
@@ -344,20 +299,55 @@ Search the web using a text query.
|---------------|---------|----------|-------------|
| `query` | string | yes | The search query. |
| `max_results` | integer | no | Max results to return (default 5, max 20). |
| `topic` | string | no | Search topic: `general`, `news`, or `finance` (default `general`). |
| `category` | string | no | Search category: `general` (default), `news`, `it` (code/tech), or `science`. Maps to SearxNG categories; the model picks per query. |
- **What it does**: Searches the web and returns ranked results with titles, URLs, and content snippets. Uses provider-native search when available:
- **Anthropic**: Replaced at the API boundary with Anthropic's `web_search_20250305` server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No Tavily key needed.
- **Anthropic**: Replaced at the API boundary with Anthropic's `web_search_20250305` server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No backend needed.
- **OpenAI search models** (`gpt-5-search-api`): Replaced with `web_search_options` parameter. The model always searches and returns `url_citation` annotations.
- **Local/vLLM models**: Falls back to the Tavily API. Requires `tavily_key` in `config.toml` or `$TAVILY_API_KEY`.
- **Local/vLLM models**: Falls back to a self-hosted [SearxNG](https://searxng.org) instance. Set `searxng_url` in `config.toml` `[tools]` or `$TURNSTONE_SEARXNG_URL` (the docker-compose stack bundles a `searxng` service and points at it by default). Operators with a custom MCP search server can instead set `web_search_backend = "mcp:server:tool"`.
- **Auto-approve**: Yes (auto-approved for all tool dispatch paths).
- **Agent availability**: `agent` and `task_agent`.
- **Agent availability**: `task_agent`.
---
### Reranking (optional)
`web_search` can use an external **reranker** to re-order the backend's result pool by relevance to the query before returning the top hits. Turnstone runs no reranker model itself; it POSTs to a Cohere/Jina-compatible `/rerank` endpoint (self-hosted [vLLM](https://docs.vllm.ai) / [TEI](https://github.com/huggingface/text-embeddings-inference) / llama.cpp, or hosted Cohere/Jina/Voyage).
**Disabled by default.** In the console **Models** tab, add a model definition whose `base_url` is a Cohere/Jina-compatible `/rerank` endpoint and whose capabilities include `{"supports_rerank": true}`, then select it under **Models → Roles → Reranker**. It's managed like every other model (write-only key, enable/disable, calibration). The reranker is purely this per-model definition — there is no global `rerank_url`-style endpoint setting.
The `rerank_web_search` toggle defaults on once a reranker is selected. If the endpoint is unreachable or errors, web_search falls back silently to the backend's native result order — reranking never makes a search fail.
When `rerank_bm25` is enabled, the candidate text for memory, tool, and skill retrieval (memory name/description/content and tool/skill names + descriptions) is also sent to the rerank endpoint — a self-hosted endpoint (vLLM/TEI/llama.cpp) keeps it on your infrastructure, a hosted provider (Cohere/Jina/Voyage) sends it off-box.
**Serving a Qwen3-Reranker with vLLM.** The model is instruction-aware, so vLLM **must** apply its chat template — pass `--chat-template` explicitly. Without it the bare query produces near-random scores and reranking actively *hurts* retrieval (verified: an irrelevant passage outscored the correct one):
```bash
vllm serve /models/Qwen3-Reranker-0.6B \
--runner pooling \
--hf-overrides '{"architectures":["Qwen3ForSequenceClassification"],"classifier_from_token":["no","yes"],"is_original_qwen3_reranker":true}' \
--chat-template /models/Qwen3-Reranker-0.6B/chat_template.jinja \
--served-model-name qwen3-reranker --port 8000
```
Then add a reranker model in the **Models** tab with `base_url` `http://vllm:8000/rerank` (model name `qwen3-reranker`) and select it under **Models → Roles → Reranker**.
For an endpoint that does *not* apply the model's template, set `rerank_instruction` instead — Turnstone then wraps each query as `<Instruct>: {instruction}` / `<Query>: {query}` (Qwen3's own default is `Given a web search query, retrieve relevant passages that answer the query`). Use the chat template **or** the instruction, not both (they double-wrap).
**Picking `rerank_bm25_threshold`.** The relevance floor that gates proactive memory injection is a probability in `[0, 1]`, but the right value differs per model (a sharp 0.6B reranker may want ~0.95; a broader 4B ~0.33). Calibrate it against your endpoint:
```bash
turnstone-admin rerank-calibrate # probe the endpoint, recommend a floor
turnstone-admin rerank-calibrate --apply # ...and write tools.rerank_bm25_threshold
```
It reports the score scale, whether the endpoint cleanly separates relevant from irrelevant probes (a **"no clean separation"** result flags a mis-served or weak reranker), and the suggested floor. Leave the threshold at `0` to rerank-without-filtering.
---
## Agent
Tool names use the `_agent` suffix — bare `plan` / `task` collide with
The tool name uses the `_agent` suffix — bare `task` collides with
chat-template channel names on some local models.
### task_agent
@@ -368,23 +358,9 @@ Delegate a general-purpose task to an autonomous sub-agent.
|-----------|--------|----------|-------------|
| `prompt` | string | yes | Complete task description for the sub-agent. |
- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, math, man, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution.
- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution.
- **Auto-approve**: No -- requires user confirmation.
- **Agent availability**: Not available to sub-agents (top-level only).
---
### plan_agent
Plan before implementing -- an autonomous agent explores the codebase and writes a structured plan.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `prompt` | string | yes | What to plan -- the goal, constraints, and scope. |
- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-<ws_id>.md` (unique per workstream, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan.
- **Auto-approve**: No -- requires user confirmation, plus post-execution review gate.
- **Agent availability**: Not available to sub-agents (top-level only).
- **Agent availability**: Top-level only.
---
@@ -445,7 +421,7 @@ Provide either `username` for user-based targeting or `channel_type` +
- **What it does**: Sends a notification via the channel gateway's HTTP endpoint (`POST /v1/api/notify`). The server queries the `services` table for healthy channel gateways, authenticates with a service JWT (`aud: turnstone-channel`), and delivers to the first healthy gateway. On failure, retries up to 2 additional times with backoff (1s, 3s). Rate-limited to 5 notifications per turn (counter only increments on success).
- **Auto-approve**: Yes — notifications are time-sensitive and auto-approved so the model can alert users urgently.
- **Agent availability**: `agent` and `task_agent`.
- **Agent availability**: `task_agent`.
> See [Channel Integrations: Notifications](channels.md#notifications)
> for the full delivery flow, service registry details, and security
@@ -521,7 +497,7 @@ data.get("mergedAt") is not None
- Duplicate names rejected within the same workstream.
- **Auto-approve**: `create` requires approval; `list` and `cancel` are auto-approved.
- **Agent availability**: Main session only — not available to plan/task sub-agents.
- **Agent availability**: Main session only — not available to task sub-agents.
> See [Watch Architecture](diagrams/png/18-watch-architecture.png) for the
> full poll → evaluate → dispatch flow.
@@ -554,33 +530,30 @@ pre-configure skills at workstream creation.
- **Auto-approve**: `load` requires approval (changes session behavior); `search`
is auto-approved (read-only).
- **Agent availability**: Main session only — not available to plan/task sub-agents.
- **Agent availability**: Main session only — not available to task sub-agents.
---
## Summary Table
| Tool | Category | Auto-approve | agent | task_agent | primary_key |
|--------------|------------|--------------|-------|------------|-------------|
| `bash` | File Ops | No | No | Yes | `command` |
| `read_file` | File Ops | Yes | Yes | Yes | `path` |
| `write_file` | File Ops | No | No | Yes | `content` |
| `edit_file` | File Ops | No | No | Yes | `old_string`|
| `search` | File Ops | Yes | Yes | Yes | `query` |
| `math` | Compute | No | Yes | Yes | `code` |
| `man` | Info | Yes | Yes | Yes | `page` |
| `web_fetch` | Info | No | Yes | Yes | `url` |
| `web_search` | Info | No | Yes | Yes | `query` |
| `task_agent` | Agent | No | No | No | `prompt` |
| `plan_agent` | Agent | No | No | No | `goal` |
| `memory` | Memory | Yes | No | No | `name` |
| `recall` | Memory | Yes | No | No | `query` |
| `notify` | Notify | Yes | Yes | Yes | `message` |
| `watch` | Monitor | No (create) | No | No | `command` |
| `read_resource`| MCP | No | Yes | Yes | `uri` |
| `use_prompt` | MCP | No | Yes | Yes | `name` |
| `skill` | Skills | No (load) | No | No | `name` |
| `tool_search`| Search | Yes | No | No | `query` |
| Tool | Category | Auto-approve | task_agent | primary_key |
|--------------|------------|--------------|------------|-------------|
| `bash` | File Ops | No | Yes | `command` |
| `read_file` | File Ops | Yes | Yes | `path` |
| `write_file` | File Ops | No | Yes | `content` |
| `edit_file` | File Ops | No | Yes | `old_string`|
| `search` | File Ops | Yes | Yes | `query` |
| `web_fetch` | Info | No | Yes | `url` |
| `web_search` | Info | No | Yes | `query` |
| `task_agent` | Agent | No | No | `prompt` |
| `memory` | Memory | Yes | No | `name` |
| `recall` | Memory | Yes | No | `query` |
| `notify` | Notify | Yes | Yes | `message` |
| `watch` | Monitor | No (create) | No | `command` |
| `read_resource`| MCP | No | Yes | `uri` |
| `use_prompt` | MCP | No | Yes | `name` |
| `skill` | Skills | No (load) | No | `name` |
| `tool_search`| Search | Yes | No | `query` |
---
@@ -632,8 +605,9 @@ CLI flags override the config file:
directly.
2. **Partitioning**: When active, tools are split into two sets:
- **Always-on** -- the 19 built-in tools (members of `BUILTIN_TOOL_NAMES`).
These are always visible to the model.
- **Always-on** -- the built-in tools present in the current session
(interactive sessions currently have 16; `BUILTIN_TOOL_NAMES` is the
28-tool built-in union). These are always visible to the model.
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
the model searches for them.
@@ -647,10 +621,10 @@ CLI flags override the config file:
### Agent exemption
Plan and task sub-agents do not use tool search. They operate on scoped tool
sets (`AGENT_TOOLS` for plan agents, `TASK_AGENT_TOOLS` for task agents) with
MCP tools merged in. Tool search is only active for the top-level session,
where the model can interactively search for tools it needs.
Task sub-agents do not use tool search. They operate on the scoped tool set
(`TASK_AGENT_TOOLS`) with MCP tools merged in. Tool search is only active for
the top-level session, where the model can interactively search for tools it
needs.
---
@@ -675,7 +649,7 @@ MCP-compatible service.
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
4. **Merging**: MCP tools are appended after the 19 built-in tools via
4. **Merging**: MCP tools are appended after the 16 built-in tools via
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
When dynamic tool search is active, MCP tools are deferred rather than directly
visible -- the model discovers them via search as needed (see
@@ -701,7 +675,6 @@ gives per-tool-type granularity (e.g., all `use_prompt` calls).
MCP tools are available to:
- **Main session** — full access
- **Task sub-agents** — via `self._task_tools` (merged list)
- **Plan sub-agents** — via `self._agent_tools` (merged list)
### Naming convention
@@ -774,7 +747,7 @@ MCP tool lists stay up-to-date without restart through two mechanisms:
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
instances via registered listener callbacks. Each session rebuilds its `_tools`,
`_task_tools`, `_agent_tools`, and reconstructs its `ToolSearchManager` (if active),
`_task_tools`, and reconstructs its `ToolSearchManager` (if active),
preserving the set of previously expanded (discovered) tools.
```
@@ -830,7 +803,7 @@ Use read_resource(uri='...') to access the resources listed above.
- **What it does**: Reads the resource from its MCP server via `MCPClientManager.read_resource_sync()`. Returns text content for text resources or base64-encoded data for binary resources. Output is truncated by the standard tool output limiter.
- **Auto-approve**: No -- requires user confirmation (reads external data).
- **Agent availability**: `agent` and `task_agent`.
- **Agent availability**: `task_agent`.
### Capability guards
@@ -872,7 +845,7 @@ the `initialize` handshake. Each prompt is stored with its prefixed name
- **What it does**: Invokes an MCP prompt by name via `MCPClientManager.get_prompt_sync()`, expanding it into messages. Returns the expanded prompt content formatted as `[role]: content` blocks joined with blank lines. The prompt catalog is listed in the system message so the model knows which prompts are available. Output is truncated by the standard tool output limiter.
- **Auto-approve**: No -- requires user confirmation (invokes external prompt servers).
- **Agent availability**: `agent` and `task_agent`.
- **Agent availability**: `task_agent`.
### Invocation
+1 -1
View File
@@ -7,7 +7,7 @@ name = "mcp-cluster-ops"
version = "0.1.0"
description = "MCP server for Turnstone cluster operations — reference implementation."
requires-python = ">=3.11"
license = "BUSL-1.1"
license = "Apache-2.0"
dependencies = [
"turnstone",
"mcp>=1.6",
+23 -22
View File
@@ -4,10 +4,11 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.6.0a2"
version = "1.6.2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
license = "Apache-2.0"
license-files = ["LICENSE", "NOTICE", "THIRD-PARTY-NOTICES"]
requires-python = ">=3.11"
authors = [{name = "Patrick Buckley", email = "buckleypm@gmail.com"}]
keywords = ["ai", "chat", "llm", "agent", "tools", "openai"]
@@ -23,19 +24,23 @@ classifiers = [
]
dependencies = [
"openai>=2.37",
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
"httpx>=0.28",
"mcp>=1.27",
"starlette>=0.45",
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
"starlette>=1.0.1", # PYSEC-2026-161: host-header path-injection in URL reconstruction (auth-bypass on apps comparing reconstructed URL paths)
"uvicorn>=0.34",
"sse-starlette>=2.0",
"httpx-sse>=0.4",
"pydantic>=2.0",
"sqlalchemy>=2.0",
"alembic>=1.14",
"psycopg[binary]>=3.2",
"croniter>=3.0",
"structlog>=24.1",
"PyJWT>=2.8",
"bcrypt>=4.0",
"cryptography>=42",
"lacme>=1.0.5",
"python-frontmatter>=1.0",
]
@@ -45,17 +50,11 @@ Repository = "https://github.com/turnstonelabs/turnstone"
Issues = "https://github.com/turnstonelabs/turnstone/issues"
[project.optional-dependencies]
test = ["pytest>=9.0", "pytest-cov>=6.0", "croniter>=3.0", "slack-bolt>=1.18", "aiohttp>=3.9"]
test = ["pytest>=9.0", "pytest-cov>=6.0", "slack-bolt>=1.18", "aiohttp>=3.9"]
dev = ["ruff>=0.9", "mypy>=1.14"]
console = ["croniter>=3.0"]
anthropic = ["anthropic>=0.39"]
postgres = ["psycopg[binary]>=3.2"]
ddg = ["ddgs>=9.0"]
discord = ["discord.py>=2.4"]
tls = ["lacme>=1.0.5"]
sandbox = ["sympy>=1.13", "numpy>=2.0", "scipy>=1.14", "pytest>=9.0"]
slack = ["slack-bolt>=1.18", "aiohttp>=3.9"]
all = ["turnstone[console,anthropic,postgres,discord,ddg,tls,sandbox,slack]"]
all = ["turnstone[discord,slack]"]
[project.scripts]
turnstone = "turnstone.cli:main"
@@ -82,17 +81,28 @@ include = [
"turnstone/console/static/coordinator/*.js",
"turnstone/shared_static/*.css",
"turnstone/shared_static/*.js",
"turnstone/shared_static/katex-0.16.47/**/*",
"turnstone/shared_static/katex-0.17.0/**/*",
"turnstone/shared_static/hljs-11.11.1/**/*",
"turnstone/shared_static/mermaid-11.15.0/**/*",
"turnstone/shared_static/hls-1.6.16/**/*",
"turnstone/sdk/py.typed",
"turnstone/deploy/*.yaml",
"turnstone/deploy/Caddyfile",
"turnstone/deploy/searxng/settings.yml",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["live: requires a running LLM backend"]
filterwarnings = [
# mcp v1 deprecates streamablehttp_client for an entry point whose call
# shape only settles in v2 — adoption rides the deliberate v2 migration
# (pin capped <2); silence exactly this message until then.
"ignore:Use `streamable_http_client` instead",
# starlette deprecates the httpx-backed TestClient; revisit at the next
# starlette floor bump.
"ignore:Using `httpx` with `starlette.testclient` is deprecated",
]
[tool.ruff]
target-version = "py311"
@@ -101,7 +111,6 @@ line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
ignore = ["E501"]
per-file-ignores = { "turnstone/core/sandbox.py" = ["N802"] }
[tool.ruff.format]
quote-style = "double"
@@ -131,10 +140,6 @@ exclude_lines = [
'if __name__ == "__main__"',
]
[[tool.mypy.overrides]]
module = ["sympy", "sympy.*", "numpy", "numpy.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["mcp", "mcp.*"]
ignore_missing_imports = true
@@ -171,10 +176,6 @@ ignore_missing_imports = true
module = ["frontmatter", "frontmatter.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["ddgs", "ddgs.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["lacme", "lacme.*"]
ignore_missing_imports = true
Executable
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env bash
#
# Turnstone one-line installer.
#
# curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
#
# Autodetects your distro (Ubuntu/Debian, Fedora/RHEL, Arch, and WSL on any of
# them) and:
# 1. ensures git is installed, then clones the repo
# 2. ensures Docker + the compose plugin are installed and the daemon is usable
# 3. asks how many server nodes to run (1-10)
# 4. builds the image
# 5. picks free host ports for Caddy (prefers 443) and PostgreSQL
# 6. writes a .env with a generated JWT secret + Postgres password
# 7. pins the node count and runs `docker compose up -d`, then prints how to
# finish setup in the UI
#
# Re-running is safe: it updates the checkout and keeps an existing .env.
#
# Env overrides:
# TURNSTONE_DIR where to clone (default: $HOME/turnstone)
# TURNSTONE_REPO git URL (default: https://github.com/turnstonelabs/turnstone.git)
set -Eeuo pipefail
REPO_URL="${TURNSTONE_REPO:-https://github.com/turnstonelabs/turnstone.git}"
INSTALL_DIR="${TURNSTONE_DIR:-$HOME/turnstone}"
# -- output helpers -----------------------------------------------------------
if [ -t 1 ]; then
BOLD=$'\033[1m'; DIM=$'\033[2m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'
RED=$'\033[31m'; RESET=$'\033[0m'
else
BOLD=""; DIM=""; GREEN=""; YELLOW=""; RED=""; RESET=""
fi
info() { printf '%s==>%s %s\n' "$GREEN" "$RESET" "$*"; }
warn() { printf '%swarning:%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
die() { printf '%serror:%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
# Any *unhandled* failure (set -Ee) lands here with an actionable note instead of
# a bare non-zero exit. `die` exits explicitly and does not trigger this.
on_error() {
local rc=$?
printf '\n%serror:%s the installer stopped unexpectedly (exit %s). The output above shows why.\n' \
"$RED" "$RESET" "$rc" >&2
printf ' Fix the issue and re-run this script — it resumes the parts already done.\n' >&2
}
trap on_error ERR
# Ask a yes/no question, reading from the terminal even under `curl | bash`
# (where stdin is the script). Defaults to "$2" when non-interactive.
ask() {
local prompt="$1" default="${2:-y}" ans hint
[ "$default" = y ] && hint="Y/n" || hint="y/N"
if [ ! -r /dev/tty ]; then
warn "non-interactive shell; assuming '$default' for: $prompt"
[ "$default" = y ]; return
fi
printf '%s%s%s [%s] ' "$BOLD" "$prompt" "$RESET" "$hint" >/dev/tty
read -r ans </dev/tty || ans=""
ans="${ans:-$default}"
case "$ans" in [Yy]*) return 0 ;; *) return 1 ;; esac
}
# -- distro / package manager detection --------------------------------------
OS_ID=""; OS_LIKE=""; PKG=""; IS_WSL=0; SUDO=""
detect_os() {
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
fi
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null || [ -n "${WSL_DISTRO_NAME:-}" ]; then
IS_WSL=1
fi
case " $OS_ID $OS_LIKE " in
*" arch "*|*manjaro*) PKG=pacman ;;
*" ubuntu "*|*" debian "*) PKG=apt ;;
*" fedora "*|*" rhel "*|*" centos "*) PKG=dnf ;;
*) if have apt-get; then PKG=apt
elif have dnf; then PKG=dnf
elif have yum; then PKG=yum
elif have pacman; then PKG=pacman
fi ;;
esac
[ -n "$PKG" ] || die "could not detect a supported package manager (apt/dnf/yum/pacman). Install git + Docker manually, then re-run."
[ "$PKG" = dnf ] && ! have dnf && have yum && PKG=yum
if [ "$(id -u)" -ne 0 ]; then
have sudo || die "this script needs root for package installs — install sudo or run as root."
SUDO="sudo"
fi
local where="$OS_ID"; [ "$IS_WSL" -eq 1 ] && where="$OS_ID (WSL)"
info "Detected ${where:-linux} — using ${PKG}."
}
pkg_install() {
info "Installing: $*"
case "$PKG" in
apt) $SUDO apt-get update -y && $SUDO apt-get install -y "$@" ;;
dnf) $SUDO dnf install -y "$@" ;;
yum) $SUDO yum install -y "$@" ;;
pacman) $SUDO pacman -Sy --needed --noconfirm "$@" ;;
esac
}
# -- git ----------------------------------------------------------------------
ensure_git() {
have git && return
warn "git is not installed."
ask "Install git now?" y || die "git is required to clone the repo."
pkg_install git || die "git installation failed — see the output above."
have git || die "git installation reported success but 'git' is not on PATH."
}
clone_repo() {
if [ -d "$INSTALL_DIR/.git" ]; then
info "Updating existing checkout at $INSTALL_DIR"
git -C "$INSTALL_DIR" pull --ff-only || warn "could not fast-forward; using the existing checkout."
else
[ -e "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ] \
&& die "$INSTALL_DIR exists and is not a turnstone checkout. Set TURNSTONE_DIR to an empty path."
info "Cloning $REPO_URL into $INSTALL_DIR"
# Skip Git LFS smudge — the LFS objects are only diagram PNGs, not needed to run.
GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 "$REPO_URL" "$INSTALL_DIR"
fi
}
# -- docker -------------------------------------------------------------------
DOCKER="docker"
install_docker() {
case "$PKG" in
apt|dnf|yum)
info "Installing Docker via the official get.docker.com script"
curl -fsSL https://get.docker.com | $SUDO sh ;;
pacman)
pkg_install docker docker-compose ;;
esac
# Best-effort: start the daemon and let the current user run docker.
if have systemctl; then
$SUDO systemctl enable --now docker 2>/dev/null || true
fi
if [ "$(id -u)" -ne 0 ] && getent group docker >/dev/null 2>&1; then
$SUDO usermod -aG docker "$USER" 2>/dev/null || true
fi
}
start_docker_daemon() {
if have systemctl; then
$SUDO systemctl start docker 2>/dev/null || true
elif have service; then
$SUDO service docker start 2>/dev/null || true
fi
}
# Resolve how to invoke docker (direct / via sudo) and make sure the daemon runs.
ensure_docker_usable() {
if ! have docker; then
warn "Docker is not installed."
ask "Install Docker now?" y || die "Docker is required."
install_docker
have docker || die "Docker installation failed."
fi
docker info >/dev/null 2>&1 && { DOCKER="docker"; return; }
# Maybe the daemon just isn't running yet.
start_docker_daemon
docker info >/dev/null 2>&1 && { DOCKER="docker"; return; }
# Maybe we lack permission (not in the docker group yet, group change not
# active in this shell) — fall back to sudo for this run.
if [ "$(id -u)" -ne 0 ] && have sudo && sudo docker info >/dev/null 2>&1; then
DOCKER="sudo docker"
warn "Using 'sudo docker' for this run. To drop the sudo: 'sudo usermod -aG docker $USER' then log out and back in."
return
fi
if [ "$IS_WSL" -eq 1 ]; then
die "Docker isn't usable inside WSL. Install Docker Desktop on Windows and enable WSL integration for this distro (Settings -> Resources -> WSL integration), then re-run."
fi
die "Docker is installed but not usable — the daemon may be stopped or you lack permission. Try: 'sudo systemctl start docker', then re-run."
}
ensure_compose() {
$DOCKER compose version >/dev/null 2>&1 && return
warn "The Docker Compose v2 plugin is missing."
case "$PKG" in
apt|dnf|yum) ask "Install docker-compose-plugin?" y && pkg_install docker-compose-plugin || true ;;
pacman) ask "Install docker-compose?" y && pkg_install docker-compose || true ;;
esac
$DOCKER compose version >/dev/null 2>&1 \
|| die "'docker compose' is unavailable. Install Docker Compose v2 and re-run."
}
# -- node count ---------------------------------------------------------------
NODE_COUNT=10
pick_node_count() {
if [ ! -r /dev/tty ]; then
info "Non-interactive shell — starting the recommended 10-node cluster."
return
fi
cat >/dev/tty <<EOF
${BOLD}How many server nodes? (1-10)${RESET}
The full 10-node cluster is recommended — it best shows off routing across
nodes. Each node uses on the order of a few hundred MB of RAM at idle; MCP
stdio servers that a node launches can raise that. Pick fewer on a small
machine — you can always add nodes later.
EOF
local ans
while :; do
printf '%sNodes [default 10]:%s ' "$BOLD" "$RESET" >/dev/tty
read -r ans </dev/tty || ans=""
ans="${ans:-10}"
case "$ans" in
[1-9]|10) NODE_COUNT="$ans"; break ;;
*) printf 'Please enter a whole number from 1 to 10.\n' >/dev/tty ;;
esac
done
info "Will start ${NODE_COUNT} server node(s)."
}
# Pin the chosen count: park nodes above NODE_COUNT behind the "extra" profile in
# an auto-loaded compose.override.yaml, so a later *plain* `docker compose up -d`
# keeps the same count (Compose merges compose.override.yaml automatically). A
# full (10) choice removes the file. Never clobbers an override we didn't write.
OVERRIDE_MARKER="# turnstone run.sh — node-count limiter (safe to delete)"
write_node_override() {
local f="$INSTALL_DIR/compose.override.yaml" k
if [ -f "$f" ] && ! head -1 "$f" 2>/dev/null | grep -qF "$OVERRIDE_MARKER"; then
warn "$f exists and isn't managed by this installer — leaving it as-is."
warn "A plain 'docker compose up -d' may not match your ${NODE_COUNT}-node choice."
return
fi
if [ "$NODE_COUNT" -ge 10 ]; then
rm -f "$f"
return
fi
{
echo "$OVERRIDE_MARKER"
echo "# Keeps 'docker compose up -d' at ${NODE_COUNT} node(s). Nodes above"
echo "# node-${NODE_COUNT} are parked behind the 'extra' profile:"
echo "# docker compose --profile extra up -d # start all 10"
echo "# docker compose up -d node-$((NODE_COUNT + 1)) # start one more"
echo "services:"
for k in $(seq $((NODE_COUNT + 1)) 10); do
printf ' node-%s: { profiles: ["extra"] }\n' "$k"
done
} >"$f"
info "Pinned ${NODE_COUNT} node(s) in $f (a later 'docker compose up -d' honors it)."
}
# -- ports --------------------------------------------------------------------
port_in_use() {
local p="$1"
if have ss; then
ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}\$" && return 0 || return 1
fi
if have lsof; then
lsof -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1 && return 0 || return 1
fi
# bash fallback: a successful connect means something is listening.
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && { exec 3>&- 3<&-; return 0; }
return 1
}
port_free() { ! port_in_use "$1"; }
random_high_port() {
local p
for _ in $(seq 1 25); do
p=$(( (RANDOM % 64000) + 1024 ))
port_free "$p" && { echo "$p"; return; }
done
echo "" # caller handles
}
pick_caddy_port() {
# Prefer 443; rootless Docker can't bind privileged ports, so skip it there.
if [ "$ROOTLESS" -eq 0 ] && port_free 443; then echo 443; return; fi
port_free 8443 && { echo 8443; return; }
local p; p="$(random_high_port)"
[ -n "$p" ] && { echo "$p"; return; }
echo 8443
}
pick_pg_port() {
port_free 5432 && { echo 5432; return; }
local p; p="$(random_high_port)"
[ -n "$p" ] && { echo "$p"; return; }
echo 5432
}
# -- secrets / .env -----------------------------------------------------------
gen_hex() {
# $1 = number of bytes → 2*$1 hex chars
if have openssl; then openssl rand -hex "$1"
elif have python3; then python3 -c 'import secrets,sys; print(secrets.token_hex(int(sys.argv[1])))' "$1"
else head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
fi
}
_env_get() { # _env_get KEY DEFAULT → value from $INSTALL_DIR/.env, else DEFAULT
local v
v="$(grep -E "^$1=" "$INSTALL_DIR/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
printf '%s' "${v:-$2}"
}
# Resolve CADDY_PORT + PG_PORT and ensure a .env exists. On re-run the existing
# .env (secrets + ports) is reused so the rest of the script reports the ports
# the stack actually binds, not freshly-picked ones.
prepare_env() {
if [ -f "$INSTALL_DIR/.env" ]; then
info "Keeping existing $INSTALL_DIR/.env (secrets and ports preserved)."
chmod 600 "$INSTALL_DIR/.env" 2>/dev/null || true
CADDY_PORT="$(_env_get CONSOLE_HTTPS_PORT 8443)"
PG_PORT="$(_env_get POSTGRES_PORT 5432)"
return
fi
CADDY_PORT="$(pick_caddy_port)"
PG_PORT="$(pick_pg_port)"
local jwt pgpw
jwt="$(gen_hex 32)" # 64 hex chars — comfortably over the 32-char minimum
pgpw="$(gen_hex 18)" # hex keeps it URL-safe in the Postgres DSN
# Write 0600 from the start (umask scoped to the subshell so it doesn't leak).
(
umask 077
cat >"$INSTALL_DIR/.env" <<EOF
# Generated by run.sh — keep this private.
TURNSTONE_JWT_SECRET=$jwt
POSTGRES_USER=turnstone
POSTGRES_PASSWORD=$pgpw
POSTGRES_BIND=127.0.0.1
POSTGRES_PORT=$PG_PORT
CONSOLE_HTTPS_PORT=$CADDY_PORT
EOF
)
chmod 600 "$INSTALL_DIR/.env"
info "Wrote $INSTALL_DIR/.env (generated JWT secret + Postgres password, mode 600)."
}
# -- summary ------------------------------------------------------------------
print_done() {
local url scale
[ "$CADDY_PORT" = 443 ] && url="https://localhost" || url="https://localhost:${CADDY_PORT}"
if [ "$NODE_COUNT" -lt 10 ]; then
scale="${NODE_COUNT} of 10 nodes — pinned in compose.override.yaml, so a plain
'docker compose up -d' keeps it. Start all 10 anytime:
${DIM}cd $INSTALL_DIR && $DOCKER compose --profile extra up -d${RESET}"
else
scale="all 10 nodes. To run fewer, stop some:
${DIM}cd $INSTALL_DIR && $DOCKER compose stop node-8 node-9 node-10${RESET}"
fi
cat <<EOF
${GREEN}${BOLD}Turnstone is running${RESET} (${NODE_COUNT} node$([ "$NODE_COUNT" = 1 ] || echo s)).
Dashboard ${BOLD}${url}${RESET}
Caddy serves it with its own local CA, so your browser warns once.
Trust it (optional):
${DIM}cd $INSTALL_DIR && $DOCKER compose exec caddy cat /data/caddy/pki/authorities/local/root.crt${RESET}
Finish setup
1. Create the first admin user:
${DIM}cd $INSTALL_DIR && $DOCKER compose exec node-1 turnstone-admin create-user --username admin --name "Admin"${RESET}
2. Open ${url}, log in, and add a model backend in the ${BOLD}Models${RESET} tab —
a local server (vLLM / llama.cpp / Ollama) or an OpenAI / Anthropic / Gemini key.
Nodes boot without a model and pick it up live; no restart needed.
Scale Running ${scale}
Manage ${DIM}cd $INSTALL_DIR${RESET}
${DIM}$DOCKER compose ps${RESET} status
${DIM}$DOCKER compose logs -f${RESET} logs
${DIM}$DOCKER compose down${RESET} stop (add -v to wipe data)
Config $INSTALL_DIR/.env (generated secrets + ports)
EOF
}
# -- main ---------------------------------------------------------------------
main() {
printf '%s%sTurnstone installer%s\n\n' "$BOLD" "$GREEN" "$RESET"
detect_os
ensure_git
clone_repo
ensure_docker_usable
ensure_compose
ROOTLESS=0
$DOCKER info 2>/dev/null | grep -qi 'rootless' && ROOTLESS=1
pick_node_count
info "Building the image (first run pulls dependencies — this can take a few minutes)…"
if ! ( cd "$INSTALL_DIR" && $DOCKER compose build ); then
die "image build failed (see output above). Common causes: low memory or disk, or the Docker daemon stopped. Free up resources and re-run — it resumes."
fi
prepare_env
info "Ports — dashboard (Caddy): ${CADDY_PORT}, PostgreSQL: 127.0.0.1:${PG_PORT}"
write_node_override
info "Starting the stack…"
# Plain `up -d` (honoring compose.override.yaml) + --remove-orphans so a
# re-run that lowers the count also stops the now-excluded nodes.
if ! ( cd "$INSTALL_DIR" && $DOCKER compose up -d --remove-orphans ); then
die "the stack failed to start (see output above). Inspect logs: cd $INSTALL_DIR && $DOCKER compose logs"
fi
print_done
}
main "$@"
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Manual BM25-vs-BM25→rerank benchmark for the retrieval surfaces.
Run this by hand on a host that has a Cohere/Jina-compatible rerank endpoint
configured (vLLM, TEI, llama.cpp, or a hosted Cohere/Jina/Voyage key). It is NOT
a pytest test and is never collected by the test suite it needs a live endpoint
to do anything useful.
It compares plain BM25 top-k against the two-stage BM25rerank path on two small
in-file labeled corpora (tool-like docs and synthetic memory dicts), printing
precision@k, MRR, a ranking diff, and the relevant-vs-irrelevant score
distribution so you can pick a sensible ``tools.rerank_bm25_threshold`` default.
The endpoint bearer token (for hosted providers) is read from the
``$TURNSTONE_RERANK_API_KEY`` environment variable, never a flag, so it does not
land in shell history or the process listing.
Example::
TURNSTONE_RERANK_API_KEY=... \
.venv/bin/python scripts/bench_bm25_rerank.py \
--rerank-url http://localhost:8000/rerank \
--rerank-model BAAI/bge-reranker-v2-m3 --k 3 --threshold 0.0
"""
from __future__ import annotations
import argparse
import os
import statistics
from typing import TYPE_CHECKING
from turnstone.core.bm25 import BM25Index
from turnstone.core.rerank import resolve_rerank_client
if TYPE_CHECKING:
from collections.abc import Callable
from turnstone.core.rerank import RerankClient
# (query, relevant-doc-name) labels over a handful of tool-like docs.
TOOL_DOCS: list[dict[str, str]] = [
{"name": "read_file", "description": "Read the contents of a file from disk"},
{"name": "write_file", "description": "Write or overwrite a file on disk"},
{"name": "list_dir", "description": "List the entries in a directory"},
{"name": "web_search", "description": "Search the web and return result snippets"},
{"name": "send_email", "description": "Send an email message to a recipient"},
{"name": "run_bash", "description": "Execute a shell command and capture output"},
{"name": "create_issue", "description": "Open a new issue in the bug tracker"},
{"name": "query_db", "description": "Run a SQL query against the database"},
]
TOOL_LABELS: list[tuple[str, str]] = [
("open a file and show me what's inside", "read_file"),
("look something up on the internet", "web_search"),
("send a message by email", "send_email"),
("run a terminal command", "run_bash"),
("file a bug report", "create_issue"),
("fetch rows from the database", "query_db"),
]
# Synthetic memory dicts (name + description + content) with labeled queries.
MEMORY_DOCS: list[dict[str, str]] = [
{
"name": "postgres_conn",
"description": "database connection settings",
"content": "host=db.internal port=5432 user=app sslmode=require",
},
{
"name": "redis_cache",
"description": "cache server config",
"content": "host=redis port=6379 maxmemory 2gb eviction allkeys-lru",
},
{
"name": "deploy_runbook",
"description": "production deploy steps",
"content": "build image, push to registry, roll nodes one at a time",
},
{
"name": "oncall_rotation",
"description": "who is on call",
"content": "primary alice, secondary bob, escalate to carol after 30m",
},
{
"name": "tls_certs",
"description": "certificate renewal",
"content": "acme renews every 60 days; caddy reloads the SSLContext in place",
},
{
"name": "api_ratelimits",
"description": "rate limit policy",
"content": "100 req/min per token, burst 20, 429 with retry-after header",
},
{
"name": "backup_schedule",
"description": "nightly backups",
"content": "pg_dump at 02:00 UTC, retained 14 days, offsite copy weekly",
},
{
"name": "feature_flags",
"description": "rollout toggles",
"content": "rerank_bm25 default on, voice_io default off, smart_approvals off",
},
{
"name": "smtp_settings",
"description": "outbound email",
"content": "relay smtp.internal port 587 starttls from noreply@example.com",
},
{
"name": "log_retention",
"description": "log storage policy",
"content": "structured logs to loki, 30 day retention, audit logs 1 year",
},
{
"name": "node_placement",
"description": "routing config",
"content": "rendezvous hashing fnv-1a, ~100 node ceiling, hrw weights",
},
{
"name": "jwt_secret_rotation",
"description": "auth secret",
"content": "TURNSTONE_JWT_SECRET in config.toml, rotate quarterly, hs256",
},
]
MEMORY_LABELS: list[tuple[str, str]] = [
("what is the postgres database host and port", "postgres_conn"),
("how often do we rotate the jwt signing secret", "jwt_secret_rotation"),
("when do nightly database backups run", "backup_schedule"),
("who do I escalate an incident to", "oncall_rotation"),
("how are nodes placed for routing", "node_placement"),
("what is the per token api rate limit", "api_ratelimits"),
]
def _doc_text(d: dict[str, str]) -> str:
return " ".join(
filter(None, (d.get("name", ""), d.get("description", ""), d.get("content", "")))
)
def _make_rank(client: RerankClient, threshold: float) -> Callable[[str, list[str]], list[int]]:
def _rank(query: str, docs: list[str]) -> list[int]:
return [
h.index for h in client.rerank(query, docs) if threshold <= 0 or h.score >= threshold
]
return _rank
def _precision_at_k(result_names: list[str], relevant: str, k: int) -> float:
return 1.0 / k if relevant in result_names[:k] else 0.0
def _reciprocal_rank(result_names: list[str], relevant: str) -> float:
for i, name in enumerate(result_names, 1):
if name == relevant:
return 1.0 / i
return 0.0
def _bench_corpus(
title: str,
docs: list[dict[str, str]],
labels: list[tuple[str, str]],
client: RerankClient,
threshold: float,
k: int,
) -> None:
print(f"\n=== {title} ({len(docs)} docs, {len(labels)} queries, k={k}) ===")
texts = [_doc_text(d) for d in docs]
names = [d["name"] for d in docs]
plain = BM25Index(texts)
# Filter mode when a floor is set (mirrors the memory composition call site)
# so --threshold actually suppresses below-floor hits rather than being
# masked by reorder-mode backfill; reorder mode at threshold 0.
reranked = BM25Index(
texts, reranker=_make_rank(client, threshold), rerank_filters=threshold > 0
)
bm25_p = bm25_mrr = rr_p = rr_mrr = 0.0
for query, relevant in labels:
b_names = [names[i] for i in plain.search(query, k=k)]
r_names = [names[i] for i in reranked.search(query, k=k)]
bm25_p += _precision_at_k(b_names, relevant, k)
rr_p += _precision_at_k(r_names, relevant, k)
bm25_mrr += _reciprocal_rank([names[i] for i in plain.search(query, k=len(docs))], relevant)
rr_mrr += _reciprocal_rank(
[names[i] for i in reranked.search(query, k=len(docs))], relevant
)
flag = "" if b_names[:k] == r_names[:k] else " <-- reordered"
print(f" q: {query!r}")
print(f" want={relevant} bm25={b_names[:k]} rerank={r_names[:k]}{flag}")
n = len(labels)
print(f" -- precision@{k}: bm25={bm25_p / n:.3f} rerank={rr_p / n:.3f}")
print(f" -- MRR: bm25={bm25_mrr / n:.3f} rerank={rr_mrr / n:.3f}")
def _score_distribution(
docs: list[dict[str, str]],
labels: list[tuple[str, str]],
client: RerankClient,
) -> None:
"""Print rerank-score stats for labeled relevant vs irrelevant pairs.
A threshold default should sit above the irrelevant max / below the relevant
min where those separate; this prints both so you can eyeball the gap.
"""
texts = [_doc_text(d) for d in docs]
names = [d["name"] for d in docs]
relevant_scores: list[float] = []
irrelevant_scores: list[float] = []
for query, relevant in labels:
for hit in client.rerank(query, texts):
bucket = relevant_scores if names[hit.index] == relevant else irrelevant_scores
bucket.append(hit.score)
print("\n=== rerank score distribution (memory corpus) ===")
for label, scores in (("relevant", relevant_scores), ("irrelevant", irrelevant_scores)):
if not scores:
print(f" {label}: (no scores)")
continue
print(
f" {label:<10} n={len(scores):>3} "
f"min={min(scores):.4f} median={statistics.median(scores):.4f} "
f"max={max(scores):.4f}"
)
if relevant_scores and irrelevant_scores:
suggested = (min(relevant_scores) + max(irrelevant_scores)) / 2
sep = "separable" if min(relevant_scores) > max(irrelevant_scores) else "overlapping"
print(f" -> classes are {sep}; midpoint threshold candidate ~= {suggested:.4f}")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--rerank-url", required=True, help="full Cohere/Jina-compatible /rerank URL")
ap.add_argument("--rerank-model", default="", help="model name sent in the request body")
ap.add_argument("--threshold", type=float, default=0.0, help="relevance floor (0 disables)")
ap.add_argument("--k", type=int, default=3, help="top-k to score precision@k over")
args = ap.parse_args()
# Bearer token comes from the environment, not a flag, to keep it out of
# shell history and the process listing.
api_key = os.environ.get("TURNSTONE_RERANK_API_KEY", "")
client = resolve_rerank_client(args.rerank_url, model=args.rerank_model, api_key=api_key)
if client is None:
print("No rerank endpoint resolved (empty --rerank-url?). Nothing to do.")
return 1
print(
f"reranker: url={args.rerank_url} model={args.rerank_model or '(default)'} "
f"threshold={args.threshold}"
)
_bench_corpus("tool search", TOOL_DOCS, TOOL_LABELS, client, args.threshold, args.k)
_bench_corpus("memory composition", MEMORY_DOCS, MEMORY_LABELS, client, args.threshold, args.k)
_score_distribution(MEMORY_DOCS, MEMORY_LABELS, client)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -61,7 +61,10 @@ CSS_FILES = [
"turnstone/shared_static/base.css",
"turnstone/shared_static/ui-base.css",
"turnstone/shared_static/chat.css",
"turnstone/shared_static/conversation.css",
"turnstone/shared_static/cards.css",
"turnstone/shared_static/shell.css",
"turnstone/shared_static/interactive.css",
"turnstone/console/static/style.css",
"turnstone/console/static/coordinator/coordinator.css",
"turnstone/ui/static/style.css",
@@ -87,12 +90,18 @@ PAGE_STYLESHEETS: dict[str, list[str]] = {
"turnstone/console/static/style.css",
"turnstone/console/static/coordinator/coordinator.css",
],
# Standalone turnstone-server now serves the L-shell (step 6): same shared
# sheets the console loads, in <link> order, plus the (slimmed) ui/static
# style.css. No coordinator sheets (orchestration off).
"turnstone/ui/static/index.html": [
"turnstone/shared_static/base.css",
"turnstone/shared_static/ui-base.css",
"turnstone/shared_static/chat.css",
"turnstone/shared_static/conversation.css",
"turnstone/shared_static/cards.css",
"turnstone/ui/static/style.css",
"turnstone/shared_static/shell.css",
"turnstone/shared_static/interactive.css",
],
}
+503
View File
@@ -0,0 +1,503 @@
#!/usr/bin/env python3
"""Build the livepass harnesses — render real hatch dialogs/shelves headlessly.
The livepass is how converted modal surfaces get verified without booting a
server: a minimal page that symlinks the REAL stylesheets and scripts, embeds
the REAL markup (extracted fresh from the index files at build time), stubs
``window.authFetch`` with canned fixtures, and drives surfaces via ``?open=``
query params including click-driving submits so dead buttons can't hide
(the model-Save bug class).
Usage:
python3 scripts/livepass.py # build into /tmp/livepass/
python3 scripts/livepass.py --out DIR # build elsewhere
python3 scripts/livepass.py --serve 8950 # build + serve (Ctrl+C stops)
Then screenshot states (file:// blocks ES modules always serve over http;
the reduced-motion flag is REQUIRED, entrance animations race the capture):
google-chrome --headless --disable-gpu --hide-scrollbars \\
--force-prefers-reduced-motion --window-size=1440,900 \\
--virtual-time-budget=9000 --screenshot=out.png \\
"http://localhost:8950/ui/livepass.html?open=new-ws&theme=light"
UI harness (?open=): new-ws · new-ws-fork · edit-title · delete-ws ·
revoke-mcp · ws-delete · ws-delete-results (+ &theme=light, &busy=1)
Console harness (?open=): schedule-create · schedule-edit · model-create ·
model-edit · model-save (drives a Save click; document.title becomes
PUT-OK-<n> on success) · policy · confirm · token
Plus &tall=1 (90-row users panel the .admin-content scroll state; the
synthetic rows wrap to two lines, so judge overflow geometry, not row
cadence) · &scrolled=1 lands mid-list, &scrolled=bottom shows the 24px
scroll tail · &focuslast=1 focuses the last shelf-body control (the
displaced-dock regression probe: only .sh-body may scroll; head/foot stay
pinned). All combinable with ?open=. The console page wraps the fragment
in the REAL L-shell chain pane-pinned height, interior scroller so
scroll/dock geometry matches production; keep it that way. Body-level
dialogs (confirm/install/coord-delete) are injected as riders; a driven
?open= that ends with no open dialog stamps OPEN-FAILED-<state> into the
title instead of passing silently.
Governance surfaces (roles/HR/OGP/memory/skill) need fixtures that are not
canned yet add a fixture + driver branch below when you need one.
Rebuild after ANY markup change: the dialog blocks are embedded at build
time. Assets are symlinked, so CSS/JS edits are live on refresh.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
UI_INDEX = ROOT / "turnstone/ui/static/index.html"
CONSOLE_INDEX = ROOT / "turnstone/console/static/index.html"
def extract_dialogs(index: Path, only_id: str | None = None) -> list[str]:
"""Every <dialog class="hatch ..."> block, verbatim from the tree."""
html = index.read_text(encoding="utf-8")
blocks = []
for m in re.finditer(r"[ \t]*<dialog\s[^>]*class=\"[^\"]*\bhatch\b[^\"]*\"", html):
end = html.index("</dialog>", m.start()) + len("</dialog>")
block = html[m.start() : end]
if only_id and f'id="{only_id}"' not in block:
continue
blocks.append(block)
if not blocks:
raise SystemExit(f"no dialog.hatch blocks found in {index}")
return blocks
def extract_admin_fragment() -> str:
"""The console admin pane — the hatch-host all shelves live inside."""
html = CONSOLE_INDEX.read_text(encoding="utf-8")
start = html.index('<div id="admin-layout"')
end = html.index("<!-- /admin-layout -->") + len("<!-- /admin-layout -->")
return html[start:end]
def inject(template: str, marker: str, payload: str) -> str:
begin = template.index(f"<!-- {marker}:BEGIN -->") + len(f"<!-- {marker}:BEGIN -->")
end = template.index(f"<!-- {marker}:END -->")
return template[:begin] + "\n" + payload + "\n" + template[end:]
def symlink(link: Path, target: Path) -> None:
if link.is_symlink() or link.exists():
link.unlink()
link.symlink_to(target)
# --------------------------------------------------------------------------
# UI harness — the standalone app's dialog tier. Drives the REAL cards.js
# controller for the batch surfaces so the production code path renders.
# --------------------------------------------------------------------------
UI_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ui livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="shared/chat.css" />
<link rel="stylesheet" href="shared/conversation.css" />
<link rel="stylesheet" href="shared/cards.css" />
<link rel="stylesheet" href="static/style.css" />
<link rel="stylesheet" href="shared/shell.css" />
<link rel="stylesheet" href="shared/interactive.css" />
<link rel="stylesheet" href="shared/hatch.css" />
</head>
<body>
<!-- DIALOGS:BEGIN -->
<!-- DIALOGS:END -->
<div id="toast" role="status" aria-live="polite"></div>
<script>
window.authFetch = function (url) {
// One canned failure so the results view shows the mixed state.
var fail = url && url.indexOf("c3d4e5f6a1b2") !== -1;
return Promise.resolve({
ok: !fail,
status: fail ? 409 : 200,
headers: { get: function () { return "application/json"; } },
json: function () { return Promise.resolve({}); },
text: function () {
return Promise.resolve(
fail ? '{"error": "workstream is still running"}' : "",
);
},
});
};
window.showToast = function (msg) { console.log("toast:", msg); };
</script>
<script type="module">
import { openDialog, setBusy } from "./shared/hatch.js";
const q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
const open = q.get("open") || "";
function fill(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
if (open === "new-ws" || open === "new-ws-fork") {
const dlg = document.getElementById("new-ws-dialog");
const canned = {
"new-ws-model": ["sonnet-4-6", "gpt-5-2", "qwen3-32b"],
"new-ws-judge-model": ["sonnet-4-6", "qwen3-32b"],
"new-ws-skill": ["code-review (default)", "deep-research"],
};
for (const id in canned) {
const s = document.getElementById(id);
for (const n of canned[id]) {
const o = document.createElement("option");
o.value = n;
o.textContent = n;
s.appendChild(o);
}
}
if (open === "new-ws-fork") {
fill("new-ws-title", "Fork workstream");
fill("new-ws-tag", "WS-FORK");
document.getElementById("new-ws-submit").textContent = "Fork";
const skillLabel = document.querySelector('label[for="new-ws-skill"]');
if (skillLabel) skillLabel.hidden = true;
document.getElementById("new-ws-skill").hidden = true;
document.getElementById("new-ws-attach-row").hidden = true;
}
openDialog(dlg);
} else if (open === "edit-title") {
document.getElementById("edit-title-input").value =
"lshell renovation pass 3";
openDialog(document.getElementById("edit-title-dialog"));
} else if (open === "delete-ws") {
fill(
"delete-ws-message",
'Delete "lshell renovation pass 3"? This cannot be undone.',
);
openDialog(document.getElementById("delete-ws-dialog"));
} else if (open === "revoke-mcp") {
fill(
"revoke-mcp-message",
"Revoke the connection to github? Tools that need this server will require re-consent.",
);
openDialog(document.getElementById("revoke-mcp-dialog"));
} else if (open === "ws-delete" || open === "ws-delete-results") {
// Drive the REAL shared controller so the dialog renders through
// the production code path (cards.js confirmSelection/confirm).
const mod = await import("./shared/cards.js");
const c = mod.createSavedCardsController({
idPrefix: "ws-delete",
buttonId: "ws-delete-btn",
noun: "workstream",
activateLabel: (s) => "Resume: " + (s.title || s.ws_id),
render: () => {},
buildDeleteRequest: (wsId) => ({
url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
options: { method: "POST" },
}),
});
c.setItems([
{ ws_id: "a1b2c3d4e5f6", title: "lshell renovation pass 3" },
{ ws_id: "b2c3d4e5f6a1", title: "canonical trajectory spike" },
{
ws_id: "c3d4e5f6a1b2",
title:
"a very long workstream title that should wrap " +
"rather than punch out of the dialog box entirely",
},
]);
c.toggleAll();
c.confirmSelection();
if (open === "ws-delete-results") c.confirm();
}
if (q.get("busy")) {
const d = document.querySelector("dialog[open]");
if (d) setBusy(d, true);
}
</script>
</body>
</html>
"""
# --------------------------------------------------------------------------
# Console harness — the admin pane fragment hosts the shelves (token-created
# included); dialog-tier markup outside the fragment (confirm/install/
# coord-delete) is injected via the RIDERS marker in build().
# model-save click-drives the submit: document.title flips to PUT-OK-<n>.
# --------------------------------------------------------------------------
CONSOLE_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>console livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="console-static/style.css" />
<link rel="stylesheet" href="shared/shell.css" />
<link rel="stylesheet" href="shared/hatch.css" />
</head>
<body>
<!-- The REAL L-shell chain (shell.js buildShell + pane.js DOM, verbatim
class names) so the harness inherits production scroll geometry:
.pane-body > #view-admin > .admin-layout height-pin the hatch-host
and .admin-content is the pane's interior scroller. Never replace
this with bespoke height overrides the clipped-pane / displaced-
shelf regressions were invisible to the harness precisely because
it used to pin #admin-layout with its own CSS. -->
<div class="app">
<aside class="rail" id="shell-rail">
<div class="rail-brand">
<button class="brand-home" type="button">
<div class="brand-mark"></div>
<span class="brand-name">turnstone</span>
<span class="brand-sub">console</span>
</button>
</div>
</aside>
<main class="content">
<div class="tabbar"></div>
<div class="panes">
<section class="pane">
<!-- no .pane-head: PaneManager._mount builds section.pane >
div.pane-body only -->
<div class="pane-body">
<div id="view-admin">
<!-- FRAGMENT:BEGIN -->
<!-- FRAGMENT:END -->
</div>
</div>
</section>
</div>
</main>
</div>
<!-- Body-level dialog tier (confirm / install / coord-delete): their
markup sits OUTSIDE #admin-layout in index.html, so the fragment
extraction misses them build() injects every hatch dialog the
fragment does not already contain. -->
<!-- RIDERS:BEGIN -->
<!-- RIDERS:END -->
<div id="toast" role="status" aria-live="polite"></div>
<script>
(function () {
function reply(data) {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: function () { return "application/json"; } },
json: function () { return Promise.resolve(data); },
text: function () { return Promise.resolve(JSON.stringify(data)); },
});
}
var SCHED = {
task_id: "t1", name: "nightly-digest", description: "Morning digest",
schedule_type: "cron", cron_expr: "0 6 * * 1,3,5", at_time: "",
target_mode: "auto", model: "fable-5", skill: "daily-digest",
initial_message: "Summarize overnight cluster activity.",
auto_approve: false, enabled: true,
notify_targets: [{ channel_type: "discord", channel_id: "8675309" }],
next_run: "2026-06-10T06:00:00",
};
var MODEL = {
definition_id: "def1", alias: "fable-5", model: "claude-fable-5",
provider: "anthropic", base_url: "", context_window: 200000,
capabilities: JSON.stringify({ supports_vision: true }),
enabled: true, temperature: null, max_tokens: null,
reasoning_effort: null, surface_persisted_reasoning: true,
replay_reasoning_to_model: false,
};
window.__putCount = 0;
window.authFetch = function (url, opts) {
var method = (opts && opts.method) || "GET";
if (method === "PUT" && url.indexOf("/model-definitions/def1") >= 0) {
window.__putCount++;
document.title = "PUT-OK-" + window.__putCount;
return reply({ ok: true });
}
if (url.indexOf("/schedules/preview") >= 0)
return reply({
valid: true, error: "",
next: [
"2026-06-10T06:00:00+00:00",
"2026-06-12T06:00:00+00:00",
"2026-06-15T06:00:00+00:00",
],
});
if (url.indexOf("/schedules/t1") >= 0) return reply(SCHED);
if (url.indexOf("/schedules") >= 0) return reply({ schedules: [SCHED] });
if (url.indexOf("/model-capabilities/known") >= 0)
return reply({ models: ["claude-fable-5", "claude-opus-4-8"] });
if (url.indexOf("/model-capabilities?") >= 0)
return reply({
known: true,
capabilities: {
context_window: 200000, supports_tools: true,
supports_streaming: true, supports_vision: true,
supports_web_search: true, supports_temperature: true,
supports_effort: true,
},
});
if (url.indexOf("/model-definitions/def1") >= 0) return reply(MODEL);
if (url.indexOf("/model-definitions") >= 0) return reply({ models: [] });
if (url.indexOf("/api/models") >= 0)
return reply({ models: [
{ alias: "fable-5", model: "claude-fable-5" },
{ alias: "gpt-5.2", model: "gpt-5.2" },
] });
if (url.indexOf("/skills") >= 0)
return reply({ skills: [{ name: "daily-digest" }, { name: "ops-runbook" }] });
if (url.indexOf("/policies") >= 0)
return reply({ policies: [
{ policy_id: "p1", name: "deny-rm", tool_pattern: "bash*rm*",
action: "deny", priority: 900, enabled: true },
{ policy_id: "p2", name: "default-ask", tool_pattern: "*",
action: "ask", priority: 0, enabled: true },
] });
return reply({});
};
window.showToast = function (m) {
console.log("toast:", m);
var t = document.getElementById("toast");
t.textContent = m;
t.classList.add("show");
};
})();
</script>
<script type="module" src="shared/utils.js"></script>
<script type="module" src="shared/hatch.js"></script>
<script src="console-static/admin.js"></script>
<script src="console-static/governance.js"></script>
<script>
window.addEventListener("load", function () {
var q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
var open = q.get("open") || "";
// ?tall=1 the scroll state: one panel visible with enough rows to
// overflow the pane, so a screenshot shows .admin-content scrolling
// (and a shelf staying docked above it). Mirrors switchAdminTab's
// one-panel-visible invariant without booting the tab loaders.
if (q.get("tall")) {
var panels = document.querySelectorAll(".admin-panel");
for (var i = 0; i < panels.length; i++)
panels[i].style.display =
panels[i].id === "admin-users" ? "" : "none";
// No fallback: a fragment rename must fail loudly, not misplace rows.
var rowHost = document.querySelector("#admin-users [role=list]");
rowHost.textContent = ""; // drop the static "Loading users…" stub
for (var r = 0; r < 90; r++) {
var row = document.createElement("div");
row.className = "admin-row"; // real row chrome geometry tracks production
row.textContent =
"user-" + String(r).padStart(3, "0") + " \\u00b7 synthetic row";
rowHost.appendChild(row);
}
var content = document.getElementById("admin-content");
if (content && q.get("scrolled"))
content.scrollTop =
q.get("scrolled") === "bottom"
? content.scrollHeight // the 24px scroll-tail state
: content.scrollHeight / 2; // land mid-list
}
setTimeout(function () {
if (open === "schedule-create") showCreateScheduleModal();
else if (open === "schedule-edit") showEditScheduleModal("t1");
else if (open === "model-create") showCreateModelModal();
else if (open === "model-edit" || open === "model-save")
showEditModelModal("def1");
else if (open === "policy") {
window._govPolicies && _govPolicies.length === 0 &&
loadGovPolicies && loadGovPolicies();
showCreatePolicyModal();
} else if (open === "confirm")
showConfirmModal(
"Delete schedule",
"Delete nightly-digest? Its run history is removed with it. This cannot be undone.",
"Delete",
function () {},
);
else if (open === "token")
showTokenCreatedModal(
"tsk_9f2e41c7a8b35d60e1f4a2b89c7d3e5f6a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d",
);
if (open === "model-save")
setTimeout(function () {
document.getElementById("model-create-submit").click();
}, 900);
if (q.get("busy"))
setTimeout(function () {
var d = document.querySelector("dialog[open]");
if (d) window.TurnstoneHatch.setBusy(d, true);
}, 400);
// A driven state that ends with nothing open must fail LOUDLY in
// the screenshot pipeline, not render a quietly dialog-less page.
setTimeout(function () {
var top = document.querySelector("dialog[open]");
if (open && !top) document.title = "OPEN-FAILED-" + open;
// &focuslast=1 the displaced-dock regression probe: focus the
// last form control in the shelf BODY (the visually-hidden
// toggle/radio inputs live there). Only .sh-body may scroll;
// the head/foot strips must stay pinned in the screenshot.
if (top && q.get("focuslast")) {
var els = top.querySelectorAll(
".sh-body input, .sh-body select, .sh-body textarea",
);
if (els.length) els[els.length - 1].focus();
}
}, 600);
}, 150);
});
</script>
</body>
</html>
"""
def build(out: Path) -> None:
ui = out / "ui"
con = out / "console"
ui.mkdir(parents=True, exist_ok=True)
con.mkdir(parents=True, exist_ok=True)
symlink(ui / "shared", ROOT / "turnstone/shared_static")
symlink(ui / "static", ROOT / "turnstone/ui/static")
blocks = extract_dialogs(UI_INDEX)
# the coordinator batch dialog shares the cards.js builder — ride along
blocks += extract_dialogs(CONSOLE_INDEX, only_id="coord-delete-dialog")
(ui / "livepass.html").write_text(
inject(UI_TEMPLATE, "DIALOGS", "\n".join(blocks)), encoding="utf-8"
)
print(f"{ui}/livepass.html — {len(blocks)} dialogs")
symlink(con / "shared", ROOT / "turnstone/shared_static")
symlink(con / "console-static", ROOT / "turnstone/console/static")
frag = extract_admin_fragment()
# Dialog-tier markup living OUTSIDE #admin-layout (confirm, install,
# coord-delete) would otherwise be silently absent — and ?open=confirm
# would screenshot a dialog-less page while the gate stayed green.
riders = [b for b in extract_dialogs(CONSOLE_INDEX) if b not in frag]
page = inject(CONSOLE_TEMPLATE, "FRAGMENT", frag)
page = inject(page, "RIDERS", "\n".join(riders))
(con / "livepass.html").write_text(page, encoding="utf-8")
print(f"{con}/livepass.html — admin fragment + {len(riders)} rider dialogs")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--out", type=Path, default=Path("/tmp/livepass"))
ap.add_argument("--serve", type=int, metavar="PORT")
args = ap.parse_args()
build(args.out)
if args.serve:
import functools
import http.server
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(args.out))
print(f"serving {args.out} on http://localhost:{args.serve}/ — Ctrl+C stops")
http.server.ThreadingHTTPServer(("127.0.0.1", args.serve), handler).serve_forever()
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+135 -132
View File
@@ -7,7 +7,7 @@
"": {
"name": "@turnstone/sdk",
"version": "0.4.0",
"license": "BUSL-1.1",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^6.0.0",
"vitest": "^4.1"
@@ -74,9 +74,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -84,9 +84,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"cpu": [
"arm64"
],
@@ -101,9 +101,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"cpu": [
"arm64"
],
@@ -118,9 +118,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"cpu": [
"x64"
],
@@ -135,9 +135,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"cpu": [
"x64"
],
@@ -152,9 +152,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"cpu": [
"arm"
],
@@ -169,9 +169,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"cpu": [
"arm64"
],
@@ -189,9 +189,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"cpu": [
"arm64"
],
@@ -209,9 +209,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"cpu": [
"ppc64"
],
@@ -229,9 +229,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"cpu": [
"s390x"
],
@@ -249,9 +249,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"cpu": [
"x64"
],
@@ -269,9 +269,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"cpu": [
"x64"
],
@@ -289,9 +289,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"cpu": [
"arm64"
],
@@ -306,9 +306,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"cpu": [
"wasm32"
],
@@ -325,9 +325,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"cpu": [
"arm64"
],
@@ -342,9 +342,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"cpu": [
"x64"
],
@@ -409,16 +409,16 @@
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -427,13 +427,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.6",
"@vitest/spy": "4.1.8",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -454,9 +454,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -467,13 +467,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.6",
"@vitest/utils": "4.1.8",
"pathe": "^2.0.3"
},
"funding": {
@@ -481,14 +481,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.6",
"@vitest/utils": "4.1.6",
"@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.8",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -497,9 +497,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -507,13 +507,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.6",
"@vitest/pretty-format": "4.1.8",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -921,15 +921,18 @@
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/pathe": {
"version": "2.0.3",
@@ -959,9 +962,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -979,7 +982,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -988,13 +991,13 @@
}
},
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.130.0",
"@oxc-project/types": "=0.133.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -1004,21 +1007,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
"@rolldown/binding-android-arm64": "1.0.3",
"@rolldown/binding-darwin-arm64": "1.0.3",
"@rolldown/binding-darwin-x64": "1.0.3",
"@rolldown/binding-freebsd-x64": "1.0.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
"@rolldown/binding-linux-arm64-musl": "1.0.3",
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
"@rolldown/binding-linux-x64-gnu": "1.0.3",
"@rolldown/binding-linux-x64-musl": "1.0.3",
"@rolldown/binding-openharmony-arm64": "1.0.3",
"@rolldown/binding-wasm32-wasi": "1.0.3",
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/siginfo": {
@@ -1060,9 +1063,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1070,9 +1073,9 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1119,17 +1122,17 @@
}
},
"node_modules/vite": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
"postcss": "^8.5.15",
"rolldown": "1.0.3",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
@@ -1197,19 +1200,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.6",
"@vitest/mocker": "4.1.6",
"@vitest/pretty-format": "4.1.6",
"@vitest/runner": "4.1.6",
"@vitest/snapshot": "4.1.6",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -1237,12 +1240,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.6",
"@vitest/browser-preview": "4.1.6",
"@vitest/browser-webdriverio": "4.1.6",
"@vitest/coverage-istanbul": "4.1.6",
"@vitest/coverage-v8": "4.1.6",
"@vitest/ui": "4.1.6",
"@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.1.8",
"@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.8",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+1 -1
View File
@@ -30,7 +30,7 @@
"sdk",
"client"
],
"license": "BUSL-1.1",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^6.0.0",
"vitest": "^4.1"
+14 -29
View File
@@ -14,17 +14,22 @@ export interface ConnectedEvent {
export interface HistoryEvent {
type: "history";
/**
* Per-message dicts the frontend consumes directly. Common optional keys:
* - `role`: "user" | "assistant" | "tool"
* - `content`: string or list (image/document parts)
* Per-message dicts the frontend consumes directly. Notable optional keys:
* - `role`: "user" | "assistant" | "tool" | "system"
* - `content`: string for text turns, list for image/document parts
* - `tool_calls`: assistant turns list of `{id, name, arguments, verdict?, output_assessment?}`
* - `tool_call_id`: tool turns id of the originating call
* - `reminders`: metacognitive nudge bubbles (user/tool channels)
* - `advisories`: extracted `UserInterjection` payloads on tool turns
* - `reasoning`: concatenated reasoning text for assistant turns whose
* `provider_data` carried reasoning-bearing blocks (Anthropic
* `thinking`, OpenAI Responses `reasoning`, or synthetic
* `reasoning_text` from path-3 servers). Present only when the
* - `source`: the operator-context kind on a `system` turn (`output_guard` /
* `user_interjection` / `tool_error` / ...), or `system_nudge` on a
* wake-driven empty user turn
* - `meta`: structured per-kind fields on an operator-context `system` turn
* (e.g. `watch_triggered`'s `{watch_name, command, poll_count, max_polls,
* is_final}`) so the renderer can rebuild per-kind UI (the watch-result
* card); absent for kinds with no structured data
* - `attachments`: per-attachment metadata `{kind, filename, mime_type}`
* - `reasoning`: concatenated reasoning text for assistant turns that
* round-tripped a thinking-block lane (Anthropic-with-thinking today;
* OpenAI Responses + Gemini in later phases). Present only when the
* active model's `surface_persisted_reasoning` flag is true.
*/
messages: Array<Record<string, unknown>>;
@@ -109,16 +114,6 @@ export interface StatusEvent {
turn_count?: number;
}
export interface PlanReviewEvent {
type: "plan_review";
content: string;
}
export interface PlanResolvedEvent {
type: "plan_resolved";
feedback: string;
}
export interface InfoEvent {
type: "info";
message: string;
@@ -192,8 +187,6 @@ export type ServerEvent =
| ToolResultEvent
| ToolOutputChunkEvent
| StatusEvent
| PlanReviewEvent
| PlanResolvedEvent
| InfoEvent
| ErrorEvent
| BusyErrorEvent
@@ -314,14 +307,6 @@ export function isApprovalResolvedEvent(
return e.type === "approval_resolved";
}
export function isPlanReviewEvent(e: ServerEvent): e is PlanReviewEvent {
return e.type === "plan_review";
}
export function isPlanResolvedEvent(e: ServerEvent): e is PlanResolvedEvent {
return e.type === "plan_resolved";
}
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
return e.type === "cancelled";
}
-3
View File
@@ -42,7 +42,6 @@ export type {
ToolResultEvent,
ToolOutputChunkEvent,
StatusEvent,
PlanReviewEvent,
InfoEvent,
ErrorEvent,
BusyErrorEvent,
@@ -71,7 +70,6 @@ export {
isWsStateEvent,
isApproveRequestEvent,
isApprovalResolvedEvent,
isPlanReviewEvent,
isCancelledEvent,
} from "./events.js";
@@ -80,7 +78,6 @@ export type {
SendRequest,
SendResponse,
ApproveRequest,
PlanFeedbackRequest,
CommandRequest,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
+18 -9
View File
@@ -180,15 +180,6 @@ export class TurnstoneServer extends BaseClient {
);
}
async planFeedback(opts: {
wsId: string;
feedback?: string;
}): Promise<StatusResponse> {
return this.request("POST", "/v1/api/plan", {
json: { ws_id: opts.wsId, feedback: opts.feedback ?? "" },
});
}
async command(opts: {
wsId: string;
command: string;
@@ -211,6 +202,24 @@ export class TurnstoneServer extends BaseClient {
);
}
/** Drop the last `turns` conversation turns. Emits a `clear_ui` event. */
async rewind(wsId: string, turns: number): Promise<StatusResponse> {
return this.request(
"POST",
`/v1/api/workstreams/${encodeURIComponent(wsId)}/rewind`,
{ json: { turns } },
);
}
/** Drop the last response and re-send the last user message. */
async retry(wsId: string): Promise<StatusResponse> {
return this.request(
"POST",
`/v1/api/workstreams/${encodeURIComponent(wsId)}/retry`,
{ json: {} },
);
}
// -- Streaming ------------------------------------------------------------
async *streamEvents(wsId: string): AsyncIterableIterator<ServerEvent> {
+10 -5
View File
@@ -120,11 +120,6 @@ export interface ApproveRequest {
ws_id: string;
}
export interface PlanFeedbackRequest {
feedback: string;
ws_id: string;
}
export interface CommandRequest {
command: string;
ws_id: string;
@@ -245,6 +240,16 @@ export interface SavedWorkstreamInfo {
created: string;
updated: string;
message_count: number;
// Enriched fields — all optional (defaulted server-side, so an older
// server may omit them).
state?: string;
kind?: string;
node_id?: string;
model_alias?: string | null;
launch_skill?: string | null;
child_count?: number;
context_tokens?: number;
context_ratio?: number;
}
export interface ListSavedWorkstreamsResponse {
-12
View File
@@ -7,8 +7,6 @@ import {
isWsStateEvent,
isApproveRequestEvent,
isApprovalResolvedEvent,
isPlanReviewEvent,
isPlanResolvedEvent,
isReasoningEvent,
} from "../src/events.js";
import type { ServerEvent } from "../src/events.js";
@@ -72,14 +70,4 @@ describe("event type guards", () => {
};
expect(isApprovalResolvedEvent(e)).toBe(true);
});
it("isPlanReviewEvent", () => {
const e: ServerEvent = { type: "plan_review", content: "## Plan" };
expect(isPlanReviewEvent(e)).toBe(true);
});
it("isPlanResolvedEvent", () => {
const e: ServerEvent = { type: "plan_resolved", feedback: "approved" };
expect(isPlanResolvedEvent(e)).toBe(true);
});
});
-80
View File
@@ -290,34 +290,6 @@
"Spin up a README.md with a project title and description in it"
]
},
{
"id": "plan-when-asked",
"description": "Call the plan tool when the user asks to plan",
"user_prompt": "Plan how to add user authentication to this app.",
"setup": {
"files": {
"app.py": "from flask import Flask, jsonify\n\napp = Flask(__name__)\n\n@app.route('/users')\ndef list_users():\n return jsonify([{'id': 1, 'name': 'Alice'}])\n\nif __name__ == '__main__':\n app.run(port=8000)\n"
}
},
"expected_actions": [
{
"tool": "plan_agent"
}
],
"match_mode": "subset",
"user_prompts": [
"Plan how to add user authentication to this app.",
"Make a plan for adding pagination to the API endpoints.",
"Plan out how to add error handling to this application.",
"I need a plan for adding logging to this codebase.",
"Plan the approach for adding unit tests to this app.",
"How would you approach adding user authentication to this app? Lay out a plan.",
"I'd like you to outline a strategy for implementing user authentication in this application.",
"Could you come up with a plan for integrating user authentication into this app?",
"Think through the steps needed to add user auth to this app and present a plan.",
"Draft a plan for incorporating user authentication functionality into this application."
]
},
{
"id": "edit-not-rewrite",
"description": "Use edit_file for small changes, not write_file to rewrite the entire file",
@@ -412,58 +384,6 @@
"Hit https://example.com and let me know what's there in summary form"
]
},
{
"id": "man-page-lookup",
"description": "Use man tool to look up command documentation",
"user_prompt": "Look up the man page for tar and tell me what the --xattrs flag does",
"expected_actions": [
{
"tool": "man",
"args_pattern": {
"page": "tar"
}
}
],
"match_mode": "subset",
"user_prompts": [
"Look up the man page for tar and tell me what the --xattrs flag does",
"What does the --xattrs flag do in tar? Check the man page for me.",
"Could you pull up the man page for tar and explain the --xattrs option?",
"I need to know what --xattrs does in tar \u2014 can you check the man page?",
"Check tar's man page and let me know the purpose of the --xattrs flag.",
"Please consult the tar man page and describe what the --xattrs flag is for.",
"Hey, look at the tar man page real quick \u2014 what's --xattrs do?",
"I'd like you to read the tar man page and summarize the --xattrs option for me.",
"Would you mind checking the man page for tar to find out what --xattrs means?",
"Look into the tar manual and explain the --xattrs flag to me."
]
},
{
"id": "math-calculation",
"description": "Use the math tool for precise calculations, not bash or mental math",
"user_prompt": "What is 2^64 - 1? Use the math tool to calculate it precisely.",
"expected_actions": [
{
"tool": "math",
"args_pattern": {
"code": "2.*64"
}
}
],
"match_mode": "subset",
"user_prompts": [
"What is 2^64 - 1? Use the math tool to calculate it precisely.",
"Calculate 2^64 - 1 for me using the math tool, please.",
"I need the exact value of 2^64 - 1. Please use the math tool.",
"Could you use the math tool to compute 2^64 minus 1 precisely?",
"Use the math tool to tell me what 2^64 - 1 equals.",
"I'm curious: what's 2^64 - 1? Compute it with the math tool.",
"Please precisely determine 2^64 - 1 via the math tool.",
"Mind using the math tool to figure out 2^64 - 1 exactly?",
"I'd like to know the precise result of 2^64 - 1 \u2014 use the math tool for this.",
"Leverage the math tool to give me an exact answer for 2^64 - 1."
]
},
{
"id": "web-search-query",
"description": "Use web_search for general knowledge lookups, not web_fetch",
+2 -3
View File
@@ -32,8 +32,8 @@ def make_replay_mocks(
don't have to reach into the nested mock; when ``None``
(default), the status replay branch stays inert.
**ui_overrides: Additional attributes set directly on the ``ui``
mock (e.g. ``_pending_approval``, ``_pending_plan_review``,
``_llm_verdicts``, ``_ws_turn_tool_calls``, ``_ws_messages``).
mock (e.g. ``_pending_approval``, ``_llm_verdicts``,
``_ws_turn_tool_calls``, ``_ws_messages``).
"""
session = MagicMock()
session.model = "gpt-5"
@@ -45,7 +45,6 @@ def make_replay_mocks(
ui = MagicMock()
ui.auto_approve = False
ui._pending_approval = None
ui._pending_plan_review = None
ui._llm_verdicts = {}
ui._ws_lock = threading.Lock()
ui._ws_turn_tool_calls = 0
@@ -0,0 +1,78 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris and London?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
},
{
"id": "call_2",
"input": {
"city": "London"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
},
{
"content": "Tool execution was cancelled.",
"is_error": true,
"tool_use_id": "call_2",
"type": "tool_result"
},
{
"text": "Actually, never mind London.",
"type": "text"
}
],
"role": "user"
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,33 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": [
{
"text": "What's in this image?",
"type": "text"
},
{
"source": {
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"media_type": "image/png",
"type": "base64"
},
"type": "image"
}
],
"role": "user"
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
}
}
@@ -0,0 +1,70 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": [
{
"signature": "sig-abc",
"thinking": "The user wants weather.",
"type": "thinking"
},
{
"text": "Let me check.",
"type": "text"
},
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "Tool execution was cancelled.",
"is_error": true,
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,69 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": [
{
"signature": "sig-abc",
"thinking": "The user wants weather.",
"type": "thinking"
},
{
"text": "Let me check.",
"type": "text"
},
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,63 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Run the deploy.",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {},
"name": "deploy",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "deployed",
"tool_use_id": "call_1",
"type": "tool_result"
},
{
"text": "Great, what's next?",
"type": "text"
}
],
"role": "user"
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"system": "Output-guard: deploy output looked clean.",
"temperature": 1.0,
"thinking": {
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,33 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Hi there.",
"role": "user"
},
{
"content": [
{
"text": "Hello! How can I help?",
"type": "text"
}
],
"role": "assistant"
},
{
"content": "What's the weather in Paris?",
"role": "user"
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
}
}
@@ -0,0 +1,69 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
},
{
"content": [
{
"text": "It's 18C and clear in Paris.",
"type": "text"
}
],
"role": "assistant"
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,61 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "Tool execution was cancelled.",
"is_error": true,
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,78 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris and London?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
},
{
"id": "call_2",
"input": {
"city": "London"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
},
{
"content": "Tool execution was cancelled.",
"is_error": true,
"tool_use_id": "call_2",
"type": "tool_result"
},
{
"text": "Actually, never mind London.",
"type": "text"
}
],
"role": "user"
}
],
"model": "claude-opus-4-8",
"output_config": {
"effort": "medium"
},
"thinking": {
"display": "summarized",
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,33 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": [
{
"text": "What's in this image?",
"type": "text"
},
{
"source": {
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"media_type": "image/png",
"type": "base64"
},
"type": "image"
}
],
"role": "user"
}
],
"model": "claude-opus-4-8",
"output_config": {
"effort": "medium"
},
"thinking": {
"display": "summarized",
"type": "adaptive"
}
}
@@ -0,0 +1,70 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": [
{
"signature": "sig-abc",
"thinking": "The user wants weather.",
"type": "thinking"
},
{
"text": "Let me check.",
"type": "text"
},
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "Tool execution was cancelled.",
"is_error": true,
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "claude-opus-4-8",
"output_config": {
"effort": "medium"
},
"thinking": {
"display": "summarized",
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,69 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": [
{
"signature": "sig-abc",
"thinking": "The user wants weather.",
"type": "thinking"
},
{
"text": "Let me check.",
"type": "text"
},
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "claude-opus-4-8",
"output_config": {
"effort": "medium"
},
"thinking": {
"display": "summarized",
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,66 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Run the deploy.",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {},
"name": "deploy",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "deployed",
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
},
{
"content": "Output-guard: deploy output looked clean.",
"role": "system"
},
{
"content": "Great, what's next?",
"role": "user"
}
],
"model": "claude-opus-4-8",
"output_config": {
"effort": "medium"
},
"thinking": {
"display": "summarized",
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,33 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Hi there.",
"role": "user"
},
{
"content": [
{
"text": "Hello! How can I help?",
"type": "text"
}
],
"role": "assistant"
},
{
"content": "What's the weather in Paris?",
"role": "user"
}
],
"model": "claude-opus-4-8",
"output_config": {
"effort": "medium"
},
"thinking": {
"display": "summarized",
"type": "adaptive"
}
}
@@ -0,0 +1,69 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
},
{
"content": [
{
"text": "It's 18C and clear in Paris.",
"type": "text"
}
],
"role": "assistant"
}
],
"model": "claude-opus-4-8",
"output_config": {
"effort": "medium"
},
"thinking": {
"display": "summarized",
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,61 @@
{
"cache_control": {
"type": "ephemeral"
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "Tool execution was cancelled.",
"is_error": true,
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "claude-opus-4-8",
"output_config": {
"effort": "medium"
},
"thinking": {
"display": "summarized",
"type": "adaptive"
},
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,71 @@
{
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris and London?",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
},
{
"function": {
"arguments": "{\"city\": \"London\"}",
"name": "get_weather"
},
"id": "call_2",
"type": "function"
}
]
},
{
"content": "18C, clear.",
"role": "tool",
"tool_call_id": "call_1"
},
{
"content": "Tool execution was cancelled.",
"role": "tool",
"tool_call_id": "call_2"
},
{
"content": "Actually, never mind London.",
"role": "user"
}
],
"model": "gemini-2.5-pro",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,26 @@
{
"max_tokens": 4096,
"messages": [
{
"content": [
{
"text": "What's in this image?",
"type": "text"
},
{
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
},
"type": "image_url"
}
],
"role": "user"
}
],
"model": "gemini-2.5-pro",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5
}
@@ -0,0 +1,54 @@
{
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": "Let me check.",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "Tool execution was cancelled.",
"role": "tool",
"tool_call_id": "call_1"
}
],
"model": "gemini-2.5-pro",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,54 @@
{
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": "Let me check.",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "18C, clear.",
"role": "tool",
"tool_call_id": "call_1"
}
],
"model": "gemini-2.5-pro",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,62 @@
{
"max_tokens": 4096,
"messages": [
{
"content": "Run the deploy.",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{}",
"name": "deploy"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "deployed",
"role": "tool",
"tool_call_id": "call_1"
},
{
"content": "Output-guard: deploy output looked clean.",
"role": "system"
},
{
"content": "Great, what's next?",
"role": "user"
}
],
"model": "gemini-2.5-pro",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,23 @@
{
"max_tokens": 4096,
"messages": [
{
"content": "Hi there.",
"role": "user"
},
{
"content": "Hello! How can I help?",
"role": "assistant"
},
{
"content": "What's the weather in Paris?",
"role": "user"
}
],
"model": "gemini-2.5-pro",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5
}
@@ -0,0 +1,58 @@
{
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "18C, clear.",
"role": "tool",
"tool_call_id": "call_1"
},
{
"content": "It's 18C and clear in Paris.",
"role": "assistant"
}
],
"model": "gemini-2.5-pro",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,54 @@
{
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "Tool execution was cancelled.",
"role": "tool",
"tool_call_id": "call_1"
}
],
"model": "gemini-2.5-pro",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,71 @@
{
"max_completion_tokens": 4096,
"messages": [
{
"content": "Weather in Paris and London?",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
},
{
"function": {
"arguments": "{\"city\": \"London\"}",
"name": "get_weather"
},
"id": "call_2",
"type": "function"
}
]
},
{
"content": "18C, clear.",
"role": "tool",
"tool_call_id": "call_1"
},
{
"content": "Tool execution was cancelled.",
"role": "tool",
"tool_call_id": "call_2"
},
{
"content": "Actually, never mind London.",
"role": "user"
}
],
"model": "gpt-4o-mini",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,26 @@
{
"max_completion_tokens": 4096,
"messages": [
{
"content": [
{
"text": "What's in this image?",
"type": "text"
},
{
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
},
"type": "image_url"
}
],
"role": "user"
}
],
"model": "gpt-4o-mini",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5
}
@@ -0,0 +1,54 @@
{
"max_completion_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": "Let me check.",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "Tool execution was cancelled.",
"role": "tool",
"tool_call_id": "call_1"
}
],
"model": "gpt-4o-mini",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,54 @@
{
"max_completion_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": "Let me check.",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "18C, clear.",
"role": "tool",
"tool_call_id": "call_1"
}
],
"model": "gpt-4o-mini",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,62 @@
{
"max_completion_tokens": 4096,
"messages": [
{
"content": "Run the deploy.",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{}",
"name": "deploy"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "deployed",
"role": "tool",
"tool_call_id": "call_1"
},
{
"content": "Output-guard: deploy output looked clean.",
"role": "system"
},
{
"content": "Great, what's next?",
"role": "user"
}
],
"model": "gpt-4o-mini",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,23 @@
{
"max_completion_tokens": 4096,
"messages": [
{
"content": "Hi there.",
"role": "user"
},
{
"content": "Hello! How can I help?",
"role": "assistant"
},
{
"content": "What's the weather in Paris?",
"role": "user"
}
],
"model": "gpt-4o-mini",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5
}
@@ -0,0 +1,58 @@
{
"max_completion_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "18C, clear.",
"role": "tool",
"tool_call_id": "call_1"
},
{
"content": "It's 18C and clear in Paris.",
"role": "assistant"
}
],
"model": "gpt-4o-mini",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,54 @@
{
"max_completion_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "Tool execution was cancelled.",
"role": "tool",
"tool_call_id": "call_1"
}
],
"model": "gpt-4o-mini",
"stream": true,
"stream_options": {
"include_usage": true
},
"temperature": 0.5,
"tools": [
{
"function": {
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
},
"type": "function"
}
]
}
@@ -0,0 +1,66 @@
{
"include": [
"reasoning.encrypted_content"
],
"input": [
{
"content": "Weather in Paris and London?",
"role": "user",
"type": "message"
},
{
"arguments": "{\"city\": \"Paris\"}",
"call_id": "call_1",
"name": "get_weather",
"type": "function_call"
},
{
"arguments": "{\"city\": \"London\"}",
"call_id": "call_2",
"name": "get_weather",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "18C, clear.",
"type": "function_call_output"
},
{
"call_id": "call_2",
"output": "Tool execution was cancelled.",
"type": "function_call_output"
},
{
"content": "Actually, never mind London.",
"role": "user",
"type": "message"
}
],
"max_output_tokens": 4096,
"model": "gpt-5",
"prompt_cache_retention": "24h",
"reasoning": {
"effort": "medium"
},
"store": false,
"stream": true,
"tools": [
{
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
}
@@ -0,0 +1,29 @@
{
"include": [
"reasoning.encrypted_content"
],
"input": [
{
"content": [
{
"text": "What's in this image?",
"type": "input_text"
},
{
"image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"type": "input_image"
}
],
"role": "user",
"type": "message"
}
],
"max_output_tokens": 4096,
"model": "gpt-5",
"prompt_cache_retention": "24h",
"reasoning": {
"effort": "medium"
},
"store": false,
"stream": true
}
@@ -0,0 +1,55 @@
{
"include": [
"reasoning.encrypted_content"
],
"input": [
{
"content": "Think about the weather.",
"role": "user",
"type": "message"
},
{
"content": "Let me check.",
"role": "assistant",
"type": "message"
},
{
"arguments": "{\"city\": \"Paris\"}",
"call_id": "call_1",
"name": "get_weather",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "Tool execution was cancelled.",
"type": "function_call_output"
}
],
"max_output_tokens": 4096,
"model": "gpt-5",
"prompt_cache_retention": "24h",
"reasoning": {
"effort": "medium"
},
"store": false,
"stream": true,
"tools": [
{
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
}
@@ -0,0 +1,55 @@
{
"include": [
"reasoning.encrypted_content"
],
"input": [
{
"content": "Think about the weather.",
"role": "user",
"type": "message"
},
{
"content": "Let me check.",
"role": "assistant",
"type": "message"
},
{
"arguments": "{\"city\": \"Paris\"}",
"call_id": "call_1",
"name": "get_weather",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "18C, clear.",
"type": "function_call_output"
}
],
"max_output_tokens": 4096,
"model": "gpt-5",
"prompt_cache_retention": "24h",
"reasoning": {
"effort": "medium"
},
"store": false,
"stream": true,
"tools": [
{
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
}
@@ -0,0 +1,56 @@
{
"include": [
"reasoning.encrypted_content"
],
"input": [
{
"content": "Run the deploy.",
"role": "user",
"type": "message"
},
{
"arguments": "{}",
"call_id": "call_1",
"name": "deploy",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "deployed",
"type": "function_call_output"
},
{
"content": "Great, what's next?",
"role": "user",
"type": "message"
}
],
"instructions": "Output-guard: deploy output looked clean.",
"max_output_tokens": 4096,
"model": "gpt-5",
"prompt_cache_retention": "24h",
"reasoning": {
"effort": "medium"
},
"store": false,
"stream": true,
"tools": [
{
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
}

Some files were not shown because too many files have changed in this diff Show More