Skip to content

Big Smooth daemon epic: SEP extensions, daemon PWA, th OAuth login, search/crawl/knowledge, web push#244

Draft
brentrager wants to merge 139 commits into
mainfrom
sep-into-daemon
Draft

Big Smooth daemon epic: SEP extensions, daemon PWA, th OAuth login, search/crawl/knowledge, web push#244
brentrager wants to merge 139 commits into
mainfrom
sep-into-daemon

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

139 commits of Big Smooth daemon work: th ext CLI (SEP extensions on smooth-daemon), daemon PWA (conversation sidebar/resume, drag-and-drop files, multimodal, web push notifications), Smoo login via th CLI OAuth + PKCE, th search/crawl/knowledge retrieval on the epic, model routing updates (Flash mode), token/iteration headroom. Large epic branch — review commit-by-commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ji3rA65cTfU9vPQh9HAASJ

brentrager and others added 30 commits June 22, 2026 17:53
…-dep)

Phase 0 of EPIC th-c89c2a — reimagine Big Smooth as an always-on,
single-tenant personal-agent daemon on the smooth-operator engine.

Why: anyone self-hosts their own instance (hermes-style), so there is no
untrusted tenant to isolate. The microsandbox microVM substrate defends
against the wrong threat; it will be replaced by a kernel OS-sandbox +
egress proxy + Claude-Code-style auto-mode permission engine. The new
daemon is a clean rewrite that lives ALONGSIDE smooth-bigsmooth so `th up`
keeps working on the old path until the new daemon reaches parity.

This commit:
- Vendors the engine: repoints the workspace `smooth-operator` dep from the
  crates.io `smooai-smooth-operator-core` 0.14.0 to a relative path dep on
  the sibling checkout, so we can patch the engine (configurable streaming
  timeouts, AUTO_CONTEXT_LIMIT, Context-Epoch prompt stability, first-class
  Dolt Memory backend) during the rewrite. CI caveat documented inline: this
  branch must convert to a git-rev dep before merging to main (same lesson as
  the smooai-client-shared dep).
- Scaffolds crates/smooth-daemon with the durable-event core (event.rs): the
  monotonic-seq DaemonEvent envelope + EventStore cursor-resume contract that
  the /api/event SSE endpoint (Phase 1) and the Dolt event log (Phase 2) are
  both built on, plus an InMemoryEventLog impl. 8 tests, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…Coordinator

The pure, fully-tested core of the daemon spine, ahead of the axum server:

- wire.rs: ClientEvent/ServerEvent matching the legacy smooth-bigsmooth
  protocol byte-for-byte (#[serde(tag="type")], PascalCase variants) so the
  th-code TUI and smooth-web SPA connect with no protocol changes. Round-trip
  tests pin the JSON shape against the legacy events.rs. Plus map_agent_event:
  the single, exhaustive AgentEvent -> ServerEvent translation point.
- coordinator.rs: SessionRunCoordinator (opencode pattern) — one agent fiber
  per session, concurrent across sessions; try_start/cancel_session/cancel_task
  with abort handles and natural-completion cleanup guarded on task id.

22 tests, clippy + fmt clean. Stage B (axum /health + /ws + in-process
Agent::run_with_channel wiring) builds on this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Stage B of the daemon spine. The always-on daemon now serves the protocol
the frontends already speak and runs the agent in-process (no microVM):

- server.rs: axum router with GET /health (liveness + version, the TUI probes
  this before auto-start) and GET /ws. The WS handler greets with
  Connected{session_id}, heartbeats Pong every 30s, decodes ClientEvents, and
  routes TaskStart through the SessionRunCoordinator (one fiber/session;
  Busy -> TaskError). AppState holds the coordinator + EventStore. serve()
  binds + serves.
- runner.rs: run_task builds the engine Agent (AgentConfig + empty ToolRegistry
  for now — text-only), runs Agent::run_with_channel, drains AgentEvents on a
  side task, maps each to a wire ServerEvent, forwards it to the socket, and
  records it to the durable EventStore. Synthesizes a terminal for the engine's
  early-return path / hard errors, guarded so it never double-emits.
- config.rs: resolve_llm from env (SMOOTH_API_URL/KEY/MODEL, per-task model
  override) + resolve_bind (default 127.0.0.1:4400 = legacy port for drop-in).

Tests (28 total): real end-to-end WebSocket smoke tests (Connected handshake,
Ping->Pong, and the full TaskStart -> coordinator -> runner -> socket path
emitting a terminal over a live socket), health handler, config resolution.
clippy + fmt clean.

Deferred to follow-on Phase 1 work: /api/event SSE + cursor resume, /api/session
REST, tailnet bind + bearer auth, th service/th up wiring, tool registration,
and pointing the TUI/SPA at the daemon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Adds GET /api/event: a cursor-resumable Server-Sent-Events stream over the
durable EventStore. Each SSE message carries the event seq as its `id`, so a
frontend (or the daemon after a restart) reconnects with ?cursor=N / a
Last-Event-ID and resumes with zero loss and zero duplicates — closing the
server-side resume gap opencode left stubbed.

Implemented as a poll loop over the EventStore (not a broadcast subscription):
the cursor is the only state, so resume is trivially correct and a restarted
daemon with a durable log resumes identically. Phase 2's Dolt-backed store
slots in behind the same EventStore trait with no change to this code.

Test proves resume-from-cursor (replays only events after the cursor, never
re-sends earlier seqs) and live-tail (blocks once caught up, delivers newly
appended events). 29 tests, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Adds the daemon's session registry and the control-surface REST endpoints:

- session.rs: Session + SessionStatus (active/idle/completed) + an async
  SessionStore trait with an in-memory impl. create() is idempotent on an
  explicit id (a reconnecting client resumes its row); list() is newest-first.
  Phase 2's Dolt-backed impl slots in behind the same trait.
- server.rs: GET /api/session (list), POST /api/session (create), GET
  /api/session/{id} (get, 404 if unknown). The WS handler now registers its
  session on connect, flips it to Active on TaskStart and Idle on disconnect,
  so the control surface reflects live state. SessionStore wired into AppState.

35 tests (6 new: store semantics + the REST happy/404 paths). clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
The daemon is now a runnable binary, not just a lib:

- main.rs: `smooth-daemon` binary — resolves bind (SMOOTH_DAEMON_BIND, default
  127.0.0.1:4400), builds AppState, serves until Ctrl-C/SIGTERM. RUST_LOG-aware
  tracing (default info + daemon=debug).
- server.rs: serve() now shuts down gracefully. Refactored into serve →
  serve_with_shutdown → serve_on (takes a pre-bound listener + a shutdown
  future) so shutdown is testable; shutdown_signal() awaits Ctrl-C / SIGTERM
  (unix) via tokio::signal.

Verified at runtime: the binary boots, serves GET /health and POST/GET
/api/session, and exits cleanly on SIGTERM (log: "shutdown signal received" ->
"smooth-daemon stopped"). 36 tests incl. a serve_on graceful-shutdown test;
clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…s live)

The daemon now Just Works with the same credentials the rest of `th` uses,
not just explicit env vars. resolve_llm priority:
  1. SMOOTH_API_URL + SMOOTH_API_KEY (+ SMOOTH_MODEL / per-task override)
  2. ~/.smooth/providers.json (what `th auth login` writes), resolved via the
     engine's ProviderRegistry::load_from_file + llm_config_for(Coding);
     overridable with SMOOTH_PROVIDERS_FILE.
  3. otherwise an actionable error ("run `th auth login` or set SMOOTH_API_*").

Refactored into a pure resolve_llm_inner so the priority logic is unit-tested
without env/FS races (5 new config tests). The two existing missing-config
integration tests now point SMOOTH_PROVIDERS_FILE at a nonexistent path so they
still exercise the error branch on a machine that has real creds.

VERIFIED LIVE end-to-end: booted the binary against the real providers.json,
drove a WS TaskStart, and watched actual model token streaming →
TaskComplete iterations=1 cost=$2.2e-06. The full spine works for real:
providers.json -> Agent::run_with_channel -> AgentEvent->ServerEvent -> WS.

41 tests, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
… (live)

New `smooth-tools` lib crate — clean, reusable reimplementations of the agent
tools the smooth-operative binary defines inline, registrable on any engine
ToolRegistry. Slice A = the read-only set, plus the security floor:

- path.rs: resolve_workspace_path — lexical (no symlink-follow, no canonicalize)
  workspace confinement, mirroring smooth-operative/tool_support.rs. Exhaustive
  adversarial tests (absolute paths, ../ escapes, sneaky sibling-prefix
  /work/space-evil, dotdot-to-base). This is the cheap first gate; the kernel
  OS-sandbox boundary lands in Phase 3 (EPIC th-c89c2a).
- read.rs: read_file (cat -n windows) + list_files (gitignore-aware glob, newest
  first). grep.rs: grep via the ripgrep libs (no shelling out), file:line:match,
  capped. All read-only, all workspace-confined.
- register_default_tools(registry, workspace) — one call to install the set.

Daemon: depends on smooth-tools; TaskSpec carries a `workspace` (from
TaskStart.working_dir, else daemon cwd, canonicalized); the runner now registers
the tool set instead of running text-only.

VERIFIED LIVE: drove a WS TaskStart against a real model — the agent reasoned,
called list_files(pattern=src/*.rs), got the correctly-scoped 9 .rs files,
counted them, replied "9" (TaskComplete iters=2). Real agentic tool use over
the daemon.

59 tests (smooth-tools 18 + daemon 41), clippy + fmt clean. Slice B (write_file/
edit_file) and C (bash) follow; smooth-operative can migrate onto smooth-tools
later as a separate refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…onfined)

Mutating filesystem tools added to smooth-tools and to register_default_tools,
so the daemon agent can now create and modify files (not just read):

- write_file: create/overwrite with exact content, auto-creates parent dirs.
- edit_file: exact-string find/replace; errors if old_string is absent or
  ambiguous (>1 match) without replace_all — same safety contract as the
  smooth-operative / Claude-Code edit tool.

Both route through resolve_workspace_path (the lexical confinement gate). They
are deliberately NOT yet permission-gated — the auto-mode engine + kernel
sandbox is Phase 3 (EPIC th-c89c2a); acceptable for the loopback single-user
daemon meanwhile. 6 new tests (create+parent-dirs, escape rejection, single/
ambiguous/replace_all edits). clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…t (full toolset live)

- bash.rs: run `sh -c <command>` with the workspace as cwd, optional timeout
  (kill_on_drop so it actually kills), exit-code + stdout/stderr capture,
  50KB/stream truncation. Clearly marked PRE-SANDBOX: a shell escapes workspace
  confinement; the kernel OS-sandbox + non-bypassable SandboxedCommand path is
  Phase 3 (EPIC th-c89c2a). Acceptable now only for the loopback single-user
  daemon. Registered in register_default_tools.
- runner.rs: replaced the stale "when you don't yet have tools available..."
  system prompt (a holdover from text-only Phase 1 that was actively suppressing
  tool use) with a tool-aware prompt that tells the agent to USE its tools.

VERIFIED LIVE (full toolset): a single TaskStart drove write_file(hello.txt,
"Smooth works.") -> bash(cat hello.txt) -> exit 0 stdout "Smooth works." ->
TaskComplete iters=3, file present with correct content. Multi-step agentic
tool use against a real model.

th-ed6604 core goal met: the daemon is a capable coding agent (read/list/grep/
write/edit/bash), all workspace-confined, all reusable from smooth-tools. 69
tests, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…I at the daemon

Wires the always-on daemon into the CLI and TUI (Phase 1 items 2+3):

- `th daemon [--port 4400] [--bind 127.0.0.1]` (smooth-cli): runs the
  smooth-daemon in the foreground via smooth_daemon::serve, graceful shutdown.
  smooth-cli now depends on smooth-daemon. Verified: `th daemon` boots, serves
  /health, prints the startup line.
- `th service install --daemon`: the launchd/systemd/schtasks unit runs
  `th daemon` instead of `th up --foreground`. Threaded a `daemon: bool` through
  install → render_plist/render_unit/windows + print_system_artifact; tests
  assert both the `up` and `daemon` program-arg variants on macOS + Linux.
- TUI: both auto-start paths (smooth-code BigSmoothClient::ensure_server and
  cmd_code's inline boot) honor SMOOTH_USE_DAEMON=1 → spawn `th daemon` instead
  of `th up`. Because `th daemon` runs in the foreground (unlike the
  self-daemonizing `th up`), cmd_code spawns it detached (not `.status()`, which
  would block) and polls /health. Default behavior (legacy `th up`) unchanged.
  The daemon is wire-compatible on the same port 4400, so the rest of the client
  is identical.

So now: `th daemon` (or `SMOOTH_USE_DAEMON=1 th code` to auto-start it) gives a
working always-on agent driven from the TUI.

422 smooth-cli + smooth-code tests pass; both crates clippy exit-0 (also fixed a
pre-existing doc_lazy_continuation error at main.rs:4631 that the current clippy
flags — unrelated to this change but in a crate I touched). fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…n restart)

The daemon's events + sessions now persist to a local SQLite DB, so the SSE
cursor-resume stream and the /api/session list survive a restart — the headline
Phase 2 capability.

DESIGN NOTE (deviates from the plan's "Dolt" wording, on purpose): the daemon's
events/sessions are PER-INSTANCE runtime state, not team-synced work items.
Dolt's value (version control + refs/dolt/data sync) is for PEARLS, and the
smooth-dolt Go binary isn't even built here (needs Go+ICU). SQLite (rusqlite,
bundled — no external binary, tests run anywhere) is the right tool for local
runtime state and achieves durable restart-resume more simply. Easy to revisit
if session sync across machines is ever wanted.

- sqlite.rs: SqliteEventLog + SqliteSessionStore implementing the existing
  EventStore/SessionStore traits, sharing one WAL connection (single serialized
  writer — plenty for single-tenant). open_db (schema + WAL), open_stores.
- AppState::persistent(db_path) / persistent_default() / default_db_path()
  (honors SMOOTH_DAEMON_DB override, else ~/.smooth/daemon.db). The
  smooth-daemon binary and `th daemon` both use the durable state now;
  AppState::new() stays in-memory for tests.

VERIFIED LIVE: created a session on one daemon instance, SIGTERM'd it, booted a
fresh instance against the same DB → the session was still there (id + title +
status). 44 daemon tests incl. 3 SQLite tests (append/since/cursor + reopen
persistence + session reopen). clippy + fmt clean; th rebuilds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
The daemon now resumes the actual CONVERSATION across a restart, not just
metadata. Verified live: told the agent a name, killed the daemon, restarted a
fresh process against the same DB, reconnected to the same session, and it
recalled the name.

How (the engine randomizes agent ids per turn, so resume is by prior-message
replay, not checkpoint-by-id):
- messages.rs: MessageStore trait + InMemoryMessageStore — durable per-session
  conversation history. sqlite.rs: SqliteMessageStore + session_messages table;
  open_stores now returns a Stores struct (events/sessions/messages sharing one
  conn).
- runner.rs: run_task takes a MessageStore, accumulates streamed assistant text,
  and on success appends the (user, assistant) turn to durable history.
- server.rs: on TaskStart the daemon loads the session's durable history as
  prior_messages (it owns the conversation, ignoring any client-sent copy). WS
  connect accepts `/ws?session=<id>` to RESUME a session (so its history
  replays) — the missing piece for cross-restart continuity, since each
  connection otherwise mints a fresh session id.
- AppState carries the message store (in-memory for new(), SQLite for
  persistent()).

47 daemon tests (incl. message store + reopen-persistence); clippy + fmt clean.
Phase 2 done: both durable state (events/sessions) AND durable conversation
resume work across a full daemon restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
The deterministic deny→ask→allow decision engine, mirroring Claude Code's
permission model (EPIC th-c89c2a §3). Pure functions of (mode, tool, args), so
exhaustively testable; no wiring yet (the ToolHook + approval flow is Slice 1b,
the kernel sandbox that actually ENFORCES is Slice 2 — this is the intent/UX
layer, explicitly not the security boundary).

- Decision { Allow, Ask, Deny }; PermissionMode { Default, AcceptEdits, Plan,
  Auto, DontAsk, BypassPermissions } (+ parse for SMOOTH_PERMISSION_MODE).
- PermissionEngine::decide(tool, args): read-only tools always allow; writes
  follow mode + a protected-path set (.git/.env/.npmrc/… — .git anywhere in the
  path, with .git/worktrees as the documented exception); bash classifies
  read-only vs not and applies the mode. Circuit-breakers (rm -rf //~ , curl|sh,
  fork bombs, dd of=/dev/…) are DENIED in every mode including bypass.

7 tests covering every mode × tool class + adversarial circuit-breaker inputs.
54 daemon tests total; clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…val flow

Wires the Gate-1 engine into the agent and adds the operator-approval
round-trip. Verified live: in Default mode a write_file emitted a
PermissionRequest, the client approved, and only then did the write run.

- hook.rs: PermissionHook implements the engine's ToolHook (pre_call). Allow →
  proceed; Deny → block (Err); Ask → emit ServerEvent::PermissionRequest and
  await the operator's reply, fail-CLOSED (deny) on timeout (default 300s).
- approval.rs: ApprovalCoordinator routes ClientEvent::PermissionReply (by
  request id) back to the waiting hook via a oneshot.
- wire.rs: ServerEvent::PermissionRequest{request_id,tool_name,summary} +
  ClientEvent::PermissionReply{request_id,allow}.
- runner.rs: run_task attaches the hook (tools.add_hook). server.rs: AppState
  carries the ApprovalCoordinator + permission_mode (config::resolve_permission_mode
  from SMOOTH_PERMISSION_MODE, default Default); handle_client_event resolves
  PermissionReply.

62 daemon tests (approval coordinator + hook: allow/deny/ask-approve/ask-deny/
timeout-fail-closed). clippy + fmt clean. Slice 2 (the kernel OS-sandbox that
ENFORCES) is next — this is the intent/UX gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…lt, enforced)

The enforcement boundary the permission engine only expresses. A reasoning
agent can talk past a userspace check but not past the kernel, so bash now runs
inside an OS sandbox. VERIFIED LIVE: in auto mode (permission engine ALLOWS
bash), the agent's `echo pwned > ~/escape.txt` got "Operation not permitted"
from Seatbelt and the file was never created.

- sandbox.rs: SandboxedCommand — the ONLY way bash builds its subprocess (P0:
  no unsandboxed spawn path exists). SandboxPolicy::for_workspace. On macOS,
  generates a Seatbelt (SBPL) profile: allow-default, but deny all file-writes
  except the (canonicalized) workspace + temp + std devices, deny writes to
  workspace/.git/hooks (P1 — postinstall can't plant a hook that runs outside
  the sandbox), and deny reads of ~/.ssh ~/.aws ~/.config/gh ~/.gnupg (P1
  credential-read-denial). Linux/other: NOT YET (bubblewrap+Landlock TODO) —
  falls back to unsandboxed + a loud tracing::warn, acceptable only for the
  single-trusted-user loopback daemon.
- bash.rs: spawns exclusively via SandboxedCommand (was the pre-sandbox path).

macOS-gated kernel-enforced tests: write-inside-workspace allowed, write-to-HOME
denied, read-~/.ssh denied. 32 smooth-tools tests; clippy + fmt clean.

Phase 3 core done: Gate-1 permission engine + approval flow (intent/UX) +
kernel sandbox (enforcement). Remaining hardening: Linux sandbox, egress proxy
(goalie), seccomp mmap/ld.so gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
… surface (live)

The daemon now serves its own control-surface SPA, and it works end-to-end in a
real browser: I typed a message and watched the assistant response stream live
over the WebSocket into the React UI.

- daemon: depends on smooth-web; build_router adds
  `.fallback_service(smooth_web::web_router())` so non-API routes serve the
  embedded SPA while /api/* and /ws keep priority (verified: GET / → index.html,
  GET /api/session → []).
- web/src/daemon.ts: a typed client for the daemon API — ServerEvent/ClientEvent
  mirroring wire.rs, getHealth/listSessions/createSession, and a reconnecting
  DaemonSocket (with /ws?session= resume support).
- web/src/control.tsx: the ControlApp — header w/ version + connection dot, a
  sessions sidebar (+ new), a live-streaming chat (TokenDelta → bubbles, tool
  calls inline), and an inline approval inbox wired to PermissionRequest/Reply.
- web/src/main.tsx: boot detects the backend via /health and renders ControlApp
  for the daemon, else the legacy Big Smooth SPA. Two surfaces, one bundle, no
  entanglement.

Verified in-browser: control surface renders (dark theme, connected dot), the
session appears, and a chat message round-tripped with live token streaming.
pnpm typecheck + vite build green; daemon fmt + clippy clean.

Next slices: approval-inbox visual pass + session resume/history, status panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Makes the control surface's sessions sidebar functional: click a session to load
its conversation and resume it.

- daemon: GET /api/session/{id}/messages returns the durable conversation
  history (StoredMessage now Serialize). Verified end-to-end: ran a turn, then
  the endpoint returned the user+assistant messages.
- control.tsx: clicking a session loads its history (renders prior messages) and
  reconnects the WS with /ws?session=<id> so the next turn continues that
  conversation; "+ new" creates and switches to a fresh session; sessions are
  now clickable buttons with active highlight.

pnpm typecheck + vite build green; 62 daemon tests; daemon fmt clean.
(Browser visual pass deferred — navigation to the test port was permission-
blocked under autonomous run; iteration 1 already verified in-browser rendering
+ live chat.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Daemon: new GET /api/status returns service/version/permission_mode/
active_tasks; PermissionMode::as_str() for the JSON surface.

Control surface: render assistant messages as markdown (react-markdown,
styled via arbitrary child selectors — no typography-plugin dep),
auto-scroll the chat to the newest item, show the live permission mode +
running-task count in the header (polled via /api/status), and append a
completion line (iterations + cost) on TaskComplete.

Verified: cargo fmt/clippy/test (62 pass), pnpm typecheck/build, and a
live curl of /api/status returning permission_mode=auto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Daemon: permission_mode becomes a thread-safe SharedPermissionMode
(AtomicU8-backed, cheap-clone) so posture is switchable without a
restart; each new task reads the current value. New PUT /api/mode
parses the mode (default|acceptEdits|plan|auto|dontAsk|bypassPermissions),
sets the holder, and returns the resolved mode — 400 on an unknown value.

Control surface: the header permission-mode badge is now a dropdown that
PUTs the new mode and re-reads /api/status (optimistic update + refresh).

Tests: SharedPermissionMode get/set/clone-sharing/u8-round-trip; set_mode
handler switches posture + status reflects it, and rejects unknown values
(67 daemon tests pass). Verified live: default → PUT auto → status=auto,
garbage → 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Daemon: TaskCancel now emits a terminal TaskError('task cancelled') and
sets the session idle when it actually aborts a fiber. The aborted task
skips its own completion cleanup, so without this the client stays 'busy'
forever with no terminal signal. Unknown task ids stay a silent no-op.

Control surface: capture the running task_id from the first event that
carries it; show a 'stop' button (in place of 'send') while busy that
fires TaskCancel; clear the task id on terminal events + session switch.

Tests: cancel emits the terminal event + frees the session (active_count
→ 0, status → Idle); unknown id is silent (69 daemon tests pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
The daemon's module doc flagged auth as the outstanding gap. Adds an
opt-in bearer token (SMOOTH_DAEMON_TOKEN): with none set the daemon
serves open (loopback trusts the local user — existing frontends keep
working); with one set, a require_auth middleware wraps every API + WS
route and rejects requests lacking 'Authorization: Bearer <token>' or a
?token= query param (the latter for browser WebSockets, which can't set
headers). Tokens are compared in constant time. /health and the embedded
SPA stay open. A non-loopback bind with no token logs a startup warning.

config: resolve_auth_token() (trim, empty→unset). AppState gains an
auth_token field, wired in persistent().

Tests: open-when-disabled, reject missing/wrong, accept bearer + query
token, /health always open, constant_time_eq, request_token precedence
(76 daemon tests pass). Verified live: 401 without, 200 with bearer and
with ?token=, startup logs auth=true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…red reads

Extends the macOS Seatbelt bash profile toward the plan's P1 security
acceptance criteria:
- P1 #5: kernel-deny writes to workspace/.git/config (not just .git/hooks).
  A writable git config can repoint core.hooksPath or install executing
  aliases that later run OUTSIDE the sandbox — same escape class as a
  planted hook.
- P1 #6: extend credential read-denial from ~/.ssh / ~/.aws / ~/.config/gh
  / ~/.gnupg to also cover ~/.config/gcloud, ~/.kube, ~/.docker, ~/.netrc.

Adds two adversarial tests (run + pass on macOS): a postinstall-style
attempt to plant .git/hooks/post-checkout and overwrite .git/config both
fail (files never exist), and cat-ing cloud/registry/netrc creds leaks no
secret material. 34 smooth-tools tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
…oth creds

Closes a real exfil hole: the cred read-deny list covered ~/.ssh, ~/.aws,
etc. but NOT ~/.smooth itself, where the daemon's own LLM API key
(providers.json) and auth JWT (auth/) live. A sandboxed bash tool could
'cat ~/.smooth/providers.json' and exfil exactly the secret that drives
the agent — the lethal-trifecta payload. Now kernel-denied (literal for
providers.json, subpath for auth/). Project-scoped <workspace>/.smooth
pearls are a different path and stay readable.

Adds an adversarial test that plants a sentinel under ~/.smooth/auth and
proves the sandbox can't read it (cleans up; only removes the auth dir if
it created it). 35 smooth-tools tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
env/printenv are read-only-classified (auto-allowed) and the bash tool
inherited the daemon's full environment, so a sandboxed 'env' could dump
SMOOTH_API_KEY / SMOOTH_DAEMON_TOKEN and provider *_API_KEYs into the
transcript — the env-var twin of the ~/.smooth read hole. Fix at the
single non-bypassable spawn point (SandboxedCommand::shell): scrub
secret-named vars (SMOOTH_*, *_API_KEY, *_TOKEN, *_SECRET, *PASSWORD*,
*_PAT, ...) from the child env, name-matched so values are never
inspected. Platform-independent, so it also protects the not-yet-
sandboxed Linux path. PATH/HOME/etc. preserved so the shell still works.

Tests: is_secret_env_name detection (broad secret set + benign set),
and a macOS adversarial test proving a planted secret can't be read via
'env' while PATH survives. 37 smooth-tools tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Previously only curl|sh / wget|bash tripped the breaker. A downloader
piped into any interpreter (python/python3/perl/ruby/node/zsh/dash/ksh,
incl. |& variants) is the same remote-code-execution pattern — now caught.
Also catches a command-substituted download fed to eval or an interpreter
-c (eval "$(curl ...)", bash -c "$(wget ...)"). Detection splits on
| and matches the first token of each segment, so an interpreter name as a
substring (shellcheck) is not a false positive. These fire in every mode
including auto/bypass, where the breaker list is the last backstop before
the kernel sandbox.

Tests: expanded the every-mode dangerous set with the new RCE forms, plus
a lookalike test proving shellcheck / local-pipe / plain-interpreter
invocations are NOT denied. 77 daemon tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Full-workspace test checkpoint after the Phase 3/4 daemon slices surfaced
one failure — pre-existing and environment-sensitive, in the legacy
smooth-bigsmooth crate (untouched by the EPIC).

find_native_operative_binary() intentionally falls back to
$CARGO_HOME/bin / ~/.cargo/bin (th-92dac3) when no target/<profile>/
build sits near the manifest — precisely the case in a worktree that
builds into a shared target dir. The test asserted the path must be under
target/release|debug, so it failed on a cargo-installed smooth-operative.
Relax the assertion to also accept a bin/ install dir, keeping the guard
that it must NOT be the aarch64-unknown-linux-musl cross-compile.

Checkpoint result: cargo fmt --check clean, 0 clippy errors workspace-wide,
cargo build --workspace clean, cargo test --workspace 2047 passed / 0
failed / 10 ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Starts the egress boundary (the largest remaining security arc) with its
load-bearing, exhaustively-testable core in smooth-goalie:

- normalize_hostname(): a single strict ASCII-DNS parser that rejects the
  bypass primitives BEFORE any membership check — embedded NUL / non-ASCII
  labels (attacker.com\0.google.com, the SOCKS5 CVE-2025-55284 class),
  ports/schemes/userinfo/paths/wildcards, and malformed label structure.
  Strips one trailing FQDN dot, lowercases.
- EgressAllowlist: exact hosts only. Wildcard/port/malformed entries are
  dropped at construction and returned for logging, so a bad config can
  only narrow reachability, never widen it. is_allowed() normalizes the
  query through the SAME parser, closing the normalization-mismatch class.

In-process (no Wonk round-trip — that was the per-VM design). The proxy
process + HTTP_PROXY forcing + Seatbelt direct-egress deny that consume
this land in following ticks.

7 adversarial tests pass; allowlist.rs is clippy-clean (0 warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
Connects the tested allowlist core to goalie's actual forward proxy.
Introduces NetworkDecider { Wonk(WonkClient), Local(EgressAllowlist) } with
one async decide(domain,path,method) -> (allowed, reason); both arms fail
closed. The always-on daemon runs the proxy via the new
run_proxy_local(addr, allowlist, audit) — exact-host egress with zero Wonk
round-trip (that was the per-VM design). The accept loop is factored into
serve(); HTTP + CONNECT share the single decision call; audit 'reason'
fields now carry the real decider rationale. run_proxy(addr, wonk, audit)
is unchanged (back-compat).

Tests: NetworkDecider::Local allow/deny incl. normalization-smuggling and
exact-only siblings; and an end-to-end test — a real reqwest client through
the proxy gets 403 for an unlisted host, contacting no upstream. 24 goalie
tests pass; proxy.rs + allowlist.rs are clippy-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
SandboxPolicy::with_proxy(host:port) makes the sandbox the egress boundary:
- sets HTTP(S)_PROXY / ALL_PROXY on the child (NO_PROXY for loopback) so
  HTTP tooling routes through the goalie proxy's exact-host allowlist;
- macOS Seatbelt denies direct network-outbound except to loopback (the
  proxy + local dev servers + unix sockets), so a tool that ignores the
  proxy env can't connect off-box at all — the proxy isn't bypassable.

Opt-in: with no proxy set, network is unrestricted as before (no
regression). On Linux the proxy env is still set (advisory until the
bubblewrap sandbox lands).

Tests: proxy env injection and a profile-parses-and-runs test (a clean
exit proves the network-deny SBPL is valid). Verified live out-of-band
that the deny truly blocks external egress (direct curl to example.com ->
http_code=000/exit=7) while an unsandboxed curl returns 200. 39
smooth-tools tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1
brentrager and others added 25 commits June 30, 2026 07:59
…xisting tailscale serve

Make the always-on daemon deployable on a shared host (smoo-hub) where :8787 and
the tailnet :443 are already taken:
- SMOOTH_ADDR sets the default 'th daemon' bind addr (else 127.0.0.1:8787), so a
  launchd/systemd unit picks a free port with no CLI arg; tailscale serve follows it.
- SMOOTH_TAILSCALE_HTTPS_PORT sets the tailnet serve port (else 443) to coexist
  with another tailscale serve already on :443.
- Coexistence-safe teardown: tailscale 1.96.5 has no per-port 'off' (only 'serve
  reset', which wipes ALL handlers), so on a custom port we leave the --bg handler
  in place (persists harmlessly, re-applies idempotently) — never reset another
  service's :443 config. Only the default :443 (single-tenant) tears down.
Tests for the addr/port resolution + the conditional teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs
Finish the smoo-hub deploy story:
- th service install --env KEY=VALUE bakes env into the LaunchAgent/systemd unit
  (SMOOTH_ADDR for a free port, SMOOTH_TAILSCALE_HTTPS_PORT for :443 coexistence).
- th service self-update: git pull --rebase --autostash + pnpm install:th + restart,
  so the running daemon execs freshly-built binaries.
- th service install-updater: a launchd StartInterval agent (default hourly) running
  self-update from the configured repo — continuous update for a self-hosted box.
  macOS-native; Linux prints the systemd-timer command. Updater agent is removed on
  uninstall. Tests for env parsing, env-in-plist, and the updater plist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs
- The Smooth 'th' gradient mark is now the favicon + PWA/app icon at every size
  (favicons transparent; apple-touch + pwa icons on the brand near-black, padded
  for maskable), plus a quiet top-left product mark in the UI. Manifest → 'Big Smooth'.
- PWA: registerType 'prompt' + a poll-while-open updater that shows a forced
  'A fresh Big Smooth is ready' refresh modal on new deploys, so an installed tab
  never serves stale code. Added workbox-window for the register hook; /admin + /search
  excluded from the SW navigation fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs
…th teal→blue

'Big Smooth' now colors the way the brand mark reads: 'Smoo' rides the Smoo
orange→coral→red (#f49f0a→#fb7a4d→#ff6b6c), 'th' (and the 'Big' qualifier) the
th teal→blue. Split into a Wordmark component with wm-smoo / wm-th gradient spans.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs
…es + reload

updateServiceWorker(true) relies on the SW controllerchange event to reload,
which iOS Safari fires unreliably (modal showed, 'Refresh now' did nothing on
iPhone). Make the button also unregister every service worker and clear all
caches before a hard reload, so the navigation always refetches fresh and the
SW re-registers clean. Button shows a 'Refreshing…' state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs
Daemon: push module with /push/{key,subscribe,test}; PushState VAPID-signs +
encrypts via web-push, persists subs to ~/.smooth/push-subs.json, prunes expired
endpoints. Keys from SMOOTH_VAPID_PUBLIC/PRIVATE (unset ⇒ disabled). Merged into the
operator's served routes alongside /search.
Frontend: usePush enrollment hook (permission → subscribe → POST), a 'Notify me'
bell top-right, and public/push-sw.js (push + notificationclick) imported into the SW.
Also fixed a pre-existing duplicate 'mod tests' in main.rs that broke the bin test target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs
Composer: paperclip + paste + drag-drop image/PDF attachments, removable
thumbnails, inline <img>/file-chip render in the user bubble. sendMessage
adds images:string[] (full data-URLs) to the send_message WS frame (omitted
when empty). Mobile: h-dvh + overflow-hidden shell (only the message list
scrolls), viewport zoom-lock, body overscroll-behavior:none — fixes the iOS
scroll/zoom-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…otos

A raw phone photo (~3.4MB base64) timed out the LLM gateway (HTTP 524) on an
image turn. Downscale to <=1568px longest edge + re-encode JPEG(0.85) via a
canvas before building the data-URL; vision models gain nothing past ~1568px.
PDFs unchanged; falls back to the original on any canvas failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ash)

deepseek-v4-flash flails on multi-step tool use — it made 15+ tool calls
exploring a repo then returned an EMPTY final response (looks hung in the
UI). Reproduced via a WS driver against smoo-hub: gemini-2.5-flash handles
the identical task with a few smart tool calls + a real answer;
gemini-2.5-flash-lite gives up too early. Flash mode is the default chat
model, so this fixes 'Big Smooth seems hung' on tool-using turns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolve_workspace_path rejected ALL absolute paths, forcing relative-only.
With the workspace = the user's home dir, the agent naturally emits absolute
paths (user says '~/dev/smooai/x'), gets 'absolute path not allowed', flails,
and returns an empty answer (looks hung). Now an absolute path that lexically
resolves INSIDE the workspace is allowed; confinement is preserved (absolute
paths outside base still fail starts_with, same as a relative ../escape). The
kernel OS-sandbox stays the load-bearing symlink boundary. Exhaustive tests
incl. absolute-outside + absolute-dotdot-escape adversarial cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ccepts abs paths

On a home-dir workspace, a glob like 'smoo-hub/**/*' walked all of home, blew
the 50k budget, and the agent wrongly concluded it couldn't access the repo.
Now list_files roots the walk at the pattern's literal directory prefix (so a
glob into one repo walks only that subtree), accepts an absolute-within-
workspace pattern (strips to relative), and treats a bare dir path as
'<dir>/**/*'. Fixes the flaky 'seems hung / can't access' on tool turns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Big Smooth's chat path resolved config via local_config() ->
ServerConfig::from_env(), inheriting the customer-support WIDGET defaults
(512 max_tokens / 6 max_iterations). A reasoning model like
deepseek-v4-flash spends its whole 512-token budget on reasoning_content
and returns EMPTY content (the 'seems hung' empty reply); 6 iterations
makes any read-a-few-files turn hit 'Maximum iterations reached'. Earlier
misdiagnosed as 'the model sucks' — it was starvation.

resolve_gateway_config() now gives the daemon 32768 tokens / 50
iterations unless SMOOTH_AGENT_MAX_TOKENS / SMOOTH_AGENT_MAX_ITERATIONS
explicitly override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…safe)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oser)

The onDragOver/onDrop handlers were only on the composer strip at the
bottom, so dropping onto the face/chat area did nothing. Move to
window-level dragenter/over/leave/drop listeners (depth-counted to avoid
flicker, file-drags only) so a drop ANYWHERE on the PWA attaches, plus a
full-screen 'Drop to attach' overlay. Reuses addFiles (downscale + PDF).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… order

1. Tool chips were stuck on 'running…' forever — operator.ts read the result
   from st.toolResult but the server nests it under st.rawResponse.toolResult
   (runner.rs), so the completion branch never fired. Chips now flip to done.
2. Chips piled at top + prose concatenated at bottom. Added an ordered 'blocks'
   array (text/tool in arrival order); turns now render say-a-bit -> tool ->
   say-a-bit -> summary as the model produced them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The smooth-web SPA was a single ephemeral chat (one fresh session per load,
no history). Add a toggleable slide-in sidebar listing recent conversations,
resume-in-context, and a New chat button — driven by three more smooth-operator
WS actions.

operator.ts: on connect, request list_conversations (refreshed after each turn);
resumeConversation(id) binds a session to an existing conversationId then loads
its history via get_conversation_messages; newConversation() opens a fresh
session and clears the transcript. New hook surface: conversations,
activeConversationId, refreshConversations, resumeConversation, newConversation.

App.tsx: Aurora-Glass Sidebar (overlay+backdrop on mobile, docks on desktop),
active-row highlight, prominent New chat, brand mark relocated into the sidebar
header. No sidebar interaction = unchanged single-chat behaviour.

Also fills the required blocks: [] on the error/user ChatMessage literals so
tsc --noEmit is clean (they previously only survived via vite's esbuild).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_conversation_messages returns the stored domain Message
({direction, content:{items:[{type,text}]}}), not {role,content}. Map
direction->role + flatten content.items[] text so resumed conversations render.
Verified live on smoo-hub. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each sidebar row gets a rename affordance — a hover pencil icon (or
double-click the row) opens an inline input in place of the title; Enter
commits, Esc/blur cancels. The row updates optimistically, then reconciles
to the server's canonical (sanitized) title from the rename_conversation
reply.

Adds renameConversation(id, title) to the useOperator hook, sending the
server's rename_conversation WS action. Pairs with the server-side
auto-title + rename (th-d5b446).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ooth-daemon)

Bring the SEP extension surface to the chat-first Big Smooth (epic
th-c89c2a-smooth-daemon). The extension HOST is already wired in
smooth-operator-server (build_extension_host + Agent::with_extension_host,
per-turn, gated by SMOOTH_EXTENSIONS_ALLOW, discovering ~/.smooth/extensions),
which the daemon embeds via LocalServer — so the daemon already hosts
extensions at runtime. What the epic lacked was the install surface.

- Port smooth_policy::ext_trust (content-hashed trust store: hash_extension,
  TrustStore, TrustRecord, trust_path) into smooth-policy (adds sha2 + dirs-next).
- Port `th ext` (install/search/update/list/trust/reload/remove) into smooth-cli,
  reading hash_extension/TrustStore from smooth_policy::ext_trust (not
  smooth_code::sep_host, which the epic doesn't have). `th ext install <dir>`
  drops an extension into ~/.smooth/extensions/<name>; with
  SMOOTH_EXTENSIONS_ALLOW=<name> the daemon discovers + spawns it per turn.

Did NOT port main's smooth-bigsmooth/src/sep.rs into smooth-daemon: that is
main's architecture (bigsmooth builds its own Agent). The daemon delegates
agent construction to smooth-operator-server, which already owns the SEP host.

Tests: ext_trust (4) + ext (11) pass; smooth-policy + smooth-cli check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USBgWKcgpkWAugmHPE7Bc9
…slice of the lean-reconcile)

Bring main's agentic web tools into the epic Big Smooth — the "take all the
good parts of main that aren't the old cast" reconcile, starting with the
agent-facing search surface:

- th knowledge search <query>  → POST /organizations/{o}/knowledge/search (org
  RAG retrieval, SMOODEV-2610) + th knowledge add-url. Ported main's knowledge.rs
  (superset of the epic's; also richer --org-id handling). Also under
  th api knowledge.
- th search <query>            → smooai::websearch (agentic web search, ADR-088).
- th crawl scrape <url>        → smooai::crawl (in-house crawler, ADR-035).
- th web-search (hidden alias) kept for back-compat.

Wired as top-level commands in main.rs so the smooai-tools SEP extension's
knowledge_search / web_search tools (which shell `th knowledge search` /
`th search`) work on Big Smooth. Makes the marketing claim honest: the org RAG
retrieval command now exists.

Tests: 183 pass (cli). Clean build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USBgWKcgpkWAugmHPE7Bc9
"Sign in with Smoo" so Big Smooth acts on the user's Smoo org via th-backed
extensions. Ports main's bigsmooth auth_login.rs into the daemon as a
self-contained, router-owned module (AuthState replaces bigsmooth's server
AppState) and wires it into the daemon's HTTP surface.

- GET /auth/login    — derive redirect_uri from Host/X-Forwarded-Proto, mint
  PKCE (S256) + single-use CSRF state (600s TTL), 302 to smoo.ai/cli-login.
- GET /auth/callback — single-use-remove state, PKCE code exchange, persist the
  user session via smooai_client_shared CredentialsStore::default_user() — the
  SAME store `th` reads, so the CLI + its SEP extensions are authed immediately.
- GET /api/auth/status — poll signed-in state.
- smooth-web: SmooSignIn.tsx pill (sign-in / "Signed in as …") in App.tsx.

Credential-store API matched main's usage exactly. Security properties
preserved (single-use state, PKCE, exact redirect_uri match). Open risk noted
in-module: smoo.ai/cli-login may only allowlist localhost redirect_uris, so the
tailnet :8443 host may need an allowlist entry / device-code fallback (test on
first real click).

Tests: 23 pass (daemon). cargo build + web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USBgWKcgpkWAugmHPE7Bc9
Rewrite the README as a funnel: hook → install → the th toolkit (search /
knowledge / agent / crawl-preview + ext/pearls/worktree/api) → how Big Smooth
works (chat-first daemon on the smooth-operator engine; safety = auto-mode +
Narc LLM-judge, no microVM) → orchestration superpower (th msg + th pearls/Dolt)
→ CTA. Adds an "It scales past your laptop" band up top (cloud scale = hosted
org agents + gateway; cloud marketplace = th ext). Run cmd = `th daemon run`.
Capabilities described generically (no product/model names) except the
multi-agent orchestration section; honest (crawl = preview).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USBgWKcgpkWAugmHPE7Bc9
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fcd9ee7

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager enabled auto-merge July 22, 2026 23:02
@brentrager
brentrager marked this pull request as draft July 22, 2026 23:20
auto-merge was automatically disabled July 22, 2026 23:20

Pull request was converted to draft

@brentrager

Copy link
Copy Markdown
Contributor Author

⚠️ Drafting this: merge against main hits 38 conflicted files across smooth-cli/smooth-code/smooth-daemon/smooth-tools/smooth-web — main advanced a week in parallel. The branch is safely pushed; reconciliation needs a dedicated session (suggest merging main in crate-by-crate, or landing the epic in slices: th ext CLI → daemon PWA → OAuth login → push).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant