diff --git a/.augment/rules/00-overview.md b/.augment/rules/00-overview.md index 92b04375..a9df9b74 100644 --- a/.augment/rules/00-overview.md +++ b/.augment/rules/00-overview.md @@ -21,6 +21,7 @@ Mitto is a CLI client for the Agent Client Protocol (ACP). It enables terminal-b | [websockets/](../docs/devel/websockets/) | **WebSocket protocol** (message types, seq numbers, sync, reconnection, delivery verification) | | [workspaces.md](../docs/devel/workspaces.md) | Multi-workspace, persistence | | [follow-up-suggestions.md](../docs/devel/follow-up-suggestions.md) | Action buttons, auxiliary analysis, persistence | +| [mcp-tool-discovery.md](../docs/devel/mcp-tool-discovery.md) | Deterministic MCP tool discovery (stdio/http/sse) vs LLM-introspection fallback | ## Package Structure @@ -35,6 +36,7 @@ internal/client/ → Go client for Mitto REST API + WebSocket (used in test internal/config/ → Configuration loading (YAML/JSON) internal/conversion/ → Markdown-to-HTML conversion, file link detection internal/defense/ → Scanner defense, blocklist, IP metrics (used by web middleware) +internal/mcpdiscovery/→ Deterministic MCP tool discovery: connects to configured servers (stdio/http/sse) via modelcontextprotocol/go-sdk client; no internal/config or internal/web imports internal/mcpserver/ → MCP servers (global debug + per-session) internal/processors/ → Command processors (pre/post processing via external commands) internal/runner/ → Restricted runner, sandbox execution (go-restricted-runner) diff --git a/.augment/rules/01-go-conventions.md b/.augment/rules/01-go-conventions.md index 58ab1ac2..9fb09682 100644 --- a/.augment/rules/01-go-conventions.md +++ b/.augment/rules/01-go-conventions.md @@ -137,4 +137,4 @@ if strings.Contains(err.Error(), "session not started") { ## JSON Marshaling - **Nil vs empty slices**: `json.Marshal` encodes nil as `null`, empty as `[]`. ACP rejects `null` where array is required. Always initialize: `MCPServers: []MCPServer{}`. Mark with `// Must be empty array, not nil — ACP validates this`. -- **`omitempty` on bool**: Never use `omitempty` on `bool` fields where `false` is meaningful — Go omits `false` as zero value. Example: `PeriodicEnabled bool` must NOT have `omitempty`. +- **`omitempty` on bool**: Never use `omitempty` on `bool` fields where `false` is meaningful — Go omits `false` as zero value. Example: `LoopEnabled bool` must NOT have `omitempty`. diff --git a/.augment/rules/02-session.md b/.augment/rules/02-session.md index 1db8ac17..cf3c8d07 100644 --- a/.augment/rules/02-session.md +++ b/.augment/rules/02-session.md @@ -30,7 +30,7 @@ keywords: | `Lock` | Session locking, heartbeat, cleanup | Yes (mutex + goroutine) | | `Queue` | Message queue for busy agent | Yes (mutex) | | `ActionButtonsStore` | Follow-up suggestions persistence | Yes (mutex) | -| `PeriodicStore` | Periodic prompt config per session | Yes (mutex) | +| `LoopStore` | Loop prompt config per session | Yes (mutex) | | `Flags` | Available feature flags registry | N/A (read-only) | ## Immediate Persistence (Web Interface) @@ -117,9 +117,9 @@ lock.SetWaitingPermission("File write") // During permission request **Important**: Queue configuration is **global/workspace-scoped**, NOT per-session. See [docs/devel/message-queue.md](../docs/devel/message-queue.md) for config options, REST API, WebSocket notifications, and title auto-generation. -## Periodic Prompts (PeriodicStore) +## Loop Prompts (LoopStore) -Stored in `periodic.json`. Only top-level sessions may have periodic prompts (child → 400). +Stored in `loop.json`. Only top-level sessions may have loop prompts (child → 400). **Key fields**: - `PromptName` — references workspace prompt by name (resolved at send time via cache) @@ -128,7 +128,7 @@ Stored in `periodic.json`. Only top-level sessions may have periodic prompts (ch - `DelaySeconds` — wait after agent idle before firing (onCompletion only) - `MaxDurationSeconds` — wall-clock cap since first run -**Critical**: Changing `PeriodicStore.Update()` signature requires updating BOTH `session_periodic_api.go` (PATCH handler) AND `mcpserver/server.go` (MCP tool) — both call `Update()`. +**Critical**: Changing `LoopStore.Update()` signature requires updating BOTH `session_loop_api.go` (PATCH handler) AND `mcpserver/server.go` (MCP tool) — both call `Update()`. ## Auxiliary Package diff --git a/.augment/rules/04-component-extraction.md b/.augment/rules/04-component-extraction.md index 6944e6f1..b7b90e65 100644 --- a/.augment/rules/04-component-extraction.md +++ b/.augment/rules/04-component-extraction.md @@ -94,3 +94,11 @@ Place in implementation file to catch interface breaking changes: // Verify component satisfies interface at compile time var _ conversation.SharedProcess = (*sharedSessionAnalyzer)(nil) ``` + +## Post-Extraction Cleanup + +After extracting a component, **remove unused delegators** from original file: +- **Scan before merge**: Run `golangci-lint run ./internal/conversation` to find unused methods +- **Remove both**: Delegator method + corresponding test if only that method used the test +- **Keep pattern**: Only delegators actively called by other packages or public API +- **Example**: If `ConfigManager.GetConfig()` is extracted and `BackgroundSession.GetConfig()` delegator is never called externally, remove both (unless it's part of exported `SessionManager` interface) diff --git a/.augment/rules/05-msghooks.md b/.augment/rules/05-msghooks.md index 0eb1d311..70dbe75c 100644 --- a/.augment/rules/05-msghooks.md +++ b/.augment/rules/05-msghooks.md @@ -46,7 +46,7 @@ when: # required block — BOTH on: and match: are required everyNTurns: 3 # fire every N responses; everyNTokens: 15000; afterInterval: 5m (all AND-logic) priority: 100 # lower = earlier enabled: true # false = never loads (build-time gate) -enabledWhen: 'acp.matchesServerType("augment") && !session.isPeriodic' # CEL runtime gate +enabledWhen: 'acp.matchesServerType("augment") && !session.isLoop' # CEL runtime gate onError: skip # skip | fail # Text-mode only (forbidden for agentResponded/agentIdle): @@ -130,7 +130,7 @@ Key CEL variables/functions (full reference in `docs/config/processors.md`): | Context | Examples | | ----------------------- | --------------------------------------------------------------------------- | | `acp.*` | `acp.matchesServerType("augment")`, `acp.name`, `acp.type`, `acp.tags` | -| `session.*` | `session.isPeriodic`, `session.isChild`, `session.id` | +| `session.*` | `session.isLoop`, `session.isChild`, `session.id` | | `Session.ModelTags` | `Session.HasModelTag("smart")`, `"smart" in Session.ModelTags` — current model's tags from `models:` profiles (template: `{{ if Model "smart" }}`); empty when model unknown | | `workspace.*` | `workspace.hasUserDataSchema`, `workspace.hasMittoRC`, `workspace.hasMetadataDescription`, `workspace.folder` | | `children.*` | `children.exists`, `children.count`, `children.mcp_count`, `children.promptingCount`, `children.idleCount` | diff --git a/.augment/rules/07-prompts.md b/.augment/rules/07-prompts.md index af7d18f3..8b1618bc 100644 --- a/.augment/rules/07-prompts.md +++ b/.augment/rules/07-prompts.md @@ -117,7 +117,7 @@ a plain conversation with no pre-selected issue. 4. Gate **every** `bd` command and id-specific `git grep` behind `{{ if $target }} … {{ end }}` — mode 3 must emit **zero** `bd` calls. -**Exemplars**: `beads-issue-investigate`, `beads-issue-discuss`, +**Exemplars**: `beads-issue-investigate`, `beads-issue-assess`, `beads-issue-status`, `beads-issue-resolved`, `beads-issue-work`. **Guard tests**: `*ThreeModeTargetResolution` tests + `TestBuiltinPrompts_NoDeprecatedMittoVars` @@ -127,13 +127,13 @@ Full recipe: [docs/config/prompts.md § Context-adaptive prompts (three modes)]( ## Key Types -`WebPrompt`: Name, Prompt, Description, Group, BackgroundColor, Icon, Source ("builtin"|"file"|"settings"|"workspace"), Enabled (*bool: nil=enabled, false=disabled), EnabledWhen (CEL, server-side only), Periodic (non-nil = periodic conversation), Singleton (bool: `true` = no concurrent conversation instances for this prompt in the same working dir; see below). +`WebPrompt`: Name, Prompt, Description, Group, BackgroundColor, Icon, Source ("builtin"|"file"|"settings"|"workspace"), Enabled (*bool: nil=enabled, false=disabled), EnabledWhen (CEL, server-side only), Loop (non-nil = loop conversation), Singleton (bool: `true` = no concurrent conversation instances for this prompt in the same working dir; see below). ### Singleton Prompts (find-or-route) A prompt with `singleton: true` must not have more than one non-archived conversation per working dir. A session records the prompt that created it in `session.Metadata.OriginPromptName` at create time (set on `POST /api/sessions` from `initial_prompt_name`/`origin_prompt_name`). When a singleton prompt is launched, `HandleCreateSession` (`internal/web/handlers/session_create.go`) scans existing non-archived sessions by `(WorkingDir, OriginPromptName)` under a keyed lock (`lockSingleton`); on a match it reuses that conversation instead of creating a new one — re-seeding the queue if idle, focus-only if busy — and responds with `reused: true`. The frontend threads `reused` through `useWebSocket.js` → `useConversationSeeding.js` and shows a "Reusing existing ..." toast instead of "Started ..." (`useBeadsIntegration.js`, `app.js`). Applied to the builtin beadsList maintenance prompts (overview, reevaluate, cleanup-stale, group-epics, status-all-inprogress) — deliberately **not** to "Start working on ready", since concurrent work-starting conversations are legitimate. -`PromptPeriodic` (YAML `periodic:`): `value`/`unit`/`at` (schedule period), `maxIterations`, plus the on-completion fields `trigger` (`schedule` default | `onCompletion`), `delay` (int seconds for onCompletion; clamped to the global floor), and `maxDuration` (duration string e.g. `4h`; wall-clock cap from the first run). `MaxIterations` caps scheduled runs; effective cap = min(prompt maxIterations, config default 100, hardcoded 1000). Backend auto-disables (not archives) when either the iteration cap or `maxDuration` is hit. +`PromptLoop` (YAML `loop:`): `value`/`unit`/`at` (schedule period), `maxIterations`, plus the on-completion fields `trigger` (`schedule` default | `onCompletion`), `delay` (int seconds for onCompletion; clamped to the global floor), and `maxDuration` (duration string e.g. `4h`; wall-clock cap from the first run). `MaxIterations` caps scheduled runs; effective cap = min(prompt maxIterations, config default 100, hardcoded 1000). Backend auto-disables (not archives) when either the iteration cap or `maxDuration` is hit. ## Merging & Caching @@ -145,7 +145,7 @@ A prompt with `singleton: true` must not have more than one non-archived convers ## Menu-Driven Prompt Sends (Named-Prompt Mechanism) -All menus (prompts, beadsIssues, beadsList) send `prompt_name` only — never the full body. Frontend helpers in `useConversationSeeding.js`: `seedConversationWithPrompt()` (existing session), `startConversationWithPrompt()` (new ± periodic), `makePeriodicNow()` (convert to periodic). Backend resolves name at dispatch via `resolvePromptByName()` in target workspace context; the body is then **Go-template rendered** (if it contains `{{`) before `${VAR}` substitution. **Anti-pattern**: never POST resolved text to `/api/sessions/{id}/queue` — send `prompt_name` instead. +All menus (prompts, beadsIssues, beadsList) send `prompt_name` only — never the full body. Frontend helpers in `useConversationSeeding.js`: `seedConversationWithPrompt()` (existing session), `startConversationWithPrompt()` (new ± loop), `makeLoopNow()` (convert to loop). Backend resolves name at dispatch via `resolvePromptByName()` in target workspace context; the body is then **Go-template rendered** (if it contains `{{`) before `${VAR}` substitution. **Anti-pattern**: never POST resolved text to `/api/sessions/{id}/queue` — send `prompt_name` instead. ## MCP Prompt Tools @@ -217,11 +217,11 @@ parameters: ## Iteration.IsUninterrupted (mitto-5xjn) -`{{ .Iteration.IsUninterrupted }}` is `true` only on a **scheduled** (non-forced, non-FreshContext) periodic run that directly follows another such run with nothing in between — no user interjection, no forced "run now", no FreshContext, same process lifetime. +`{{ .Iteration.IsUninterrupted }}` is `true` only on a **scheduled** (non-forced, non-FreshContext) loop run that directly follows another such run with nothing in between — no user interjection, no forced "run now", no FreshContext, same process lifetime. **Reset boundaries** (set marker to false): - Archive/unarchive, GC suspend/resume, process restart - ACP process reinit/restart -- Periodic loop config change / pause / re-enable +- Loop loop config change / pause / re-enable **Authoring rule**: compact "continue" branch must carry durable re-anchor (one-line goal + file/bead ref). Always render verbose form when `IsFirst || !IsUninterrupted` to reset context after interruptions. diff --git a/.augment/rules/08-config.md b/.augment/rules/08-config.md index 34fabc96..4dd2610b 100644 --- a/.augment/rules/08-config.md +++ b/.augment/rules/08-config.md @@ -21,6 +21,8 @@ keywords: - folder deduplication - LoadFolders - SaveFolders + - ShortcutButton + - global shortcuts --- # Configuration System @@ -117,6 +119,18 @@ Prompt `preferredModels` field (see `07-prompts.md`) references these profiles b Agent metadata can pre-seed these at discovery: `metadata.yaml` `defaults.constraints` (plus `defaults.env`/`tags`/`autoApprove`) map onto `ACPServer.Constraints`/`Env`/`Tags`/`AutoApprove` via `seedACPServerDefaults` (see [03-cli-acp.md](03-cli-acp.md#agent-defaults-seeded-at-discovery)). Seeding is request-wins (user-supplied values are not overwritten). +## Global Shortcuts + +Global shortcut buttons (`shortcuts:` in `settings.json`) mirror per-folder shortcuts (`folders.json`) and are merged with them **at render time** on the frontend — the backend stores and serves each level independently: + +- Type: `config.ShortcutButton{Icon, Prompt}` (`internal/config/folders.go`) — `Prompt` is a workspace prompt name, `Icon` an optional `PROMPT_ICONS` key. +- Sections (map key in both global and folder JSON): `conversations`, `beadsIssue`, `tasksList`. +- API: `GET/PUT /api/global/shortcuts` (`internal/web/handlers/global_shortcuts.go`) mirrors the folder shortcuts endpoint; GET also returns the available `Prompts` so the editor needs only one request. +- `buildNewSettings` must preserve `shortcuts:` so an unrelated Settings save never wipes global shortcuts. +- Defaults for new installs are seeded in embedded `config/config.default.yaml` (`shortcuts:` key) — written to `settings.json` on first run only; existing installs are untouched. + +Frontend merge/dedupe logic (duplicated in `BeadsView.js` ×2 and `app.js`): global list first, then folder entries whose `prompt` isn't already in the global list. See `25-web-frontend-components.md` for the shared `ShortcutsEditor` UI component. + ## WorkspaceSettings Override Pattern `WorkspaceSettings.ACPCommandOverride`: set default from server map, then apply override. See `internal/config/merger.go` for `GenericMerger[T]`. diff --git a/.augment/rules/11-web-backend-sequences.md b/.augment/rules/11-web-backend-sequences.md index 7e797418..50dbed3d 100644 --- a/.augment/rules/11-web-backend-sequences.md +++ b/.augment/rules/11-web-backend-sequences.md @@ -62,7 +62,7 @@ When a tool call or thought arrives from ACP, force-flush the MarkdownBuffer: | Method | Behavior | When to Use | | ------------- | ------------------------------------ | ------------------------------------- | | `Flush()` | Force flush, ignores markdown state | Tool calls, thoughts, prompt complete | -| `SafeFlush()` | Only flush if not in table/list/code | Periodic/timeout flushes | +| `SafeFlush()` | Only flush if not in table/list/code | Loop/timeout flushes | ## Observer Patterns diff --git a/.augment/rules/15-web-backend-session-lifecycle.md b/.augment/rules/15-web-backend-session-lifecycle.md index 6d2dd9e6..c18d759b 100644 --- a/.augment/rules/15-web-backend-session-lifecycle.md +++ b/.augment/rules/15-web-backend-session-lifecycle.md @@ -5,7 +5,7 @@ globs: - "internal/web/session_manager.go" - "internal/web/background_session.go" - "internal/web/session_ws.go" - - "internal/web/session_periodic_api.go" + - "internal/web/session_loop_api.go" - "internal/web/acp_error_classification.go" - "internal/web/shared_acp_process.go" - "internal/web/acp_process_gc.go" @@ -25,7 +25,7 @@ keywords: - crash recovery - parent child - cascade delete - - periodic + - loop --- # Session Lifecycle Management @@ -46,15 +46,15 @@ keywords: **Critical**: Always check `meta.Archived` before calling `ResumeSession()` on WebSocket connect — never resume an archived session automatically. -## Session Suspension (GC Periodic Suspend) +## Session Suspension (GC Loop Suspend) -The GC suspends idle periodic sessions whose next prompt is far away, saving ACP resources. Sessions resume transparently when the user focuses them. +The GC suspends idle loop sessions whose next prompt is far away, saving ACP resources. Sessions resume transparently when the user focuses them. -- **Config**: `PeriodicSuspendThreshold` (default 30m) in `acp_process_gc.go`. Settings UI: `periodic_suspend_timeout` (`"disabled"`, `"15m"`, `"30m"`, `"1h"`, `"2h"`). -- **Eligibility**: Periodic session + next prompt > threshold from now. Applies even if user has it open (resumes instantly). -- **Grace window**: `PeriodicSuspendGracePeriod` (default 10m) — a session is NOT suspended while its most recent turn completion (`SessionInfo.LastResponseCompleteAt`) or activity (`LastActivityAt`) is within this window. Prevents reclaiming a conversation that just ended a turn and may continue. Use `LastResponseCompleteAt` (turn END) as the signal — `LastActivityAt` is set at prompt START and is stale after long tasks. GC always skips actively-prompting sessions first (`IsPrompting`), so this only matters once the turn ends. +- **Config**: `LoopSuspendThreshold` (default 30m) in `acp_process_gc.go`. Settings UI: `loop_suspend_timeout` (`"disabled"`, `"15m"`, `"30m"`, `"1h"`, `"2h"`). +- **Eligibility**: Loop session + next prompt > threshold from now. Applies even if user has it open (resumes instantly). +- **Grace window**: `LoopSuspendGracePeriod` (default 10m) — a session is NOT suspended while its most recent turn completion (`SessionInfo.LastResponseCompleteAt`) or activity (`LastActivityAt`) is within this window. Prevents reclaiming a conversation that just ended a turn and may continue. Use `LastResponseCompleteAt` (turn END) as the signal — `LastActivityAt` is set at prompt START and is stale after long tasks. GC always skips actively-prompting sessions first (`IsPrompting`), so this only matters once the turn ends. - **Tracking**: `ACPProcessManager.gcSuspendedSessions` map. `SetGCSuspended()` / `IsGCSuspended()` / `ClearGCSuspended()`. -- **Resume**: `ensure_resumed` WebSocket message (sent on user focus) → `handleEnsureResumed()` in `session_ws.go`. Also clears GC-suspended flag on any explicit resume (periodic runner, prompt send). +- **Resume**: `ensure_resumed` WebSocket message (sent on user focus) → `handleEnsureResumed()` in `session_ws.go`. Also clears GC-suspended flag on any explicit resume (loop runner, prompt send). - **UI**: Suspended sessions show a friendly "Session suspended" balloon (not error), yellow dot in sidebar tooltip. ## Staggered Session Resumption @@ -75,7 +75,7 @@ Broadcast in `session_archived` WebSocket message as `archive_reason` field. ## Auto-Archive -Config: `session.auto_archive_inactive_after: "1w"` (in `checkAutoArchive()`). Excluded: already-archived, child sessions, sessions with periodic prompts (enabled or paused). +Config: `session.auto_archive_inactive_after: "1w"` (in `checkAutoArchive()`). Excluded: already-archived, child sessions, sessions with loop prompts (enabled or paused). ## ACP Process Crash Recovery @@ -123,23 +123,23 @@ Send `session_gone` (NOT generic error — clients stop reconnecting on `session | ---- | ---------- | ------ | | **1** | Children (`ParentSessionID != ""`) cannot be directly archived — HTTP 400 | `session_api.go`, `mcpserver/server.go` | | **2** | Archiving a parent **cascade-deletes** all children permanently (`store.Delete`, not archive) | `go sm.DeleteChildSessions(parentID)` | -| **3** | Children cannot be made periodic | `session_periodic_api.go`, `mcpserver/server.go` | +| **3** | Children cannot be made loop | `session_loop_api.go`, `mcpserver/server.go` | `DeleteChildSessions`: lists children → gracefully stops each (30s timeout) → `store.Delete` → broadcasts `session_deleted`. -**Anti-patterns**: Never archive a child directly. Never allow periodic config on a child. +**Anti-patterns**: Never archive a child directly. Never allow loop config on a child. -## Periodic Prompt Name Resolution +## Loop Prompt Name Resolution `PromptName` field selects a named workspace prompt instead of inline text. Resolved at send time via `PromptResolverFunc`. Either `Prompt` or `PromptName` must be set. -### Title Generation from Periodic Prompts +### Title Generation from Loop Prompts -`TriggerTitleGenerationFromPeriodic()` in `BackgroundSession` generates session titles from periodic prompts, skipping the "(pending)" placeholder. Named prompts are resolved to full text before title generation. This applies on first run and on subsequent runs when the prompt changes. +`TriggerTitleGenerationFromLoop()` in `BackgroundSession` generates session titles from loop prompts, skipping the "(pending)" placeholder. Named prompts are resolved to full text before title generation. This applies on first run and on subsequent runs when the prompt changes. ### Auto-Pause on Prompt Resolve Failures -Periodic runner (`PeriodicRunner.maybeRunPrompt()`) auto-pauses the periodic session after `MaxPromptResolveFailures` (default 5) consecutive resolution errors. This prevents endless retry loops when a prompt cannot be resolved (e.g., missing variable, invalid prompt name). The session can be manually resumed via UI. +Loop runner (`LoopRunner.maybeRunPrompt()`) auto-pauses the loop session after `MaxPromptResolveFailures` (default 5) consecutive resolution errors. This prevents endless retry loops when a prompt cannot be resolved (e.g., missing variable, invalid prompt name). The session can be manually resumed via UI. ## Auto-Resume Guard (Race Condition) diff --git a/.augment/rules/21-web-frontend-state.md b/.augment/rules/21-web-frontend-state.md index 161443e7..c7836314 100644 --- a/.augment/rules/21-web-frontend-state.md +++ b/.augment/rules/21-web-frontend-state.md @@ -78,7 +78,7 @@ useLayoutEffect(() => { ## Adding New Session Properties (Checklist) -When adding a new field to session state (e.g., `periodic_enabled`), **three places** in the frontend must all be updated or the value will be silently dropped: +When adding a new field to session state (e.g., `loop_enabled`), **three places** in the frontend must all be updated or the value will be silently dropped: | File | Location | What to add | |------|----------|-------------| @@ -86,7 +86,7 @@ When adding a new field to session state (e.g., `periodic_enabled`), **three pla | `useWebSocket.js` | Fingerprint string (~line 1090) | `\|${s.field}` so changes trigger re-renders | | `lib.js` | `computeAllSessions` "no stored session" case (~line 444) | Field with default value | -**Anti-pattern**: WebSocket handler (`periodic_updated`) sets `sessions[id].info.periodic_enabled` correctly, but if `activeSessions` useMemo doesn't forward it, the property is lost when `computeAllSessions` runs. +**Anti-pattern**: WebSocket handler (`loop_updated`) sets `sessions[id].info.loop_enabled` correctly, but if `activeSessions` useMemo doesn't forward it, the property is lost when `computeAllSessions` runs. ## Settings Dialog Patterns @@ -94,7 +94,7 @@ When saving settings that affect external state, update local state immediately ## Per-Tab Active Conversation State -Each filter tab (Conversations, Periodic, Archived) remembers its own last-focused conversation. Storage helpers: `getLastActiveSessionIdForTab(tab)` / `setLastActiveSessionIdForTab(tab, id)` in `storage.js`. +Each filter tab (Conversations, Loop, Archived) remembers its own last-focused conversation. Storage helpers: `getLastActiveSessionIdForTab(tab)` / `setLastActiveSessionIdForTab(tab, id)` in `storage.js`. **Recording**: In `App` effect, compute tab via `getFilterTabForSession()`, record with guard ref to avoid redundant writes during streaming. diff --git a/.augment/rules/25-web-frontend-components.md b/.augment/rules/25-web-frontend-components.md index 26cf1deb..88c89821 100644 --- a/.augment/rules/25-web-frontend-components.md +++ b/.augment/rules/25-web-frontend-components.md @@ -31,6 +31,8 @@ keywords: - radio tabs - WorkspacesDialog - daisyUI tabs + - ShortcutsEditor + - global shortcuts --- # Frontend Components and Hooks @@ -48,6 +50,8 @@ All components use Preact/HTM with window globals: `const { useState, useEffect, | `SessionPanel` | Unified overlay (Changes + Properties tabs) | | `ContextMenu` | Right-click menu with viewport-aware position | | `SessionItem` | List item with swipe, menu, status | +| `Toolbar` | Config-driven action bar (see below) | +| `ShortcutsEditor` | Global+folder shortcut button config panel | ## ChatInput @@ -67,32 +71,26 @@ Resizable via `useResizeHandle` (initialHeight: `getQueueDropdownHeight()`, min: ## Tooltip Patterns -### PortalTooltip (Viewport-Clamped) - -For overflow-clipped rows (e.g., SessionList), render tooltips in a body-level portal to escape clip bounds: - +**PortalTooltip** (`SessionItem.js`): for overflow-clipped rows, renders via `createPortal()` to escape clip bounds, auto-clamps position to viewport (e.g. "top" → "bottom" near the edge), adds a `.tooltip-blur` background. ```javascript -html`<${PortalTooltip} text=${"Long text..."} position=${"top"} > -
Clipped row
-` +html`<${PortalTooltip} text=${"Long text..."} position=${"top"}>
Row
` ``` -**Features**: -- Escapes overflow:hidden containers via `createPortal()` -- Auto-clamps position to viewport (e.g., "top" → "bottom" if near top edge) -- Applies background blur behind tooltip (`.tooltip-blur`) -- Used in `SessionItem.js` for session titles/paths +**daisyUI tooltip** (non-clipped content): `
` -### daisyUI Tooltip +## Reusable Config-Driven Components -For non-clipped content (in-component tooltips), use daisyUI `tooltip`: +**Toolbar** (`Toolbar.js`) — portable action bar rendered as a segmented pill from an `items` array. Item kinds: `button`, `dropdown`, `overflow`, `separator`, `spacer`, `custom`. Props: `variant` (`"floating"` | `"block"`), `surface`, `ariaLabel`, `testId`. ```javascript -html`
- -
` +html`<${Toolbar} variant="block" surface="bg-mitto-surface-3" + ariaLabel="Issue actions" testId="beads-issue-toolbar" items=${headerToolbarItems} />` ``` +Used in `BeadsView.js` list actions and issue-detail header — prefer over ad-hoc "..." kebab menus. + +**ShortcutsEditor** (`ShortcutsEditor.js`) — one panel reused for both **global** (Settings dialog) and **folder** (Workspaces dialog) shortcut config. Consumers (conversations/beadsIssue/tasksList toolbars) merge global + folder shortcuts at render: global first, folder duplicates of a global `prompt` dropped; leftover duplicates render greyed-out via `redundantPromptNames`. Backend mirror: `GET/PUT /api/global/shortcuts` (`internal/web/handlers/global_shortcuts.go`); type `config.ShortcutButton{Icon, Prompt}`. Refresh via `mitto:global_shortcuts_updated`/`mitto:folder_shortcuts_updated` window events. See `08-config.md` for backend details. + ## Icons Naming: `[Name]Icon` (e.g., `TrashIcon`, `QueueIcon`). Always `CloseIcon` SVG, never `✕`. Sizes: `w-4 h-4` (toasts), `w-5 h-5` (dialogs). @@ -146,4 +144,4 @@ html`
## Session List Tab Filtering -Filters by tab (Conversations, Periodic, Archived) via `getFilterTabForSession()`. On click, restore last-focused session via `getLastActiveSessionIdForTab()` — **user-clicks only**, not programmatic. Guard races with refs to avoid redundant localStorage updates. +Filters by tab (Conversations, Loop, Archived) via `getFilterTabForSession()`. On click, restore last-focused session via `getLastActiveSessionIdForTab()` — **user-clicks only**, not programmatic. Guard races with refs to avoid redundant localStorage updates. diff --git a/.augment/rules/30-testing.md b/.augment/rules/30-testing.md index 8dee2b77..b896a1cf 100644 --- a/.augment/rules/30-testing.md +++ b/.augment/rules/30-testing.md @@ -66,73 +66,41 @@ func TestSomething(t *testing.T) { ## Integration Tests -### Mock ACP Server +**Mock ACP**: `make build-mock-acp` always before integration tests. Scenario matching via regex in `tests/fixtures/responses/*.json`. -```bash -make build-mock-acp # Always rebuild after changes! -``` - -**Structure**: `main.go` (entry point, stdin/stdout loop), `types.go` (protocol types), `handler.go` (request handlers), `sender.go` (thread-safe sending). - -**Scenario matching**: Prompt text matched against regex patterns in `tests/fixtures/responses/*.json`. If images detected, mock responds with acknowledgment before checking scenarios. - -### In-Process Test Setup - -```go -func SetupTestServer(t *testing.T) *TestServer { - t.Helper() - tmpDir := t.TempDir() - t.Setenv(appdir.MittoDirEnv, tmpDir) - appdir.ResetCache() - t.Cleanup(appdir.ResetCache) - - srv, _ := web.NewServer(web.Config{ - ACPCommand: findMockACPServer(t), - ACPServer: "mock-acp", - DefaultWorkingDir: filepath.Join(tmpDir, "workspace"), - AutoApprove: true, - }) - httpServer := httptest.NewServer(srv.Handler()) - t.Cleanup(httpServer.Close) - return &TestServer{Server: srv, HTTPServer: httpServer, Client: client.New(httpServer.URL)} -} -``` +**Setup**: `SetupTestServer(t)` in `internal/client/test_helpers.go` — isolates temp dir + resets appdir cache. -### Running Integration Tests - -```bash -go test -tags integration -v ./tests/integration/inprocess -go test -tags integration -coverprofile=coverage.out \ - -coverpkg=./internal/web/...,./internal/client/... \ - ./tests/integration/inprocess -``` +**Run**: `go test -tags integration ./tests/integration/inprocess` ## JavaScript Tests -- Browser globals (`window.marked`, `window.DOMPurify`): check `typeof window === "undefined"` and return `null` as graceful fallback — makes code testable in Node.js -- `localStorage`: mock with a plain object implementing `getItem/setItem/removeItem/clear`, assigned via `Object.defineProperty(global, "localStorage", { value: mock })` - -## Text Processing Testing Strategy +Mock browser globals (`window.marked`, `window.DOMPurify`, `localStorage`) for Node.js testability. -Use `contains`/`excludes` fields in table-driven tests for HTML output assertions (see `internal/conversion/*_test.go` for examples). +## Smoke Tests -## Smoke Tests (Docker / Linux) +Docker-based verification in pristine Linux. Cross-compiled binaries in `tests/smoke/.build/`. Health check: `GET /mitto/api/health`. -Smoke tests verify Mitto works in a pristine Linux environment using Docker + cross-compiled binaries. +## Timeout Testing Anti-Pattern: Shell Command Subprocess Escapes -**Location**: `tests/smoke/` — Dockerfile, entrypoint.sh, smoke-test.sh, docker-compose.yml, run.sh +**Problem**: `exec.CommandContext(ctx, "sh", "-c", "sleep 5")` does NOT kill the child process on context timeout. +- The shell (`sh`) is spawned in a new process group +- When context deadline expires, only the shell is killed +- The original child process (`sleep`) continues running in the background +- Result: `cmd.Run()` unblocks from shell exit, but the actual work process is orphaned -**Key architecture**: -- Mitto binds to `0.0.0.0:8089` via `mitto web --host 0.0.0.0 --port 8089` -- Docker maps host `8089 → container 8089` directly (no socat needed) -- Cross-compiled binaries are staged in `tests/smoke/.build/` (gitignored) -- `MITTO_DIR` env var controls Mitto's data directory inside the container - -**entrypoint.sh** writes `settings.json` + `workspaces.json`, then `exec mitto web --host 0.0.0.0` - -**Health check**: `GET /mitto/api/health` → `{"status":"healthy",...}` +**Fix**: For commands that spawn subprocesses, use process group killing: +```go +// WRONG: Child process escapes the timeout +cmd := exec.CommandContext(ctx, "sh", "-c", command) +err := cmd.Run() // Returns quickly but sleep 5 still running + +// RIGHT: Kill entire process group on timeout +cmd := exec.CommandContext(ctx, "sh", "-c", command) +cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +err := cmd.Run() // Timeout kills shell + all children +``` -**Full Playwright smoke run** uses `MITTO_EXTERNAL_SERVER=1` and `MITTO_TEST_URL=http://localhost:8089` +**See**: `internal/hooks/hooks.go` StartUp() line 132 for correct pattern. RunDown() must be updated similarly. ## Lessons Learned @@ -140,3 +108,6 @@ Smoke tests verify Mitto works in a pristine Linux environment using Docker + cr - Test edge cases and negative cases, not just happy paths - Auth page assets must be in `publicStaticPaths` (symptom: unstyled login page with MIME error) - CDN resources may be blocked by tracking prevention (Firefox, Safari) +- Timeout enforcement via context requires process group setup or subprocess escapes +- Verify a previous turn's edits actually persisted (`git status`/`git diff`) before continuing — apparent changes can be lost across session gaps/restarts +- When new test failures appear after a frontend/htm change, `git stash` and re-run the same tests against the base branch first — this distinguishes real regressions from pre-existing flakiness (e.g. test-isolation/state-leak failures) before spending time debugging your own code diff --git a/.augment/rules/31-periodic-prompts.md b/.augment/rules/31-loop-prompts.md similarity index 69% rename from .augment/rules/31-periodic-prompts.md rename to .augment/rules/31-loop-prompts.md index 65b55106..ae1540a9 100644 --- a/.augment/rules/31-periodic-prompts.md +++ b/.augment/rules/31-loop-prompts.md @@ -1,46 +1,46 @@ --- -description: Periodic prompt design patterns, silent mode, spawn deduplication, gate testing +description: Loop prompt design patterns, silent mode, spawn deduplication, gate testing globs: - "internal/config/prompts*.go" - "internal/web/handlers/session_*.go" keywords: - - periodic + - loop - silent-mode - - IsPeriodic - - IsPeriodicForced + - IsLoop + - IsLoopForced - spawn-deduplication - Children - MCPText - gate-testing --- -# Periodic Prompt Design Patterns +# Loop Prompt Design Patterns ## Silent Mode vs Interactive Mode -Periodic prompts must detect runtime context and adapt behavior: +Loop prompts must detect runtime context and adapt behavior: ```go -{{ if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} +{{ if and .Session.IsLoop (not .Session.IsLoopForced) }} // Silent mode: scheduled run, user not watching // Use mitto_ui_notify ONLY (non-blocking) // Do NOT use interactive tools: options, form, textbox // Act autonomously when safe; notify on failures {{ else }} - // Interactive mode: forced run or non-periodic conversation + // Interactive mode: forced run or non-loop conversation // May use all UI tools freely for confirmations {{ end }} ``` **Key fields**: -- `.Session.IsPeriodic` — true if conversation has periodic config enabled -- `.Session.IsPeriodicForced` — true if user force-triggered the run (via `mitto_conversation_run_periodic_now_mitto`) +- `.Session.IsLoop` — true if conversation has loop config enabled +- `.Session.IsLoopForced` — true if user force-triggered the run (via `mitto_conversation_run_loop_now_mitto`) **Pattern**: Silent mode never blocks the user; interactive mode can present dialogs, options, textboxes for user input. ## Spawn Deduplication -When a periodic prompt spawns child conversations for multi-step repairs, always check for existing children **before** spawning: +When a loop prompt spawns child conversations for multi-step repairs, always check for existing children **before** spawning: ```go Existing child conversations: @@ -54,7 +54,7 @@ If found and still idle, RE-PROMPT it instead of spawning a duplicate. - `.Children.MCPText` — list of non-archived child conversations (from `mitto_children_tasks_wait_mitto` context) - Search child titles for a substring match (e.g., "PR #66" in "Fix CI for PR #66") -**Spawn cap**: Limit to **3 spawns per periodic run**. Prioritize by severity: +**Spawn cap**: Limit to **3 spawns per loop run**. Prioritize by severity: 1. Rebase conflicts (blocks merge) 2. CI failures (blocks merge) 3. Unresolved review comments (informational) @@ -66,7 +66,7 @@ If found and still idle, RE-PROMPT it instead of spawning a duplicate. ## Gate Testing Before External Actions -When a periodic prompt identifies CI failures and spawns a fixer conversation, instruct the fixer to **run the full local gate suite BEFORE pushing**: +When a loop prompt identifies CI failures and spawns a fixer conversation, instruct the fixer to **run the full local gate suite BEFORE pushing**: ```yaml Before pushing, run ALL gates in order: @@ -78,7 +78,7 @@ Before pushing, run ALL gates in order: Push only after ALL gates pass locally. ``` -**Why**: Periodic automation that reveals CI failures incrementally (fix one, reveal the next) creates unnecessary re-runs. Full local validation before push breaks this cycle. +**Why**: Loop automation that reveals CI failures incrementally (fix one, reveal the next) creates unnecessary re-runs. Full local validation before push breaks this cycle. **Common gates in mitto**: - `make fmt-check` — Go format check (gofmt) @@ -107,7 +107,7 @@ mitto_ui_notify_mitto( ## State Persistence -For long-running periodic prompts that track external state (CI status, branch status, etc.): +For long-running loop prompts that track external state (CI status, branch status, etc.): - Store state in a **file in the workspace** (`.mitto/state/` convention) - Reference state file path in compact continuation messages - Use `.Iteration.IsUninterrupted` to detect continuation vs restart diff --git a/.augment/rules/40-debugging.md b/.augment/rules/40-debugging.md index 2002128c..c76b9155 100644 --- a/.augment/rules/40-debugging.md +++ b/.augment/rules/40-debugging.md @@ -61,6 +61,8 @@ All logs in `~/Library/Logs/Mitto/` (macOS): | `client_id=` | WebSocket client events | | `seq=` | Sequence numbers (DEBUG) | +> ⚠️ **Logfmt field-order assumption**: Don't assume relative field order when regex-matching logfmt lines (e.g. expecting `session_id=` before `msg=`). Actual order varies by call site and can cause false-negative counts (e.g. an `acp_sdk_event_received` count reading 0 when events did stream). Match fields independently, not by position. + ### Quick Commands > ⚠️ **Self-referential matches**: When grep runs inside a live Mitto session, the log captures the prompt/agent messages containing the grep query strings, causing false matches. Use `grep -E 'level=(ERROR|WARN)'` (exact field syntax) instead of `-i 'error|warn'` to avoid this. @@ -83,10 +85,8 @@ grep -v '127\.0\.0\.1\|::1' ~/Library/Logs/Mitto/access.log # non-localhost ### Processor Log Patterns ```bash -grep 'processor pipeline starting\|processor pipeline complete' ~/Library/Logs/Mitto/mitto.log | tail -20 -grep 'applying processor\|processor applied\|processor executed' ~/Library/Logs/Mitto/mitto.log | tail -30 -grep 'processor skipped\|processor rerun triggered' ~/Library/Logs/Mitto/mitto.log | tail -20 -grep 'processor execution failed\|processor returned error\|processor failed' ~/Library/Logs/Mitto/mitto.log +grep 'processor pipeline starting\|processor pipeline complete\|applying processor\|processor applied\|processor executed' ~/Library/Logs/Mitto/mitto.log | tail -30 +grep 'processor skipped\|processor rerun triggered\|processor execution failed\|processor returned error\|processor failed' ~/Library/Logs/Mitto/mitto.log ``` ### Anomaly Detection Patterns diff --git a/.augment/rules/42-mcpserver-development.md b/.augment/rules/42-mcpserver-development.md index c409104e..0035d37b 100644 --- a/.augment/rules/42-mcpserver-development.md +++ b/.augment/rules/42-mcpserver-development.md @@ -24,7 +24,7 @@ The Mitto MCP server (`internal/mcpserver/`) provides a **single global server** Single global MCP server at `http://127.0.0.1:5757/mcp`. Two tool classes: - **Global tools** (no session): `mitto_conversation_list`, `mitto_get_config`, `mitto_get_runtime_info` -- **Session-scoped tools** (require `self_id`): UI prompts, conversation control, history, prompt management (`mitto_prompt_list/get/update`), periodic control (`mitto_conversation_set_periodic`, `mitto_conversation_run_periodic_now`) +- **Session-scoped tools** (require `self_id`): UI prompts, conversation control, history, prompt management (`mitto_prompt_list/get/update`), loop control (`mitto_conversation_set_loop`, `mitto_conversation_run_loop_now`) ## Adding New Tools @@ -96,16 +96,16 @@ if callerMeta.WorkingDir != targetWS.WorkingDir { ## Optional Late-Bound Dependencies -Some dependencies (e.g. `PeriodicRunner`) are initialized after the MCP server and wired in via setter methods rather than through `Dependencies`: +Some dependencies (e.g. `LoopRunner`) are initialized after the MCP server and wired in via setter methods rather than through `Dependencies`: ```go -// In internal/web/server.go — after s.periodicRunner.Start(): +// In internal/web/server.go — after s.loopRunner.Start(): if s.mcpServer != nil { - s.mcpServer.SetPeriodicRunner(s.periodicRunner) + s.mcpServer.SetLoopRunner(s.loopRunner) } ``` -The `PeriodicRunner` interface (defined in `mcpserver/server.go`) is satisfied by `*web.PeriodicRunner`. Use setter methods (not `Dependencies`) when a dependency must exist before `NewServer()` completes but the dependency itself starts later. +The `LoopRunner` interface (defined in `mcpserver/server.go`) is satisfied by `*web.LoopRunner`. Use setter methods (not `Dependencies`) when a dependency must exist before `NewServer()` completes but the dependency itself starts later. ## Processor Auxiliary Session MCP Access @@ -137,3 +137,13 @@ if agent.HasCommand(agents.CommandMCPList) { ``` API endpoint: `GET /api/workspace-mcp-tools?acp_server=NAME&dir=PATH` (handler in `config_handlers.go`). + +**`mcp-list.sh` audit (mitto-sys.11)** — scripts are often copy-pasted from the claude-code template but never repointed at the target agent's real config path/key/shape, so `ListMCPServers` silently returns empty. Verify against actual docs/source when adding or fixing one: + +| Agent | Status | Notes | +|---|---|---| +| cursor, goose | OK | `~/.cursor/mcp.json`/`mcpServers`; `~/.config/goose/config.yaml`/`extensions` | +| opencode | BROKEN (mitto-sys.13) | wrong path/key (`mcp` not `mcpServers`), command-as-array, `environment` not `env` | +| github-copilot | BROKEN (mitto-sys.14) | wrong path: real is `~/.copilot/mcp-config.json` | +| qwen-code | BROKEN (mitto-sys.15) | wrong path: real is `~/.qwen` | +| junie | stub (mitto-sys.10) | always returns `{"servers": []}` | diff --git a/AGENTS.md b/AGENTS.md index 9390d72d..f5824be3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,3 +82,14 @@ bd close # Complete work - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds + + +## User Preferences + +- **Autonomous action in loop mode**: In scheduled loop runs, act autonomously on safe, routine actions (spawning fix conversations, clean rebases) without asking. Use `mitto_ui_notify` for communication only. Never use interactive/blocking UI tools. +- **Deduplication before spawning**: Always check existing child conversations and reuse idle ones rather than spawning duplicates. This avoids unnecessary parallel work and keeps PR monitoring focused. +- **Full gate execution before push**: When fixing CI failures, run the complete local gate (format check → lint → unit → integration) before pushing. Never push partial fixes. +- **Root cause investigation**: When issues recur across multiple runs, investigate the root cause (e.g., external commits landing without proper formatting) and communicate findings. +- **Targeted problem analysis**: When only a single file or small set of issues is found, provide precise, targeted instructions rather than attempting broad fixes. +- **Pre-commit discipline enforcement**: Communicate that developers should always run `make fmt` before committing to avoid reintroducing CI failures from formatting issues. + diff --git a/CLAUDE.md b/CLAUDE.md index ddee5b80..2bb1b8b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ go test -v -tags integration ./tests/integration/inprocess/ - **Log authoritative source**: Check `events.jsonl` (session dir) when debugging; server logs rotate and have gaps. - **daisyUI drawer GPU bug**: `.drawer-side` + fixed-position overlay compete for pointer events → blank artifacts. Fix: See `web/static/styles.css` for verified pattern. Do NOT use `translateZ(0)`. - **Zombie WebSocket recovery**: When phone sleeps or app backgrounded, WS may enter "zombie" state (appearing open but dead). On visibility change or app activate, force-close and reconnect. This is expected behavior — not a bug. See `.augment/rules/23-web-frontend-mobile.md` for resilience patterns. +- **Verify prior edits actually persisted**: Don't trust that a previous turn's file edits are still on disk (session gaps, restarts, or reverted stashes can silently drop them). Before continuing/relying on earlier work, re-check with `git status`/`git diff` or re-view the file rather than assuming. ## New Agent Capability Checklist @@ -73,46 +74,30 @@ go test -v -tags integration ./tests/integration/inprocess/ 4. Store in `useWebSocket.js` and pass through `app.js` 5. Update mock ACP server and add integration test -## Go 1.22+ Routing Pattern (Complete) +## Go 1.22+ Routing Pattern -**Status**: ✅ COMPLETE. Eliminated `strings.Split` path-parsing via Go 1.22+ `http.ServeMux` method+pattern routing with `r.PathValue()`. - -**Pattern**: Extract path params, validate, delegate to handler: -```go -func (s *Server) handleSessionGet(w http.ResponseWriter, r *http.Request) { - if id, ok := s.sessionIDFromPath(w, r); ok { - s.apiHandlers.HandleGetSession(w, r, id, false) - } -} -``` - -**Route table** (`routes.go`): Declarative method+pattern entries (no subtree fallback): +`http.ServeMux` method+pattern routing (`r.PathValue()`), no manual path parsing. Route table (`routes.go`) is declarative: ```go apiRoute{http.MethodGet, "/api/sessions/{id}", s.handleSessionGet}, -apiRoute{http.MethodPatch, "/api/sessions/{id}", s.handleSessionUpdate}, -apiRoute{http.MethodDelete, "/api/sessions/{id}", s.handleSessionDelete}, ``` -## Frontend authFetch Pattern (Complete) - -**Pattern**: Use `authFetch(url, options?)` for all authenticated API calls. Ensures `credentials: "include"` (cross-origin/Tailscale safe) + unified 401 handling. +## Frontend authFetch Pattern +Use `authFetch(url, options?)` for all authenticated calls — adds `credentials: "include"` + unified 401 handling. URLs always from `web/static/utils/endpoints.js` (never hardcoded): ```javascript -// Use endpoints registry (never hardcoded URLs) -const response = await authFetch(endpoints.config.get()); const response = await authFetch(endpoints.sessions.get(sessionId)); ``` +Exception: public endpoints (e.g. `/api/supported-runners`) use raw `fetch` with `same-origin`. -**Key**: All URLs come from `web/static/utils/endpoints.js` registry. Never construct URLs manually. +## Reusable Config-Driven Components -**Defense-in-depth**: Add explicit 401 guard in critical paths: +**Toolbar** (`Toolbar.js`) — segmented-pill action bar from an `items` array (`button`/`dropdown`/`overflow`/`separator`/`spacer`/`custom`). Prefer over bespoke "..." kebab menus. ```javascript -if (response.status === 401) { redirectToLogin(); return; } +html`<${Toolbar} variant="block" surface="bg-mitto-surface-3" items=${headerToolbarItems} />` ``` +Used in `BeadsView.js` (list actions + issue-detail header). -**Public vs. authenticated**: -- ✅ `authFetch`: All authenticated endpoints (via `endpoints` builders) -- ❌ Keep raw `fetch` with `same-origin`: Public endpoints like `/api/supported-runners` +**ShortcutsEditor** (`ShortcutsEditor.js`) — one panel reused for both **global** (Settings dialog) and **folder** (Workspaces dialog) shortcut config. The three consumers (conversations/beadsIssue/tasksList toolbars) merge global + folder shortcuts **at render time**: global entries first, folder entries whose `prompt` duplicates a global one are dropped; any remaining duplicate renders greyed-out via `redundantPromptNames`. Backend: `config.ShortcutButton{Icon, Prompt}`, mirrored `GET/PUT /api/global/shortcuts` (`internal/web/handlers/global_shortcuts.go`). Refresh via `mitto:global_shortcuts_updated`/`mitto:folder_shortcuts_updated` window events — no reload needed. Safe defaults seeded in `config/config.default.yaml` (`shortcuts:`, new installs only). ## Model Selection & Preferred Models @@ -132,14 +117,27 @@ Prompts can declare `preferredModels:` to route to specific ACP models. `selectP - **Processors**: Always see the real tool list (fail-open is disabled internally) - Once tools are fetched, evaluation uses the actual list. Useful for tool-gated prompt/processor gating via `enabledWhen` -## Periodic Conversations +## MCP Tool Discovery + +Two-tier discovery for `enabledWhen`/CEL `tools.*` gating (see `docs/devel/mcp-tool-discovery.md`): +1. **Deterministic** (`internal/mcpdiscovery`): connects directly to configured MCP servers (stdio/http/sse) via `modelcontextprotocol/go-sdk` client and calls `tools/list`. Preferred — no LLM involved. +2. **LLM fallback** (`internal/auxiliary/workspace_manager.go` `fetchMCPToolsViaLLM`): used only when a server can't be reached deterministically. `parseMCPToolsList` (`utils.go`) is **strict**: whole trimmed/unfenced response must be one JSON object with `tools`/`error` keys — no substring or bare-array extraction (that leniency caused false negatives/hallucinated tools). Retries once with a reminder prompt on parse failure or an implausible zero-tools result (checked against the deterministically-known configured server count). +3. **Disk persistence** (mitto-sys.8): deterministic tool lists survive restarts via `appdir.MCPToolsCacheDir()` (`$MITTO_DIR/mcp-tools-cache`), one JSON snapshot per workspace, 15-min TTL (`persistedMCPTools` + `loadPersistedMCPTools`/`savePersistedMCPTools` in `workspace_manager.go`). The **LLM fallback is never written to disk** — in-memory only. `ClearMCPToolsCache` also deletes the snapshot, forcing re-probe. + +**Anti-pattern**: lenient JSON extraction (searching for `{...}` substrings or bare arrays in free-form LLM text) silently accepts malformed/partial answers. Prefer strict whole-response parsing + explicit retry over "try to salvage whatever looks like JSON." + +`checkRequiredToolPatterns` (`internal/web/session_ws.go`) no longer runs a blind 30/60/120s `prompts_changed` re-broadcast timer (removed, mitto-sys.12) — it emits one immediate broadcast; late/changed tools surface only via the event-driven watcher and bounded-backoff paths above. + +Per-agent `mcp-list.sh` config paths/keys are **not** interchangeable across agents — verify against real docs before writing/trusting one (audit + known-broken scripts: `.augment/rules/42-mcpserver-development.md`). + +## Loop Conversations -**onCompletion trigger** (distinct from schedule-based periodic): +**onCompletion trigger** (distinct from schedule-based loop): - Re-fires automatically 30s after agent finishes each turn (configurable `delay_seconds`) -- Green "Running" pill = `periodic_enabled: true`, NOT generic "agent is active" status +- Green "Running" pill = `loop_enabled: true`, NOT generic "agent is active" status - Limited by `max_iterations` and `max_duration_seconds` -- Free-text periodic prompts NOT sent to frontend → selector can't display them (UI gap) -- `app.js` line ~1928: `headerPeriodicState()` returns `{ state, label, badgeClass }` pill object +- Free-text loop prompts NOT sent to frontend → selector can't display them (UI gap) +- `app.js` line ~1928: `headerLoopState()` returns `{ state, label, badgeClass }` pill object - Issue `mitto-36nm` tracks UI clarity improvement (prompt visibility + pill disambiguation) ## Tokensave Rule (Mandatory) diff --git a/README.md b/README.md index a5ca9e5a..e29ff8ee 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ install yet another AI coding agent... - **Session Management** — Automatic conversation history with resume capability - **Parent/Children Conversations** — Spawn child conversations to delegate work to faster/cheaper models, wait for results, and synthesize — enabling multi-agent workflows -- **Periodic Conversations** — Schedule recurring prompts (every N minutes/hours/days) for automated tasks like daily reports or periodic checks, with HTTP callback URLs for on-demand triggering from webhooks, cron jobs, or CI pipelines +- **Loop Conversations** — Schedule recurring prompts (every N minutes/hours/days) for automated tasks like daily reports or recurring checks, with HTTP callback URLs for on-demand triggering from webhooks, cron jobs, or CI pipelines - **Message Queue** — Queue messages while the agent is busy, with auto-generated titles and automatic delivery when the agent becomes idle 🖥️ **User Interface** diff --git a/config/agents/builtin/amp/cmds/mcp-list.sh b/config/agents/builtin/amp/cmds/mcp-list.sh index 6d08180f..c2a5b0b1 100755 --- a/config/agents/builtin/amp/cmds/mcp-list.sh +++ b/config/agents/builtin/amp/cmds/mcp-list.sh @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/augment/cmds/mcp-list.sh b/config/agents/builtin/augment/cmds/mcp-list.sh index d858e2c0..f2d4b96b 100755 --- a/config/agents/builtin/augment/cmds/mcp-list.sh +++ b/config/agents/builtin/augment/cmds/mcp-list.sh @@ -60,6 +60,8 @@ for name, cfg in merged.items(): entry['url'] = cfg['url'] if cfg.get('env'): entry['env'] = cfg['env'] + if cfg.get('headers'): + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) diff --git a/config/agents/builtin/claude-code/cmds/mcp-list.sh b/config/agents/builtin/claude-code/cmds/mcp-list.sh index 0b936b96..ac409b52 100755 --- a/config/agents/builtin/claude-code/cmds/mcp-list.sh +++ b/config/agents/builtin/claude-code/cmds/mcp-list.sh @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/cline/cmds/mcp-list.sh b/config/agents/builtin/cline/cmds/mcp-list.sh index c44575a7..b8cfa2c7 100755 --- a/config/agents/builtin/cline/cmds/mcp-list.sh +++ b/config/agents/builtin/cline/cmds/mcp-list.sh @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/codex/cmds/mcp-list.sh b/config/agents/builtin/codex/cmds/mcp-list.sh index 34128c22..04526509 100755 --- a/config/agents/builtin/codex/cmds/mcp-list.sh +++ b/config/agents/builtin/codex/cmds/mcp-list.sh @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/cursor/cmds/mcp-list.sh b/config/agents/builtin/cursor/cmds/mcp-list.sh index 6554020f..e6d36b8f 100755 --- a/config/agents/builtin/cursor/cmds/mcp-list.sh +++ b/config/agents/builtin/cursor/cmds/mcp-list.sh @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/gemini/cmds/mcp-list.sh b/config/agents/builtin/gemini/cmds/mcp-list.sh index 432c5c6a..d1959d0f 100755 --- a/config/agents/builtin/gemini/cmds/mcp-list.sh +++ b/config/agents/builtin/gemini/cmds/mcp-list.sh @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/github-copilot/cmds/mcp-list.sh b/config/agents/builtin/github-copilot/cmds/mcp-list.sh index 728185e3..3cf68b53 100755 --- a/config/agents/builtin/github-copilot/cmds/mcp-list.sh +++ b/config/agents/builtin/github-copilot/cmds/mcp-list.sh @@ -4,7 +4,7 @@ # Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]} INPUT=$(cat 2>/dev/null || echo '{}') -CONFIG_FILE="${HOME}/.github-copilot/settings.json" +CONFIG_FILE="${HOME}/.copilot/mcp-config.json" if [ ! -f "$CONFIG_FILE" ]; then echo '{"servers": []}' @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/goose/cmds/mcp-list.sh b/config/agents/builtin/goose/cmds/mcp-list.sh index a001393d..dca79ea7 100755 --- a/config/agents/builtin/goose/cmds/mcp-list.sh +++ b/config/agents/builtin/goose/cmds/mcp-list.sh @@ -33,6 +33,8 @@ try: entry['env'] = cfg['envs'] elif cfg.get('type') == 'sse': entry['url'] = cfg.get('uri', '') + if cfg.get('headers'): + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/junie/cmds/mcp-list.sh b/config/agents/builtin/junie/cmds/mcp-list.sh index 5f145f77..809640d5 100755 --- a/config/agents/builtin/junie/cmds/mcp-list.sh +++ b/config/agents/builtin/junie/cmds/mcp-list.sh @@ -1,7 +1,43 @@ #!/usr/bin/env bash # List MCP servers configured for Junie # Input: {"path": "/optional/workspace/path"} (optional, via stdin) -# Output: {"servers": [...]} +# Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]} INPUT=$(cat 2>/dev/null || echo '{}') -echo '{"servers": []}' +CONFIG_FILE="" +for cand in "${HOME}/.junie/mcp/mcp.json" "${HOME}/.junie/mcp.json"; do + if [ -f "$cand" ]; then + CONFIG_FILE="$cand" + break + fi +done + +if [ -z "$CONFIG_FILE" ]; then + echo '{"servers": []}' + exit 0 +fi + +python3 -c " +import json, sys +try: + with open('$CONFIG_FILE') as f: + config = json.load(f) + servers = config.get('mcpServers', {}) + result = [] + for name, cfg in servers.items(): + entry = {'name': name} + if 'command' in cfg: + entry['command'] = cfg['command'] + if 'args' in cfg: + entry['args'] = cfg['args'] + if 'url' in cfg: + entry['url'] = cfg['url'] + if 'env' in cfg: + entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] + result.append(entry) + print(json.dumps({'servers': result})) +except Exception: + print(json.dumps({'servers': []})) +" diff --git a/config/agents/builtin/kilo/cmds/mcp-list.sh b/config/agents/builtin/kilo/cmds/mcp-list.sh index e194af9e..8d69fba5 100755 --- a/config/agents/builtin/kilo/cmds/mcp-list.sh +++ b/config/agents/builtin/kilo/cmds/mcp-list.sh @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/mistral-vibe/cmds/mcp-list.sh b/config/agents/builtin/mistral-vibe/cmds/mcp-list.sh index 5e645d5a..aa625b24 100755 --- a/config/agents/builtin/mistral-vibe/cmds/mcp-list.sh +++ b/config/agents/builtin/mistral-vibe/cmds/mcp-list.sh @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/opencode/cmds/mcp-list.sh b/config/agents/builtin/opencode/cmds/mcp-list.sh index 6ec798bd..6c61e0ef 100755 --- a/config/agents/builtin/opencode/cmds/mcp-list.sh +++ b/config/agents/builtin/opencode/cmds/mcp-list.sh @@ -4,7 +4,7 @@ # Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]} INPUT=$(cat 2>/dev/null || echo '{}') -CONFIG_FILE="${HOME}/.opencode/settings.json" +CONFIG_FILE="${HOME}/.config/opencode/opencode.json" if [ ! -f "$CONFIG_FILE" ]; then echo '{"servers": []}' @@ -16,18 +16,27 @@ import json, sys try: with open('$CONFIG_FILE') as f: config = json.load(f) - servers = config.get('mcpServers', {}) + servers = config.get('mcp', {}) result = [] for name, cfg in servers.items(): entry = {'name': name} - if 'command' in cfg: - entry['command'] = cfg['command'] - if 'args' in cfg: - entry['args'] = cfg['args'] - if 'url' in cfg: - entry['url'] = cfg['url'] - if 'env' in cfg: - entry['env'] = cfg['env'] + t = cfg.get('type') + cmd = cfg.get('command') + if t == 'local' or (t is None and isinstance(cmd, list)): + if isinstance(cmd, list) and len(cmd) > 0: + entry['command'] = cmd[0] + if len(cmd) > 1: + entry['args'] = cmd[1:] + elif isinstance(cmd, str) and cmd: + entry['command'] = cmd + env = cfg.get('environment') + if env: + entry['env'] = env + if t == 'remote' or (t is None and 'url' in cfg): + if 'url' in cfg: + entry['url'] = cfg['url'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/agents/builtin/qwen-code/cmds/mcp-list.sh b/config/agents/builtin/qwen-code/cmds/mcp-list.sh index 491e8b9f..e031f91e 100755 --- a/config/agents/builtin/qwen-code/cmds/mcp-list.sh +++ b/config/agents/builtin/qwen-code/cmds/mcp-list.sh @@ -4,7 +4,7 @@ # Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]} INPUT=$(cat 2>/dev/null || echo '{}') -CONFIG_FILE="${HOME}/.qwen-code/settings.json" +CONFIG_FILE="${HOME}/.qwen/settings.json" if [ ! -f "$CONFIG_FILE" ]; then echo '{"servers": []}' @@ -28,6 +28,8 @@ try: entry['url'] = cfg['url'] if 'env' in cfg: entry['env'] = cfg['env'] + if 'headers' in cfg: + entry['headers'] = cfg['headers'] result.append(entry) print(json.dumps({'servers': result})) except Exception: diff --git a/config/builtin_prompts.go b/config/builtin_prompts.go index 57330b63..eac41209 100644 --- a/config/builtin_prompts.go +++ b/config/builtin_prompts.go @@ -128,14 +128,18 @@ func EnsureBuiltinPrompts(targetDir string) (bool, error) { // Prune orphaned builtin prompts: the builtin directory is fully managed by // Mitto, so any deployed .prompt.yaml file not present in the embedded set is stale - // (e.g. a prompt that was consolidated or removed in a newer build). + // (e.g. a prompt that was consolidated or removed in a newer build). Legacy + // old-format *.md builtin files left over from pre-migration versions are always + // stale (the embedded set is *.prompt.yaml only) and are removed as well. if deployedEntries, derr := os.ReadDir(targetDir); derr == nil { for _, entry := range deployedEntries { if entry.IsDir() { continue } name := entry.Name() - if !strings.HasSuffix(name, ".prompt.yaml") { + isPromptYAML := strings.HasSuffix(name, ".prompt.yaml") + isLegacyMD := strings.HasSuffix(name, ".md") + if !isPromptYAML && !isLegacyMD { continue } if _, ok := embeddedNames[name]; ok { diff --git a/config/builtin_prompts_test.go b/config/builtin_prompts_test.go new file mode 100644 index 00000000..4895519e --- /dev/null +++ b/config/builtin_prompts_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// TestEnsureBuiltinPrompts_PrunesStaleFiles verifies that EnsureBuiltinPrompts +// removes stale builtin files that are not part of the embedded set: both +// orphaned *.prompt.yaml files (consolidated/removed in a newer build) and +// legacy old-format *.md builtin files left over from pre-migration versions. +// Files with other extensions and the embedded prompts themselves must survive. +func TestEnsureBuiltinPrompts_PrunesStaleFiles(t *testing.T) { + targetDir := t.TempDir() + + // First run deploys the embedded builtin prompts. + if _, err := EnsureBuiltinPrompts(targetDir); err != nil { + t.Fatalf("initial EnsureBuiltinPrompts failed: %v", err) + } + + embedded, err := ListEmbeddedPrompts() + if err != nil { + t.Fatalf("ListEmbeddedPrompts failed: %v", err) + } + if len(embedded) == 0 { + t.Fatal("expected at least one embedded builtin prompt") + } + + // Seed stale files that must be pruned on the next run. + staleYAML := filepath.Join(targetDir, "totally-removed.prompt.yaml") + staleMD1 := filepath.Join(targetDir, "legacy-old.md") + staleMD2 := filepath.Join(targetDir, "another-legacy.md") + for _, p := range []string{staleYAML, staleMD1, staleMD2} { + if err := os.WriteFile(p, []byte("stale"), 0644); err != nil { + t.Fatalf("failed to seed stale file %s: %v", p, err) + } + } + + // Seed a file with an unrelated extension that must be preserved (scope + // is limited to *.prompt.yaml and legacy *.md files only). + keepTXT := filepath.Join(targetDir, "keep-me.txt") + if err := os.WriteFile(keepTXT, []byte("keep"), 0644); err != nil { + t.Fatalf("failed to seed keep file: %v", err) + } + + // Second run should prune the stale files and report that it changed state. + changed, err := EnsureBuiltinPrompts(targetDir) + if err != nil { + t.Fatalf("second EnsureBuiltinPrompts failed: %v", err) + } + if !changed { + t.Error("expected EnsureBuiltinPrompts to report changes after pruning stale files") + } + + // Stale files must be gone. + for _, p := range []string{staleYAML, staleMD1, staleMD2} { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("expected stale file %s to be pruned, but it still exists (err=%v)", p, err) + } + } + + // Unrelated file must be preserved. + if _, err := os.Stat(keepTXT); err != nil { + t.Errorf("expected unrelated file %s to be preserved, but stat failed: %v", keepTXT, err) + } + + // Embedded prompts must still be present. + for _, name := range embedded { + p := filepath.Join(targetDir, name) + if _, err := os.Stat(p); err != nil { + t.Errorf("expected embedded prompt %s to be preserved, but stat failed: %v", name, err) + } + } +} + +// TestEnsureBuiltinPrompts_NoStaleFilesIsIdempotent verifies that running +// EnsureBuiltinPrompts twice with no stale files reports no changes on the +// second run (nothing to deploy, update, or prune). +func TestEnsureBuiltinPrompts_NoStaleFilesIsIdempotent(t *testing.T) { + targetDir := t.TempDir() + + if _, err := EnsureBuiltinPrompts(targetDir); err != nil { + t.Fatalf("initial EnsureBuiltinPrompts failed: %v", err) + } + + changed, err := EnsureBuiltinPrompts(targetDir) + if err != nil { + t.Fatalf("second EnsureBuiltinPrompts failed: %v", err) + } + if changed { + t.Error("expected no changes on second run with no stale files") + } +} diff --git a/config/config.default.yaml b/config/config.default.yaml index aba57b87..b80b8e45 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -72,6 +72,26 @@ models: pattern: Gemini tags: [Smart, LongContext] +# Global shortcut buttons +# Quick-action buttons shown across the UI, keyed by the section they appear in: +# conversations: the conversation toolbar (runs the prompt in the current conversation) +# beadsIssue: the beads issue detail toolbar (starts work on the open issue) +# tasksList: the Tasks list toolbar +# Each button links to a prompt by name (`prompt:`) and may set an optional +# `icon:` (a PROMPT_ICONS key); an empty icon falls back to the prompt's own icon. +# +# These seed NEW installs only (written to settings.json on first run); existing +# settings.json files are left untouched. Global shortcuts are MERGED with any +# per-folder shortcuts at render time (global entries appear first). Edit or +# extend them via Settings → Shortcuts. +shortcuts: + conversations: + - prompt: Commit changes + beadsIssue: + - prompt: Start work + tasksList: + - prompt: Overview + # Web server configuration web: host: 127.0.0.1 # Local listener always binds to 127.0.0.1 for security diff --git a/config/processors/builtin/auggie-manage-rules.yaml b/config/processors/builtin/auggie-manage-rules.yaml index feb6a57b..8ff5b95f 100644 --- a/config/processors/builtin/auggie-manage-rules.yaml +++ b/config/processors/builtin/auggie-manage-rules.yaml @@ -23,8 +23,8 @@ priority: 200 timeout: 300s onError: skip -# Only for Auggie sessions, skip periodic prompts, and only when rules don't exist yet -enabledWhen: 'ACP.MatchesServerType("augment") && !Session.IsPeriodic && !DirExists(".augment/rules")' +# Only for Auggie sessions, skip loop prompts, and only when rules don't exist yet +enabledWhen: 'ACP.MatchesServerType("augment") && !Session.IsLoop && !DirExists(".augment/rules")' prompt: | You generate `.augment/rules` files for this workspace for the first time. diff --git a/config/processors/builtin/auggie-update-rules.yaml b/config/processors/builtin/auggie-update-rules.yaml index 4c941029..e3526c47 100644 --- a/config/processors/builtin/auggie-update-rules.yaml +++ b/config/processors/builtin/auggie-update-rules.yaml @@ -37,8 +37,8 @@ parameters: description: "How many recent user/agent messages the auxiliary agent reviews" default: "10" -# Only for Auggie sessions, skip periodic prompts, and only when rules already exist -enabledWhen: 'ACP.MatchesServerType("augment") && !Session.IsPeriodic && DirExists(".augment/rules")' +# Only for Auggie sessions, skip loop prompts, and only when rules already exist +enabledWhen: 'ACP.MatchesServerType("augment") && !Session.IsLoop && DirExists(".augment/rules")' prompt: | You update `.augment/rules` files for this workspace based on recent conversation insights. diff --git a/config/processors/builtin/beads-prime.yaml b/config/processors/builtin/beads-prime.yaml index 1b83ae40..b44b6ac9 100644 --- a/config/processors/builtin/beads-prime.yaml +++ b/config/processors/builtin/beads-prime.yaml @@ -12,7 +12,7 @@ # 2. The workspace contains a `.beads` directory (DirExists CEL gate) # # Throttling: -# - Fires on the first message, then re-runs periodically to keep beads memories +# - Fires on the first message, then re-runs repeatedly to keep beads memories # fresh as the conversation grows, without injecting on every single message. # # Uses command-mode with outputFormat:raw so that the plain-text markdown output diff --git a/config/processors/builtin/beads-ready-tasks.yaml b/config/processors/builtin/beads-ready-tasks.yaml index 38991c45..366b7092 100644 --- a/config/processors/builtin/beads-ready-tasks.yaml +++ b/config/processors/builtin/beads-ready-tasks.yaml @@ -11,7 +11,7 @@ # 2. The workspace contains a `.beads` directory (DirExists CEL gate) # # Throttling: -# - Fires on the first message, then re-runs periodically so the agent is reminded +# - Fires on the first message, then re-runs repeatedly so the agent is reminded # about outstanding work without spamming every message. # # Uses text-mode (no external command). The agent itself runs `bd ready` to see the diff --git a/config/processors/builtin/beads-track-tasks.yaml b/config/processors/builtin/beads-track-tasks.yaml index ab448f1f..8ccf93af 100644 --- a/config/processors/builtin/beads-track-tasks.yaml +++ b/config/processors/builtin/beads-track-tasks.yaml @@ -9,7 +9,7 @@ # - The `bd` command exists on PATH (CommandExists CEL gate) # # Throttling: -# - Fires on the first message, then re-runs periodically to keep the reminder fresh +# - Fires on the first message, then re-runs repeatedly to keep the reminder fresh # without spamming every single message. # # Uses text-mode (no external command). diff --git a/config/processors/builtin/check-mcp-tools.yaml b/config/processors/builtin/check-mcp-tools.yaml index 02624de5..0d863195 100644 --- a/config/processors/builtin/check-mcp-tools.yaml +++ b/config/processors/builtin/check-mcp-tools.yaml @@ -3,7 +3,7 @@ # # This processor appends a note asking the agent to verify MCP tool availability # and show installation instructions if needed. It runs on the first message -# and re-runs periodically to re-check. +# and re-runs repeatedly to re-check. # # Uses text-mode (no external command). ########################################################################################## diff --git a/config/processors/builtin/claude-manage-memory.yaml b/config/processors/builtin/claude-manage-memory.yaml index 012b615c..e9af1fcd 100644 --- a/config/processors/builtin/claude-manage-memory.yaml +++ b/config/processors/builtin/claude-manage-memory.yaml @@ -23,8 +23,8 @@ priority: 200 timeout: 300s onError: skip -# Only for Claude Code sessions, skip periodic prompts, and only when memory files don't exist yet -enabledWhen: 'ACP.MatchesServerType("claude-code") && !Session.IsPeriodic && !FileExists("CLAUDE.md") && !DirExists(".claude")' +# Only for Claude Code sessions, skip loop prompts, and only when memory files don't exist yet +enabledWhen: 'ACP.MatchesServerType("claude-code") && !Session.IsLoop && !FileExists("CLAUDE.md") && !DirExists(".claude")' prompt: | You generate Claude Code memory files for this workspace for the first time. diff --git a/config/processors/builtin/claude-update-memory.yaml b/config/processors/builtin/claude-update-memory.yaml index e7c91faa..821349ad 100644 --- a/config/processors/builtin/claude-update-memory.yaml +++ b/config/processors/builtin/claude-update-memory.yaml @@ -31,8 +31,8 @@ priority: 200 timeout: 300s onError: skip -# Only for Claude Code sessions, skip periodic prompts, and only when memory files already exist -enabledWhen: 'ACP.MatchesServerType("claude-code") && !Session.IsPeriodic && (FileExists("CLAUDE.md") || DirExists(".claude"))' +# Only for Claude Code sessions, skip loop prompts, and only when memory files already exist +enabledWhen: 'ACP.MatchesServerType("claude-code") && !Session.IsLoop && (FileExists("CLAUDE.md") || DirExists(".claude"))' prompt: | You update Claude Code memory files for this workspace based on recent conversation insights. diff --git a/config/processors/builtin/identify-user-data.yaml b/config/processors/builtin/identify-user-data.yaml index 3e39110e..50778d6a 100644 --- a/config/processors/builtin/identify-user-data.yaml +++ b/config/processors/builtin/identify-user-data.yaml @@ -26,8 +26,8 @@ priority: 190 timeout: 120s onError: skip -# Only activate when the workspace has a user data schema and this isn't a periodic prompt -enabledWhen: 'Workspace.HasUserDataSchema && !Session.IsPeriodic' +# Only activate when the workspace has a user data schema and this isn't a loop prompt +enabledWhen: 'Workspace.HasUserDataSchema && !Session.IsLoop' prompt: | You are a metadata extractor. Your job is to analyze the conversation messages below diff --git a/config/processors/builtin/identify-workspace-metadata.yaml b/config/processors/builtin/identify-workspace-metadata.yaml index cd945777..b191e157 100644 --- a/config/processors/builtin/identify-workspace-metadata.yaml +++ b/config/processors/builtin/identify-workspace-metadata.yaml @@ -21,8 +21,8 @@ priority: 195 timeout: 120s onError: skip -# Only activate when .mittorc exists but has no metadata description, and not periodic -enabledWhen: 'Workspace.HasMittoRC && !Workspace.HasMetadataDescription && !Session.IsPeriodic' +# Only activate when .mittorc exists but has no metadata description, and not a loop run +enabledWhen: 'Workspace.HasMittoRC && !Workspace.HasMetadataDescription && !Session.IsLoop' prompt: | You are a workspace metadata curator. Your job is to analyze the project in the diff --git a/config/processors/builtin/memorize-preferences.yaml b/config/processors/builtin/memorize-preferences.yaml index 8421d100..bcf3e91a 100644 --- a/config/processors/builtin/memorize-preferences.yaml +++ b/config/processors/builtin/memorize-preferences.yaml @@ -56,8 +56,8 @@ priority: 200 timeout: 120s onError: skip -# Skip periodic prompts — only process real user messages -enabledWhen: '!Session.IsPeriodic' +# Skip loop prompts — only process real user messages +enabledWhen: '!Session.IsLoop' parameters: - name: PreferencesFile diff --git a/config/prompts/builtin/add-tests.prompt.yaml b/config/prompts/builtin/add-tests.prompt.yaml index 33b56113..490cccd1 100644 --- a/config/prompts/builtin/add-tests.prompt.yaml +++ b/config/prompts/builtin/add-tests.prompt.yaml @@ -1,6 +1,6 @@ icon: check name: Add tests -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Write comprehensive tests for new or modified code group: Testing backgroundColor: '#FFE0B2' diff --git a/config/prompts/builtin/address-pr-comments.prompt.yaml b/config/prompts/builtin/address-pr-comments.prompt.yaml index 6c518c3d..cf01d359 100644 --- a/config/prompts/builtin/address-pr-comments.prompt.yaml +++ b/config/prompts/builtin/address-pr-comments.prompt.yaml @@ -1,11 +1,11 @@ icon: chat-bubble name: Address PR Comments -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Systematically address all pull request review feedback group: Submission of changes backgroundColor: '#B2DFDB' tags: -- periodic +- loop - github enabledWhen: FileExists(".git/config") && (Tools.HasPattern("github_*") || CommandExists("gh") || CommandExists("glab")) prompt: | @@ -16,9 +16,9 @@ prompt: | Your session ID is `{{ .Session.ID }}` — use this as `self_id` for all `mitto_*` MCP tool calls. ## Interaction Mode - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Scheduled mode** — a periodic run; the user is not watching. + **Scheduled mode** — a loop run; the user is not watching. - Use **only** `mitto_ui_notify` for all communication — non-blocking notifications only. - Do **NOT** use `mitto_ui_options`, `mitto_ui_form`, `mitto_ui_textbox`, or any interactive/blocking UI tool. They will stall an unattended run. @@ -29,7 +29,7 @@ prompt: | quietly with a single notification — do not block waiting for input. {{- else }} - **Interactive mode** — a force-triggered run or a non-periodic conversation; the user may be present. + **Interactive mode** — a force-triggered run or a non-loop conversation; the user may be present. - You may freely use `mitto_ui_options`, `mitto_ui_form`, and other interactive tools in addition to `mitto_ui_notify`. - Confirm analysis and ask before pushing, as described in the steps below. @@ -57,7 +57,7 @@ prompt: | glab mr view # GitLab ``` - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode: auto-select the PR for the current branch. If multiple or none match, end the run quietly with a `mitto_ui_notify` note — do not ask. {{- else }} @@ -88,7 +88,7 @@ prompt: | - **Disagree**: Acknowledge perspective, explain reasoning with evidence, offer alternatives - **Already addressed**: Point to relevant code/commit - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode: apply the **Autonomy criteria** above. Implement only objectively-valid, low-risk comments; for the rest, reply requesting clarification and collect them for the final notification. @@ -108,7 +108,7 @@ prompt: | |---------|------|----------|----------|--------| | ... | ... | ... | ... | ... | - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode: skip confirmation — proceed with the autonomy-approved subset and defer the rest (no blocking UI). {{- else }} @@ -128,11 +128,11 @@ prompt: | For fixes requiring **significant work** (3+ files, substantial new code, risky refactors), delegate to Mitto child conversations for parallel execution. - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode: before spawning, check `{{ .Children.AllText }}` for an existing child for the same PR/comment and reuse it instead of creating a duplicate. Spawn at most **3 conversations per run**. **Spawned conversations - must never be periodic** — they are one-off tasks. + must never be loops** — they are one-off tasks. {{- end }} **How to delegate (requires Mitto MCP tools):** @@ -185,7 +185,7 @@ prompt: | ### 9. Push and Request Re-review - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode: push the autonomy-approved fixes and request re-review automatically (no confirmation), then `mitto_ui_notify` the result. If there are **no** autonomy-approved changes, do not push — just notify which items @@ -232,12 +232,12 @@ prompt: | - Max 4 parallel child conversations - In fork workflows, push to `origin`, not `upstream` - **Interaction mode** (see "Interaction Mode" section above): - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} - - **Scheduled periodic**: use only `mitto_ui_notify`; act on objectively-valid, + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} + - **Scheduled loop**: use only `mitto_ui_notify`; act on objectively-valid, low-risk comments per the Autonomy criteria; reply-and-defer the rest; never - resolve threads you didn't author; spawned children must never be periodic. + resolve threads you didn't author; spawned children must never be loops. {{- else }} - - **Force-triggered or non-periodic**: you may use `mitto_ui_options`, + - **Force-triggered or non-loop**: you may use `mitto_ui_options`, `mitto_ui_form`, and other interactive tools; confirm analysis and ask before pushing. {{- end }} diff --git a/config/prompts/builtin/analyze-logs.prompt.yaml b/config/prompts/builtin/analyze-logs.prompt.yaml index c73e13a6..dfe85443 100644 --- a/config/prompts/builtin/analyze-logs.prompt.yaml +++ b/config/prompts/builtin/analyze-logs.prompt.yaml @@ -15,7 +15,7 @@ parameters: multiLine: true description: 'Optional additional instructions to steer the analysis (e.g. "these are nginx access logs — focus on security and abuse", "this is the payment service, flag any data-consistency issues", "ignore deprecation warnings", "correlate by request-id")' enabledWhen: CommandExists("bd") && DirExists(".beads") -periodic: +loop: mode: optional default: false trigger: onCompletion diff --git a/config/prompts/builtin/architectural-analysis.prompt.yaml b/config/prompts/builtin/architectural-analysis.prompt.yaml index af29c201..f262b852 100644 --- a/config/prompts/builtin/architectural-analysis.prompt.yaml +++ b/config/prompts/builtin/architectural-analysis.prompt.yaml @@ -5,9 +5,9 @@ description: Analyze the codebase architecture from a 10,000-ft view and file be backgroundColor: '#C8E6C9' group: Code Quality tags: -- periodic +- loop enabledWhen: CommandExists("bd") && DirExists(".beads") -periodic: +loop: mode: optional default: false trigger: onCompletion @@ -28,18 +28,18 @@ prompt: | which abstractions earn their keep, and where the design fights itself. Turn every finding worth acting on into a beads issue (`bd`), grouping related findings under epics so the work is coherent rather than a flat pile. Run it **on demand** in a regular conversation, or schedule it to run - periodically — it adapts its behaviour to whichever mode it is invoked in (see Interaction Mode). + as a loop — it adapts its behaviour to whichever mode it is invoked in (see Interaction Mode). ## Interaction Mode - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode** — a scheduled periodic run; the user is not watching. + **Silent mode** — a scheduled loop run; the user is not watching. - Use **only** `mitto_ui_notify` — non-blocking notifications. - Do **NOT** use `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. - File only **high-confidence, high-value** findings (skip anything speculative), then notify with a summary. {{- else }} - **Interactive mode** — a regular conversation or a force-triggered periodic run; the user is present. + **Interactive mode** — a regular conversation or a force-triggered loop run; the user is present. - Present findings for approval with `mitto_ui_form` and **wait for confirmation before filing any bead**. You may also use `mitto_ui_options` / `mitto_ui_textbox` and `mitto_ui_notify`. {{- end }} @@ -123,11 +123,11 @@ prompt: | ## Step 6 — Confirm before filing - **Silent mode** (scheduled periodic, not forced): skip confirmation entirely. File only the + **Silent mode** (scheduled loop, not forced): skip confirmation entirely. File only the high-confidence findings directly (Step 7), then notify with `mitto_ui_notify` summarizing what was created. Do not block. - **Interactive mode** (regular conversation or forced periodic run): this is **read-only until you + **Interactive mode** (regular conversation or forced loop run): this is **read-only until you confirm**. First, **print the organized list as a regular message** — existing epics with their newly-attached children, then proposed new epics with their children, then standalone findings — each line as: diff --git a/config/prompts/builtin/beads-issue-assess.prompt.yaml b/config/prompts/builtin/beads-issue-assess.prompt.yaml new file mode 100644 index 00000000..1e652a9c --- /dev/null +++ b/config/prompts/builtin/beads-issue-assess.prompt.yaml @@ -0,0 +1,175 @@ +icon: chat-bubble +name: Assess issue readiness +menus: beadsIssues, conversation +parameters: + - name: IssueID + type: beadsId + required: false + description: The beads issue ID to act on +description: 'Discuss and refine a bead: resolve pending decisions, assess its quality, and sharpen it until it is ready to work on — capturing the rationale back into the tracker' +backgroundColor: '#F8BBD0' +group: Tasks +enabledWhen: CommandExists("bd") && DirExists(".beads") && Item.Status != "closed" +prompt: | + ## Session Context + + Your session ID is `{{ .Session.ID }}` — use this as `self_id` for all `mitto_*` MCP tool calls. + + # Beads: Assess a Bead's Readiness + + Beads is a CLI issue tracker (`bd`). Issues are called "beads" and have IDs like `bd-xyz`. + + {{ $target := "" -}} + {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }}{{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} + {{ if $target -}} + The **target bead** is `{{ $target }}`. Your job is to help the user **think through and refine** this bead — resolving pending decisions, assessing its quality, and clarifying gaps — until it is **ready to work on**, then **capture the outcome back into the tracker**. + {{- else -}} + There is **no linked bead** for this conversation. Help the user **think through and refine the current problem** under discussion here — the topic, design question, or decision raised in this conversation: resolve pending decisions, surface trade-offs, and sharpen it into an actionable conclusion. Do **not** run any `bd` commands — there is no bead to load or update. + {{- end }} + + > **Scope:** this is a *discussion and refinement* prompt. Do **not** implement the work, write + > code, or change anything outside the beads tracker. + > {{ if $target -}} + > You may only investigate, discuss, and — with the user's confirmation — update this bead or create new beads. + > {{- else -}} + > You may only investigate and discuss; there is no bead to modify (you may, with confirmation, create a new bead to capture the outcome). + > {{- end }} + + {{ if $target -}} + ## Step 1 — Load the bead and its context + + ```bash + bd show {{ $target }} --long --json # description, acceptance, design, notes, labels, status + bd dep tree {{ $target }} # blockers and what it blocks + bd show {{ $target }} --children --json # existing child beads (if any) + bd comments {{ $target }} # prior discussion, if any + ``` + + Read everything carefully. Where the bead references code or features, investigate the **codebase** + just enough to discuss it knowledgeably — but remember you are not here to implement it. + {{- end }} + + ## Step 2 — Analyse the bead or current problem: pending decisions & blockers + + Scan the bead or current problem for **decisions that are blocking progress or awaiting input**, and + for anything stopping it from being worked on, e.g.: + + - Open questions in the description, notes, or comments ("should we…?", "TBD", "decide whether…") + - Unresolved trade-offs between approaches, or an undefined acceptance criterion + - A `blocked` / `deferred` status, or labels such as `needs-decision` / `question` / `discuss` + - Unmet dependencies or prerequisites (from the dependency tree) + - Ambiguity that would force an implementer to guess + + **If nothing stands out** — no pending decisions, blockers, or obvious gaps — ask the user what they + want to refine or discuss, via `mitto_ui_options(self_id: "{{ .Session.ID }}", + allow_free_text: true)`, offering focus areas such as *scope*, *approach / design*, *acceptance + criteria*, *risks & edge cases*, or *priority*, plus free text for their own topic. + + ## Step 3 — Propose next steps and pick a direction + + Based on your analysis, propose a **short list of concrete next steps**, each with clear reasoning + for why it matters and what it would settle. Then ask the user **which direction to take** via + `mitto_ui_options(self_id: "{{ .Session.ID }}", allow_free_text: true)`, listing the proposed + steps as options plus free text so they can choose, reprioritise, or describe their own. + + ## Step 4 — Assess the quality of the bead or current problem + + Evaluate whether the bead or current problem is **ready to work on**, checking: + + - **Description** — is it complete and unambiguous? + - **Acceptance criteria** — are they defined, and do they make "done" clear? + - **Dependencies / prerequisites** — are they identified and understood? + - **Actionability** — could an implementer pick this up as-is, or does it need refinement first? + + Summarise the result as a short readiness checklist, flagging each item as ✅ clear or ⚠️ needs work. + + ## Step 5 — Identify what still needs clarification + + From the quality assessment, list the **specific gaps or ambiguities** that must be resolved before + the bead or current problem is ready — the open questions, undefined criteria, or unclear + dependencies that would otherwise force an implementer to guess. If it is already in good shape, + say so. + + ## Step 6 — Discuss the gaps with the user + + Engage the user in **focused discussion** on the gaps from Step 5 (and the direction chosen in + Step 3). Ground the conversation in evidence — investigate the codebase, related beads, and history + as needed so your input is concrete rather than speculative. Batch related questions into a single + `mitto_ui_options(self_id: "{{ .Session.ID }}", allow_free_text: true)` call, offering your + best-guess answer plus free text, and iterate until each gap is resolved into an **actionable + conclusion**. + + {{ if $target -}} + ## Step 7 — Update the bead (after confirming) + + Everything above is **read-only until the user confirms**. Present exactly what you intend to change + and get approval via `mitto_ui_options(self_id: "{{ .Session.ID }}", allow_free_text: true)`, + e.g. "Apply these updates to `{{ $target }}`?" with options like **"Apply all"**, **"Apply some"** + (free text), and **"Make no changes"**. Never write anything the user did not approve. + + Once approved, update the bead to reflect the decisions made: + + ```bash + bd update {{ $target }} --body-file /tmp/bead-desc.md # revise the description + bd update {{ $target }} --acceptance "" # if a decision sharpened "done" + bd update {{ $target }} --append-notes "" + bd update {{ $target }} --remove-label needs-decision # drop flags the decision removed + bd update {{ $target }} -s open # un-block now that it can proceed + bd update {{ $target }} -p <0-4> # if the decision changed its priority + ``` + + **Whenever you change the description** (or other substantive fields), **add a comment documenting + the change and the reasoning behind it** — this preserves the decision history and makes it easy to + understand why the bead evolved as it did: + + ```bash + bd comment {{ $target }} "Refined — rationale: " + ``` + + If the discussion revealed separable work, you may propose sub-issues (title, one-line scope, + type/priority); for a full breakdown defer to the **"Decompose issue"** prompt. After confirmation: + + ```bash + bd create "" --parent {{ $target }} --type --priority <0-4> --body-file /tmp/child.md + ``` + {{- else }} + ## Step 7 — Capture the outcome (no bead to update) + + There is no bead to update. If the discussion produced work worth tracking, offer (with + confirmation) to create a new bead via the **"Decompose issue"** or **"Start work"** prompt. No + `bd` commands. + {{- end }} + + ## Step 8 — Final summary + + Summarise the work: the decisions resolved (with rationale) and the readiness assessment. + {{ if $target -}} + Include which bead fields / labels / status changed, the comments added, and any sub-issues created (IDs + titles). + {{- end }} + Reiterate that **no implementation was done** — point the user to the **"Start work"** prompt when + the bead or current problem is ready. + + ## Final step — Offer to delete this conversation + + The task is complete. Offer to tidy up so finished conversations do not accumulate. + + 1. Ask the user whether to delete this conversation now, via + `mitto_ui_options(self_id: "{{ .Session.ID }}", question: "All done — delete this conversation now?", timeout_seconds: 120)` with options: + - **"Yes, delete it"** + - **"No, keep it"** + + 2. Honour the answer: + - **Delete** → first notify the user (the deletion is deferred until your turn ends, so the + message is delivered first) with + `mitto_ui_notify_mitto(self_id: "{{ .Session.ID }}", title: "", message: "", style: "success")`, + then self-destruct with + `mitto_conversation_delete_mitto(self_id: "{{ .Session.ID }}", conversation_id: "self")`. + - **Keep** → leave the conversation in place. + + 3. **On timeout** (no response): only delete this conversation if **all** of the following hold — + it was **started by this prompt** (a dedicated conversation for this task, not an existing + conversation you were invoked into), **no further action is expected from the user**, and + **all the work was clearly completed**. If so, notify (as above) then self-destruct; otherwise + leave the conversation untouched. + + If the `mitto_*` tools are unavailable, skip this step silently. diff --git a/config/prompts/builtin/beads-issue-discuss.prompt.yaml b/config/prompts/builtin/beads-issue-discuss.prompt.yaml index 25b61bab..ac205f13 100644 --- a/config/prompts/builtin/beads-issue-discuss.prompt.yaml +++ b/config/prompts/builtin/beads-issue-discuss.prompt.yaml @@ -1,12 +1,17 @@ icon: chat-bubble -name: Discuss & Refine issue +name: Discuss a topic menus: beadsIssues, conversation parameters: - name: IssueID type: beadsId required: false - description: The beads issue ID to act on -description: 'Discuss and refine a bead: resolve pending decisions, assess its quality, and sharpen it until it is ready to work on — capturing the rationale back into the tracker' + description: The beads issue ID to discuss + - name: Topic + type: text + required: true + multiLine: true + description: 'What you want to discuss, refine, or add — the question, idea, concern, or change to explore in the context of this bead' +description: 'Raise a topic on a bead — a question, idea, concern, or change — and work it through together in the context of the issue, capturing any decisions back into the tracker' backgroundColor: '#F8BBD0' group: Tasks enabledWhen: CommandExists("bd") && DirExists(".beads") && Item.Status != "closed" @@ -15,22 +20,32 @@ prompt: | Your session ID is `{{ .Session.ID }}` — use this as `self_id` for all `mitto_*` MCP tool calls. - # Beads: Discuss & Refine a Bead + # Beads: Discuss a Topic on a Bead Beads is a CLI issue tracker (`bd`). Issues are called "beads" and have IDs like `bd-xyz`. {{ $target := "" -}} {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }}{{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} {{ if $target -}} - The **target bead** is `{{ $target }}`. Your job is to help the user **think through and refine** this bead — resolving pending decisions, assessing its quality, and clarifying gaps — until it is **ready to work on**, then **capture the outcome back into the tracker**. + The **target bead** is `{{ $target }}`. The user wants to **discuss, refine, or add + something** to it. Your job is to explore the topic below together with them — grounded in the + bead and the codebase — and, once you reach a conclusion, **capture the outcome back into the + tracker**. {{- else -}} - There is **no linked bead** for this conversation. Help the user **think through and refine the current problem** under discussion here — the topic, design question, or decision raised in this conversation: resolve pending decisions, surface trade-offs, and sharpen it into an actionable conclusion. Do **not** run any `bd` commands — there is no bead to load or update. + There is **no linked bead** for this conversation. Discuss the topic below with the user in the + context of the current problem under discussion here. Do **not** run any `bd` commands — there is + no bead to load or update (you may, with confirmation, create one to capture the outcome). {{- end }} - > **Scope:** this is a *discussion and refinement* prompt. Do **not** implement the work, write - > code, or change anything outside the beads tracker. + ## The topic to discuss + + The user raised the following (treat it as the starting point, not the whole conversation): + + > {{ .Args.Topic }} + + > **Scope:** this is a *discussion* prompt. Do **not** implement the work or write code. > {{ if $target -}} - > You may only investigate, discuss, and — with the user's confirmation — update this bead or create new beads. + > You may investigate, discuss, and — with the user's confirmation — update this bead or create new beads. > {{- else -}} > You may only investigate and discuss; there is no bead to modify (you may, with confirmation, create a new bead to capture the outcome). > {{- end }} @@ -45,85 +60,56 @@ prompt: | bd comments {{ $target }} # prior discussion, if any ``` - Read everything carefully. Where the bead references code or features, investigate the **codebase** - just enough to discuss it knowledgeably — but remember you are not here to implement it. + Read everything carefully so you can discuss the topic knowledgeably. Where the bead or the topic + references code or features, investigate the **codebase** just enough to ground the conversation — + but remember you are not here to implement anything. {{- end }} - ## Step 2 — Analyse the bead or current problem: pending decisions & blockers - - Scan the bead or current problem for **decisions that are blocking progress or awaiting input**, and - for anything stopping it from being worked on, e.g.: - - - Open questions in the description, notes, or comments ("should we…?", "TBD", "decide whether…") - - Unresolved trade-offs between approaches, or an undefined acceptance criterion - - A `blocked` / `deferred` status, or labels such as `needs-decision` / `question` / `discuss` - - Unmet dependencies or prerequisites (from the dependency tree) - - Ambiguity that would force an implementer to guess - - **If nothing stands out** — no pending decisions, blockers, or obvious gaps — ask the user what they - want to refine or discuss, via `mitto_ui_options(self_id: "{{ .Session.ID }}", - allow_free_text: true)`, offering focus areas such as *scope*, *approach / design*, *acceptance - criteria*, *risks & edge cases*, or *priority*, plus free text for their own topic. - - ## Step 3 — Propose next steps and pick a direction - - Based on your analysis, propose a **short list of concrete next steps**, each with clear reasoning - for why it matters and what it would settle. Then ask the user **which direction to take** via - `mitto_ui_options(self_id: "{{ .Session.ID }}", allow_free_text: true)`, listing the proposed - steps as options plus free text so they can choose, reprioritise, or describe their own. - - ## Step 4 — Assess the quality of the bead or current problem - - Evaluate whether the bead or current problem is **ready to work on**, checking: - - - **Description** — is it complete and unambiguous? - - **Acceptance criteria** — are they defined, and do they make "done" clear? - - **Dependencies / prerequisites** — are they identified and understood? - - **Actionability** — could an implementer pick this up as-is, or does it need refinement first? + ## Step 2 — Understand the topic - Summarise the result as a short readiness checklist, flagging each item as ✅ clear or ⚠️ needs work. + Restate the topic in your own words to confirm you understood it, and classify what is being asked: + a **question** to answer, an **idea** to evaluate, a **gap** to fill, or a **change** to consider. + Note how it relates to {{ if $target }}the bead's current description, acceptance criteria, and + dependencies{{ else }}the current problem under discussion{{ end }}. If the topic is ambiguous, + ask a single clarifying question via `mitto_ui_options(self_id: "{{ .Session.ID }}", + allow_free_text: true)` before going further. - ## Step 5 — Identify what still needs clarification + ## Step 3 — Investigate and form a view - From the quality assessment, list the **specific gaps or ambiguities** that must be resolved before - the bead or current problem is ready — the open questions, undefined criteria, or unclear - dependencies that would otherwise force an implementer to guess. If it is already in good shape, - say so. + Gather the evidence needed to have a concrete discussion — read {{ if $target }}the bead, related + beads, comments, {{ end }}and the relevant codebase. Then form an initial, reasoned position on the + topic: what you'd recommend and why, along with the trade-offs, risks, and any open questions it + raises. - ## Step 6 — Discuss the gaps with the user + ## Step 4 — Discuss it with the user - Engage the user in **focused discussion** on the gaps from Step 5 (and the direction chosen in - Step 3). Ground the conversation in evidence — investigate the codebase, related beads, and history - as needed so your input is concrete rather than speculative. Batch related questions into a single - `mitto_ui_options(self_id: "{{ .Session.ID }}", allow_free_text: true)` call, offering your - best-guess answer plus free text, and iterate until each gap is resolved into an **actionable - conclusion**. + Engage the user in **focused, iterative discussion** on the topic. Present your view, then batch + related questions into a single `mitto_ui_options(self_id: "{{ .Session.ID }}", + allow_free_text: true)` call — offering your best-guess answer plus free text — and iterate until + the topic is resolved into an **actionable conclusion**. Keep the discussion grounded in evidence + rather than speculation; investigate further whenever a point is uncertain. {{ if $target -}} - ## Step 7 — Update the bead (after confirming) + ## Step 5 — Capture the outcome (after confirming) Everything above is **read-only until the user confirms**. Present exactly what you intend to change and get approval via `mitto_ui_options(self_id: "{{ .Session.ID }}", allow_free_text: true)`, e.g. "Apply these updates to `{{ $target }}`?" with options like **"Apply all"**, **"Apply some"** (free text), and **"Make no changes"**. Never write anything the user did not approve. - Once approved, update the bead to reflect the decisions made: + Once approved, update the bead to reflect the conclusion: ```bash bd update {{ $target }} --body-file /tmp/bead-desc.md # revise the description - bd update {{ $target }} --acceptance "" # if a decision sharpened "done" + bd update {{ $target }} --acceptance "" # if the discussion sharpened "done" bd update {{ $target }} --append-notes "" - bd update {{ $target }} --remove-label needs-decision # drop flags the decision removed - bd update {{ $target }} -s open # un-block now that it can proceed - bd update {{ $target }} -p <0-4> # if the decision changed its priority ``` **Whenever you change the description** (or other substantive fields), **add a comment documenting - the change and the reasoning behind it** — this preserves the decision history and makes it easy to - understand why the bead evolved as it did: + the change and the reasoning behind it** — this preserves the decision history: ```bash - bd comment {{ $target }} "Refined — rationale: " + bd comment {{ $target }} "Discussed — outcome: — rationale: " ``` If the discussion revealed separable work, you may propose sub-issues (title, one-line scope, @@ -133,21 +119,21 @@ prompt: | bd create "" --parent {{ $target }} --type --priority <0-4> --body-file /tmp/child.md ``` {{- else }} - ## Step 7 — Capture the outcome (no bead to update) + ## Step 5 — Capture the outcome (no bead to update) There is no bead to update. If the discussion produced work worth tracking, offer (with confirmation) to create a new bead via the **"Decompose issue"** or **"Start work"** prompt. No `bd` commands. {{- end }} - ## Step 8 — Final summary + ## Step 6 — Final summary - Summarise the work: the decisions resolved (with rationale) and the readiness assessment. + Summarise the discussion: the conclusion reached and the reasoning behind it. {{ if $target -}} Include which bead fields / labels / status changed, the comments added, and any sub-issues created (IDs + titles). {{- end }} Reiterate that **no implementation was done** — point the user to the **"Start work"** prompt when - the bead or current problem is ready. + the bead is ready to be worked on. ## Final step — Offer to delete this conversation @@ -161,7 +147,7 @@ prompt: | 2. Honour the answer: - **Delete** → first notify the user (the deletion is deferred until your turn ends, so the message is delivered first) with - `mitto_ui_notify_mitto(self_id: "{{ .Session.ID }}", title: "", message: "", style: "success")`, + `mitto_ui_notify_mitto(self_id: "{{ .Session.ID }}", title: "", message: "", style: "success")`, then self-destruct with `mitto_conversation_delete_mitto(self_id: "{{ .Session.ID }}", conversation_id: "self")`. - **Keep** → leave the conversation in place. diff --git a/config/prompts/builtin/beads-issue-feature-phase-implement.prompt.yaml b/config/prompts/builtin/beads-issue-feature-phase-implement.prompt.yaml index 1a7871ac..57b46a00 100644 --- a/config/prompts/builtin/beads-issue-feature-phase-implement.prompt.yaml +++ b/config/prompts/builtin/beads-issue-feature-phase-implement.prompt.yaml @@ -55,7 +55,7 @@ prompt: | done — `bd comment` a note that the phase re-ran redundantly and stop **without** re-adding the label. - **Important — this phase runs in its own periodic iteration.** Unlike a single + **Important — this phase runs in its own loop iteration.** Unlike a single long-lived session, each phase of this driver is a separate scheduled run, so any uncommitted work here would be lost between iterations unless committed now (see Step 3). @@ -128,18 +128,18 @@ prompt: | ``` 3. End with a concise handoff message naming the blocker and the single thing - needed, and disable the enclosing conversation's periodic flag so the driver + needed, and disable the enclosing conversation's loop flag so the driver loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` ## Guidelines - **One stage only.** This phase advances at most one label (`implemented`). Do not write tests or self-review — those are separate phases. - - **Commit to persist across iterations.** Each phase runs in a separate periodic + - **Commit to persist across iterations.** Each phase runs in a separate loop iteration; without `Commit: "true"`, work here is only as durable as the conversation's working tree. - **Live state only.** Always re-read labels via `bd show --json` at the start of diff --git a/config/prompts/builtin/beads-issue-feature-phase-plan.prompt.yaml b/config/prompts/builtin/beads-issue-feature-phase-plan.prompt.yaml index 71485160..f92c104e 100644 --- a/config/prompts/builtin/beads-issue-feature-phase-plan.prompt.yaml +++ b/config/prompts/builtin/beads-issue-feature-phase-plan.prompt.yaml @@ -51,7 +51,7 @@ prompt: | Interaction mode depends on how this phase was invoked: - - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} **Silent mode** (scheduled continuation of the driver's loop). Do **not** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. Decide autonomously; if something is genuinely unclear/unsolvable, defer via **Step 4** rather than guess. + - {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} **Silent mode** (scheduled continuation of the driver's loop). Do **not** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. Decide autonomously; if something is genuinely unclear/unsolvable, defer via **Step 4** rather than guess. {{- else }} **Interactive mode** may apply (a user is present, e.g. first send). You *may* ask clarifying questions via `mitto_ui_options` during planning; every other phase decides autonomously or defers. {{- end }} @@ -101,10 +101,10 @@ prompt: | 3. End with a concise handoff message naming the blocker and the single thing needed (interactive runs also `mitto_ui_notify`), and disable the enclosing conversation's - periodic flag so the driver loop does not spin on the blocker: + loop flag so the driver loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` ## Guidelines diff --git a/config/prompts/builtin/beads-issue-feature-phase-review.prompt.yaml b/config/prompts/builtin/beads-issue-feature-phase-review.prompt.yaml index ca8a97e6..4758615f 100644 --- a/config/prompts/builtin/beads-issue-feature-phase-review.prompt.yaml +++ b/config/prompts/builtin/beads-issue-feature-phase-review.prompt.yaml @@ -140,11 +140,11 @@ prompt: | ``` 3. End with a concise handoff message naming the blocker and the single thing - needed, and disable the enclosing conversation's periodic flag so the driver + needed, and disable the enclosing conversation's loop flag so the driver loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` ## Guidelines diff --git a/config/prompts/builtin/beads-issue-feature-phase-test.prompt.yaml b/config/prompts/builtin/beads-issue-feature-phase-test.prompt.yaml index 5f6b49af..ece90612 100644 --- a/config/prompts/builtin/beads-issue-feature-phase-test.prompt.yaml +++ b/config/prompts/builtin/beads-issue-feature-phase-test.prompt.yaml @@ -123,11 +123,11 @@ prompt: | ``` 3. End with a concise handoff message naming the blocker and the single thing - needed, and disable the enclosing conversation's periodic flag so the driver + needed, and disable the enclosing conversation's loop flag so the driver loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` ## Guidelines @@ -136,7 +136,7 @@ prompt: | self-review or close — the review phase runs next. - **Green before green label.** Never add `tested` while any new or existing relevant test is red. - - **Commit to persist across iterations.** Each phase runs in a separate periodic + - **Commit to persist across iterations.** Each phase runs in a separate loop iteration; without `Commit: "true"`, work here is only as durable as the conversation's working tree. - **Live state only.** Always re-read labels via `bd show --json` at the start of diff --git a/config/prompts/builtin/beads-issue-fix-phase-fix.prompt.yaml b/config/prompts/builtin/beads-issue-fix-phase-fix.prompt.yaml index 4cc2dedb..8dc8b8e5 100644 --- a/config/prompts/builtin/beads-issue-fix-phase-fix.prompt.yaml +++ b/config/prompts/builtin/beads-issue-fix-phase-fix.prompt.yaml @@ -132,11 +132,11 @@ prompt: | ``` 3. End with a concise handoff message naming the blocker and the single thing - needed, and disable the enclosing conversation's periodic flag so the driver + needed, and disable the enclosing conversation's loop flag so the driver loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` ## Guidelines diff --git a/config/prompts/builtin/beads-issue-fix-phase-investigate.prompt.yaml b/config/prompts/builtin/beads-issue-fix-phase-investigate.prompt.yaml index cee2b647..2e0eb863 100644 --- a/config/prompts/builtin/beads-issue-fix-phase-investigate.prompt.yaml +++ b/config/prompts/builtin/beads-issue-fix-phase-investigate.prompt.yaml @@ -52,7 +52,7 @@ prompt: | speculate about code you have not opened. Interaction mode depends on how this phase was invoked: - - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} **Silent mode** (scheduled continuation of the driver's loop). Do **not** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. Decide autonomously; if something is genuinely unclear/unsolvable, defer via **Step 4** rather than guess. + - {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} **Silent mode** (scheduled continuation of the driver's loop). Do **not** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. Decide autonomously; if something is genuinely unclear/unsolvable, defer via **Step 4** rather than guess. {{- else }} **Interactive mode** may apply (a user is present, e.g. first send). You *may* ask clarifying questions via `mitto_ui_options` during investigation; every other phase decides autonomously or defers. {{- end }} @@ -93,10 +93,10 @@ prompt: | 3. End with a concise handoff message naming the blocker and the single thing needed (interactive runs also `mitto_ui_notify`), and disable the enclosing - conversation's periodic flag so the driver loop does not spin on the blocker: + conversation's loop flag so the driver loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` ## Guidelines diff --git a/config/prompts/builtin/beads-issue-fix-phase-reproduce.prompt.yaml b/config/prompts/builtin/beads-issue-fix-phase-reproduce.prompt.yaml index 2ef918c4..086dc9d0 100644 --- a/config/prompts/builtin/beads-issue-fix-phase-reproduce.prompt.yaml +++ b/config/prompts/builtin/beads-issue-fix-phase-reproduce.prompt.yaml @@ -102,11 +102,11 @@ prompt: | ``` 3. End with a concise handoff message naming the blocker and the single thing - needed, and disable the enclosing conversation's periodic flag so the driver + needed, and disable the enclosing conversation's loop flag so the driver loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` ## Guidelines diff --git a/config/prompts/builtin/beads-issue-iterate-fixing-bug.prompt.yaml b/config/prompts/builtin/beads-issue-loop-fixing-bug.prompt.yaml similarity index 72% rename from config/prompts/builtin/beads-issue-iterate-fixing-bug.prompt.yaml rename to config/prompts/builtin/beads-issue-loop-fixing-bug.prompt.yaml index 8705752c..a56a896d 100644 --- a/config/prompts/builtin/beads-issue-iterate-fixing-bug.prompt.yaml +++ b/config/prompts/builtin/beads-issue-loop-fixing-bug.prompt.yaml @@ -1,5 +1,5 @@ -icon: periodic -name: Iterate fixing bug +icon: loop +name: Loop fixing bug menus: beadsIssues, conversation parameters: - name: IssueID @@ -9,11 +9,11 @@ parameters: - name: Commit type: boolean description: Commit the fix at the end of the fix stage -description: Auto-periodic — drive a bug bead through investigate → reproduce → fix by dispatching per-phase prompts (each on its own model tier), then self-terminate +description: Auto-loop — drive a bug bead through investigate → reproduce → fix by dispatching per-phase prompts (each on its own model tier), then self-terminate backgroundColor: '#FFCDD2' group: Tasks -enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*") && Item.Type == "bug" && Item.Status != "closed"' -periodic: +enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Permissions.CanSendPrompt && Item.Type == "bug" && Item.Status != "closed"' +loop: mode: always trigger: onCompletion delay: 30 @@ -31,7 +31,7 @@ prompt: | Beads is a CLI issue tracker (`bd`). Issues are called "beads" and have IDs like `bd-xyz`. This prompt drives a single `bug`-type bead through a **label-encoded state machine** — `researched` → `reproduced` → `fixed` — advancing **exactly one stage per run**, then - removing its own periodic flag once `fixed` is reached. + removing its own loop flag once `fixed` is reached. **Per-phase model tiering.** This driver does **not** do the investigate / reproduce / fix work inline. It loads the bead's live labels, then **dispatches** the matching @@ -45,7 +45,7 @@ prompt: | {{ $target := "" -}} {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }}{{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} {{ if $target -}} - The **target bug** for this run is `{{ $target }}`{{ if .Session.BeadsIssue }} (from this conversation's linked beads issue — preferred, durable across periodic runs){{ else }} (supplied as the `IssueID` argument){{ end }}. + The **target bug** for this run is `{{ $target }}`{{ if .Session.BeadsIssue }} (from this conversation's linked beads issue — preferred, durable across loop runs){{ else }} (supplied as the `IssueID` argument){{ end }}. {{- else -}} The **target bug** for this run is **not explicitly specified**. This prompt requires one specific `bug` bead — do not guess an ID and do not scan the backlog for candidates. If a @@ -74,9 +74,9 @@ prompt: | ## Interaction Mode — READ THIS FIRST This prompt almost always runs **unattended on a schedule**. - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode — a scheduled periodic run.** + **Silent mode — a scheduled loop run.** - Use **only** `mitto_ui_notify` — non-blocking notifications. - Do **NOT** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. Nobody is watching. Never block waiting for input. @@ -171,6 +171,18 @@ prompt: | ### Step 3c — Dispatch Fix (`reproduced` present, not yet `fixed`) + **First assess parallelizability.** Most bugs have a single root cause and one coherent + fix — in that case do **not** split; dispatch the single Fix phase below. But if the + investigation and reproduction show the fix spans **two or more genuinely independent, + non-conflicting parts** (distinct modules/files, no shared state, no ordering + dependency between them), resolve them **in parallel across child conversations** + instead of one inline phase — provided this conversation can spawn children (it is + top-level and the **Can start conversation** flag is enabled, in addition to **Can Send + Prompt**). See **Step 3f — Parallel fan-out**; after all parts report back and verify, + add the `fixed` label yourself and end the turn (a deliberate, documented exception to + the self-send rule, justified because the work was genuinely distributed). Otherwise + dispatch the single Fix phase: + ``` mitto_conversation_send_prompt( self_id: "{{ .Session.ID }}", @@ -198,11 +210,38 @@ prompt: | Then stop this conversation from re-running: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) mitto_ui_notify(self_id: "{{ .Session.ID }}", title: "Iterate fixing bug — done", message: "", style: "success") ``` After stopping, do nothing further this run. + + ### Step 3f — Parallel fan-out (only when a stage is genuinely decomposable) + + Reached only from a stage above (today: Fix) whose work splits into independent, + non-conflicting parts. **Never** split a single-root-cause fix, work with shared files, + or work with interdependencies — those go through the normal single self-send phase. + Cap at **3–4** parallel children. + + 1. Define each part completely and self-contained: the exact sub-problem, the + **disjoint** set of files/modules it owns (confirm the sets do not overlap), + verification for that part, and a definition of done. + 2. For each part, reuse a suitable **idle** child from `{{ .Children.MCPText }}` when + possible, otherwise create one with `mitto_conversation_new` (prefer a + faster/cheaper Coding-tier agent), seeding a fully self-contained worker prompt that + ends by reporting via `mitto_children_tasks_report`. + 3. Block on all of them: + `mitto_children_tasks_wait(self_id: "{{ .Session.ID }}", children_list: [], task_id: "fix-{{ $target }}", timeout_seconds: 3600)`. + On timeout, retry the pending children with the **same** `task_id` (omit the prompt); + after a second timeout treat those parts as failed and finish the remainder via a + single Fix phase on the next run. + 4. **Synthesize** the reports and verify the combined fix (the reproduction test passes, + build/lint clean). If verified, record a `Fix:` comment summarising the distributed + fix, add the `fixed` label{{ if eq .Args.Commit "true" }} and commit only the changed + files (staged by path){{ end }}, archive the finished children, and end the turn. If + any part reports a conflict, abandon the split and finish the fix via the single Fix + phase on the next run. If a spawn call errors because **Can start conversation** is + off, fall back to the single Fix phase (Step 3c). {{- else }} ## Step 1 — No target bug to work on @@ -230,24 +269,33 @@ prompt: | 3. End the iteration with a concise handoff message naming the blocker and the single thing needed (interactive runs also `mitto_ui_notify`), and disable this - conversation's own periodic flag so the loop does not spin on the blocker: + conversation's own loop flag so the loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` - The user resumes the loop (re-enabling periodic, or force-running) after clearing the + The user resumes the loop (re-enabling the loop, or force-running) after clearing the `needs-human` label and addressing the handoff. ## Guidelines - **One phase per run.** Dispatch a single phase prompt (`researched` → `reproduced` → `fixed`) via `mitto_conversation_send_prompt`, then end the turn. Never skip a - stage or dispatch two at once. + stage or dispatch two at once. The sole exception is **Step 3f (Parallel fan-out)**, + which resolves one genuinely decomposable stage across parallel children within a + single run. + - **Parallelize only when genuinely independent.** Prefer the single self-send phase by + default. Only fan out (Step 3f) when the stage's work splits into disjoint, + non-conflicting parts with no interdependencies and children can be spawned; never + split a single-root-cause fix or work that shares files. - **Never do the phase work inline.** The whole point of this driver is per-phase model tiering. If you catch yourself running `bd update ... --add-label` for `researched`/`reproduced`/`fixed` inside *this* prompt, stop — that is the phase - prompt's job. This driver only labels `needs-human` (Step 4) and closes on Done. + prompt's job. This driver only labels `needs-human` (Step 4) and closes on Done. The + one documented exception is **Step 3f**: when a stage is distributed across parallel + children, this driver adds that stage's label itself after synthesizing and verifying + the children's reports. - **Live state only.** Always re-read labels via `bd show --json` at the start of the run; never assume labels from a prior run or from `Item.*`. - **Decide autonomously; never guess.** The only time you must not proceed is when diff --git a/config/prompts/builtin/beads-issue-iterate-fixing-bugs.prompt.yaml b/config/prompts/builtin/beads-issue-loop-fixing-bugs.prompt.yaml similarity index 90% rename from config/prompts/builtin/beads-issue-iterate-fixing-bugs.prompt.yaml rename to config/prompts/builtin/beads-issue-loop-fixing-bugs.prompt.yaml index 5d62156c..4e3d1c09 100644 --- a/config/prompts/builtin/beads-issue-iterate-fixing-bugs.prompt.yaml +++ b/config/prompts/builtin/beads-issue-loop-fixing-bugs.prompt.yaml @@ -1,5 +1,5 @@ -icon: periodic -name: Iterate fixing bugs +icon: loop +name: Loop fixing bugs menus: beadsList parameters: - name: Commit @@ -8,7 +8,7 @@ parameters: description: One-shot list orchestrator — fix eligible open bugs one at a time by spawning a self-driving per-bug loop for each, waiting for it to finish, then moving on backgroundColor: '#FFCDD2' group: Tasks -enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*")' +enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Permissions.CanStartConversation' prompt: | ## Session Context @@ -28,12 +28,12 @@ prompt: | up, then move to the next bug. Stop when the eligible set is empty or a per-run budget is hit. - **INNER loop (already shipped: the `Iterate fixing bug` prompt, mitto-gap.1).** Each - child runs that per-bug driver as an `onCompletion` periodic, advancing one + child runs that per-bug driver as an `onCompletion` loop, advancing one `researched → reproduced → fixed` label per re-fire, then self-terminating. - This prompt itself is a **single, non-periodic run that loops internally**. It has no - `periodic:` block — one send, one whole outer pass, one stop. The children are the - periodic ones. + This prompt itself is a **single, non-loop run that loops internally**. It has no + `loop:` block — one send, one whole outer pass, one stop. The children are the + loop ones. ## Preflight — required flags, top-level only, degrade gracefully @@ -115,8 +115,8 @@ prompt: | - the eligible set (Step 2) is empty, OR - this run has already processed **N** bugs, OR - `.Iteration.IsLast` is true (a future-proofing hook — this prompt has no - `periodic:` block today, so `.Iteration.IsLast` is false in practice, but future - variants may make it periodic; honouring the flag now keeps this driver + `loop:` block today, so `.Iteration.IsLast` is false in practice, but future + variants may make it a loop; honouring the flag now keeps this driver forward-compatible). When you stop, jump to **Step 7 (Final)**. @@ -124,7 +124,7 @@ prompt: | ## Step 4 — Spawn ONE child for the highest-priority eligible bug Take the first entry from Step 2's ordered set — call it **``**. Spawn exactly - ONE child conversation whose seed AND periodic re-fire are both ``. Link + ONE child conversation whose seed AND loop re-fire are both ``. Link it to `` via `beads_issue` so the per-bug driver resolves its target durably from `.Session.BeadsIssue` on every re-fire (this is why the child does not need to know its own `IssueID` after the seed run): @@ -136,22 +136,22 @@ prompt: | beads_issue: "", initial_prompt: , arguments: { "IssueID": "", "Commit": "{{ if eq .Args.Commit "false" }}false{{ else }}true{{ end }}" }, - periodic_prompt: , - periodic_trigger: "onCompletion", - periodic_completion_delay_seconds: 30, - periodic_max_iterations: 20, - periodic_max_duration_seconds: 14400 + loop_prompt: , + loop_trigger: "onCompletion", + loop_completion_delay_seconds: 30, + loop_max_iterations: 20, + loop_max_duration_seconds: 14400 ) ``` Notes on why each argument is what it is: - **`initial_prompt: ` + `arguments`** — `mitto_conversation_new` does - NOT auto-apply a fetched prompt's own `periodic:` block. To make the child + NOT auto-apply a fetched prompt's own `loop:` block. To make the child self-drive its state machine, the same body must be provided as both the seed AND - the periodic re-fire. The `arguments` map fills the driver's template + the loop re-fire. The `arguments` map fills the driver's template placeholders on the seed run. - - **`periodic_prompt: `** — every `onCompletion` re-fire uses this text + - **`loop_prompt: `** — every `onCompletion` re-fire uses this text verbatim. It resolves its target from `.Session.BeadsIssue` (set here via `beads_issue`), so it does not need `IssueID`/`Commit` in the arguments map on re-fires. The initial `Commit` argument only affects whichever run dispatches the @@ -231,7 +231,7 @@ prompt: | ) ``` - This prompt has no `periodic:` block, so there is no re-fire to disable — a single + This prompt has no `loop:` block, so there is no re-fire to disable — a single natural end-of-turn stops the outer loop. ## Step 8 — Blocked → Defer + Handoff (spawn / preflight failures) @@ -254,7 +254,7 @@ prompt: | ## Guidelines - **Serial by design.** Exactly one child in flight at a time. Do not fan out — the - per-bug loop itself is periodic and can take hours; parallel spawns would blow + per-bug loop itself is a loop and can take hours; parallel spawns would blow past the max-children cap and be impossible to reason about. - **Top-level only.** Only a non-child conversation may spawn. If this run finds itself a child (`Session.IsChild`), stop with a notify — do not touch `bd`, do diff --git a/config/prompts/builtin/beads-issue-iterate-implementing-feature.prompt.yaml b/config/prompts/builtin/beads-issue-loop-implementing-feature.prompt.yaml similarity index 73% rename from config/prompts/builtin/beads-issue-iterate-implementing-feature.prompt.yaml rename to config/prompts/builtin/beads-issue-loop-implementing-feature.prompt.yaml index 342bd68c..4a9d254d 100644 --- a/config/prompts/builtin/beads-issue-iterate-implementing-feature.prompt.yaml +++ b/config/prompts/builtin/beads-issue-loop-implementing-feature.prompt.yaml @@ -1,5 +1,5 @@ -icon: periodic -name: Iterate implementing feature +icon: loop +name: Loop implementing feature menus: beadsIssues, conversation parameters: - name: IssueID @@ -9,11 +9,11 @@ parameters: - name: Commit type: boolean description: Commit the work at the end of the review stage -description: Auto-periodic — drive a feature bead through plan → implement → test → review by dispatching per-phase prompts (each on its own model tier), then self-terminate +description: Auto-loop — drive a feature bead through plan → implement → test → review by dispatching per-phase prompts (each on its own model tier), then self-terminate backgroundColor: '#C8E6C9' group: Tasks -enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*") && Item.Type == "feature" && Item.Status != "closed"' -periodic: +enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Permissions.CanSendPrompt && Item.Type == "feature" && Item.Status != "closed"' +loop: mode: always trigger: onCompletion delay: 30 @@ -31,7 +31,7 @@ prompt: | Beads is a CLI issue tracker (`bd`). Issues are called "beads" and have IDs like `bd-xyz`. This prompt drives a single `feature`-type bead through a **label-encoded state machine** — `planned` → `implemented` → `tested` → `verified` — advancing **exactly - one stage per run**, then removing its own periodic flag once `verified` is reached. + one stage per run**, then removing its own loop flag once `verified` is reached. **Per-phase model tiering.** This driver does **not** do the plan / implement / test / review work inline. It loads the bead's live labels, then **dispatches** the matching @@ -45,7 +45,7 @@ prompt: | {{ $target := "" -}} {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }}{{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} {{ if $target -}} - The **target feature** for this run is `{{ $target }}`{{ if .Session.BeadsIssue }} (from this conversation's linked beads issue — preferred, durable across periodic runs){{ else }} (supplied as the `IssueID` argument){{ end }}. + The **target feature** for this run is `{{ $target }}`{{ if .Session.BeadsIssue }} (from this conversation's linked beads issue — preferred, durable across loop runs){{ else }} (supplied as the `IssueID` argument){{ end }}. {{- else -}} The **target feature** for this run is **not explicitly specified**. This prompt requires one specific `feature` bead — do not guess an ID and do not scan the backlog for @@ -74,9 +74,9 @@ prompt: | ## Interaction Mode — READ THIS FIRST This prompt almost always runs **unattended on a schedule**. - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode — a scheduled periodic run.** + **Silent mode — a scheduled loop run.** - Use **only** `mitto_ui_notify` — non-blocking notifications. - Do **NOT** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. Nobody is watching. Never block waiting for input. @@ -157,6 +157,18 @@ prompt: | ### Step 3b — Dispatch Implement (`planned` present, not yet `implemented`) + **First assess parallelizability.** If the plan is a single coherent increment, do + **not** split; dispatch the single Implement phase below. But if the plan decomposes + into **two or more genuinely independent, non-conflicting parts** (distinct + modules/files, no shared state, no ordering dependency — e.g. the plan phase already + carved the feature into independent sub-issues), implement them **in parallel across + child conversations** instead of one inline phase — provided this conversation can spawn + children (it is top-level and the **Can start conversation** flag is enabled, in + addition to **Can Send Prompt**). See **Step 3f — Parallel fan-out**; after all parts + report back and verify, add the `implemented` label yourself and end the turn (a + deliberate, documented exception to the self-send rule, justified because the work was + genuinely distributed). Otherwise dispatch the single Implement phase: + ``` mitto_conversation_send_prompt( self_id: "{{ .Session.ID }}", @@ -221,11 +233,40 @@ prompt: | Then self-terminate so this conversation stops re-running: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) mitto_ui_notify(self_id: "{{ .Session.ID }}", title: "Iterate implementing feature — done", message: "", style: "success") ``` After stopping, do nothing further this run. + + ### Step 3f — Parallel fan-out (only when a stage is genuinely decomposable) + + Reached only from a stage above (today: Implement) whose work splits into independent, + non-conflicting parts — typically when the plan phase already decomposed the feature + into independent sub-issues. **Never** split a tightly-coupled increment, work with + shared files, or work with interdependencies — those go through the normal single + self-send phase. Cap at **3–4** parallel children. + + 1. Define each part completely and self-contained: the exact sub-scope (ideally a + sub-issue ID), the **disjoint** set of files/modules it owns (confirm the sets do not + overlap), acceptance criteria, and a definition of done. + 2. For each part, reuse a suitable **idle** child from `{{ .Children.MCPText }}` when + possible, otherwise create one with `mitto_conversation_new` (prefer a + faster/cheaper Coding-tier agent), seeding a fully self-contained worker prompt that + ends by reporting via `mitto_children_tasks_report`. + 3. Block on all of them: + `mitto_children_tasks_wait(self_id: "{{ .Session.ID }}", children_list: [], task_id: "impl-{{ $target }}", timeout_seconds: 3600)`. + On timeout, retry the pending children with the **same** `task_id` (omit the prompt); + after a second timeout treat those parts as failed and finish the remainder via a + single Implement phase on the next run. + 4. **Synthesize** the reports and verify the combined increment (build/lint clean, the + parts integrate). If verified, record an `Implementation:` comment summarising the + distributed work, add the `implemented` label{{ if eq .Args.Commit "true" }} and + commit only the changed files (staged by path){{ end }}, archive the finished + children, and end the turn. The next scheduled run will observe `implemented` and + dispatch Test. If any part reports a conflict, abandon the split and finish via the + single Implement phase on the next run. If a spawn call errors because **Can start + conversation** is off, fall back to the single Implement phase (Step 3b). {{- else }} ## Step 1 — No target feature to work on @@ -253,25 +294,33 @@ prompt: | 3. End the iteration with a concise handoff message naming the blocker and the single thing needed (interactive runs also `mitto_ui_notify`), and disable this - conversation's own periodic flag so the loop does not spin on the blocker: + conversation's own loop flag so the loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` - The user resumes the loop (re-enabling periodic, or force-running) after clearing the + The user resumes the loop (re-enabling the loop, or force-running) after clearing the `needs-human` label and addressing the handoff. ## Guidelines - **One phase per run.** Dispatch a single phase prompt (`planned` → `implemented` → `tested` → `verified`) via `mitto_conversation_send_prompt`, then end the turn. - Never skip a stage or dispatch two at once. + Never skip a stage or dispatch two at once. The sole exception is **Step 3f (Parallel + fan-out)**, which resolves one genuinely decomposable stage across parallel children + within a single run. + - **Parallelize only when genuinely independent.** Prefer the single self-send phase by + default. Only fan out (Step 3f) when the stage's work splits into disjoint, + non-conflicting parts with no interdependencies and children can be spawned; never + split a tightly-coupled increment or work that shares files. - **Never do the phase work inline.** The whole point of this driver is per-phase model tiering. If you catch yourself running `bd update ... --add-label` for `planned`/`implemented`/`tested`/`verified` inside *this* prompt, stop — that is the phase prompt's job. This driver only labels `needs-human` (Step 4) and closes on - Done. + Done. The one documented exception is **Step 3f**: when a stage is distributed across + parallel children, this driver adds that stage's label itself after synthesizing and + verifying the children's reports. - **Live state only.** Always re-read labels via `bd show --json` at the start of the run; never assume labels from a prior run or from `Item.*`. - **Decide autonomously; never guess.** The only time you must not proceed is when diff --git a/config/prompts/builtin/beads-issue-iterate-implementing-features.prompt.yaml b/config/prompts/builtin/beads-issue-loop-implementing-features.prompt.yaml similarity index 90% rename from config/prompts/builtin/beads-issue-iterate-implementing-features.prompt.yaml rename to config/prompts/builtin/beads-issue-loop-implementing-features.prompt.yaml index 52f83b4b..846114f8 100644 --- a/config/prompts/builtin/beads-issue-iterate-implementing-features.prompt.yaml +++ b/config/prompts/builtin/beads-issue-loop-implementing-features.prompt.yaml @@ -1,5 +1,5 @@ -icon: periodic -name: Iterate implementing features +icon: loop +name: Loop implementing features menus: beadsList parameters: - name: Commit @@ -8,7 +8,7 @@ parameters: description: One-shot list orchestrator — implement eligible open features one at a time by spawning a self-driving per-feature loop for each, waiting for it to finish, then moving on backgroundColor: '#C8E6C9' group: Tasks -enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*")' +enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Permissions.CanStartConversation' prompt: | ## Session Context @@ -29,12 +29,12 @@ prompt: | set is empty or a per-run budget is hit. - **INNER loop (already shipped: the `Iterate implementing feature` prompt, mitto-gap.5).** Each child runs that per-feature driver as an `onCompletion` - periodic, advancing one `planned → implemented → tested → verified` label per + loop, advancing one `planned → implemented → tested → verified` label per re-fire, then self-terminating. - This prompt itself is a **single, non-periodic run that loops internally**. It has no - `periodic:` block — one send, one whole outer pass, one stop. The children are the - periodic ones. + This prompt itself is a **single, non-loop run that loops internally**. It has no + `loop:` block — one send, one whole outer pass, one stop. The children are the + loop ones. ## Preflight — required flags, top-level only, degrade gracefully @@ -117,8 +117,8 @@ prompt: | - the eligible set (Step 2) is empty, OR - this run has already processed **N** features, OR - `.Iteration.IsLast` is true (a future-proofing hook — this prompt has no - `periodic:` block today, so `.Iteration.IsLast` is false in practice, but future - variants may make it periodic; honouring the flag now keeps this driver + `loop:` block today, so `.Iteration.IsLast` is false in practice, but future + variants may make it a loop; honouring the flag now keeps this driver forward-compatible). When you stop, jump to **Step 7 (Final)**. @@ -126,7 +126,7 @@ prompt: | ## Step 4 — Spawn ONE child for the highest-priority eligible feature Take the first entry from Step 2's ordered set — call it **``**. Spawn exactly - ONE child conversation whose seed AND periodic re-fire are both ``. Link + ONE child conversation whose seed AND loop re-fire are both ``. Link it to `` via `beads_issue` so the per-feature driver resolves its target durably from `.Session.BeadsIssue` on every re-fire (this is why the child does not need to know its own `IssueID` after the seed run): @@ -138,22 +138,22 @@ prompt: | beads_issue: "", initial_prompt: , arguments: { "IssueID": "", "Commit": "{{ if eq .Args.Commit "false" }}false{{ else }}true{{ end }}" }, - periodic_prompt: , - periodic_trigger: "onCompletion", - periodic_completion_delay_seconds: 30, - periodic_max_iterations: 30, - periodic_max_duration_seconds: 28800 + loop_prompt: , + loop_trigger: "onCompletion", + loop_completion_delay_seconds: 30, + loop_max_iterations: 30, + loop_max_duration_seconds: 28800 ) ``` Notes on why each argument is what it is: - **`initial_prompt: ` + `arguments`** — `mitto_conversation_new` does - NOT auto-apply a fetched prompt's own `periodic:` block. To make the child + NOT auto-apply a fetched prompt's own `loop:` block. To make the child self-drive its state machine, the same body must be provided as both the seed AND - the periodic re-fire. The `arguments` map fills the driver's template + the loop re-fire. The `arguments` map fills the driver's template placeholders on the seed run. - - **`periodic_prompt: `** — every `onCompletion` re-fire uses this text + - **`loop_prompt: `** — every `onCompletion` re-fire uses this text verbatim. It resolves its target from `.Session.BeadsIssue` (set here via `beads_issue`), so it does not need `IssueID`/`Commit` in the arguments map on re-fires. The initial `Commit` argument only affects whichever run dispatches the @@ -234,7 +234,7 @@ prompt: | ) ``` - This prompt has no `periodic:` block, so there is no re-fire to disable — a single + This prompt has no `loop:` block, so there is no re-fire to disable — a single natural end-of-turn stops the outer loop. ## Step 8 — Blocked → Defer + Handoff (spawn / preflight failures) @@ -257,7 +257,7 @@ prompt: | ## Guidelines - **Serial by design.** Exactly one child in flight at a time. Do not fan out — the - per-feature loop itself is periodic and can take hours; parallel spawns would blow + per-feature loop itself is a loop and can take hours; parallel spawns would blow past the max-children cap and be impossible to reason about. - **Top-level only.** Only a non-child conversation may spawn. If this run finds itself a child (`Session.IsChild`), stop with a notify — do not touch `bd`, do diff --git a/config/prompts/builtin/beads-issue-iterate-until-complete.prompt.yaml b/config/prompts/builtin/beads-issue-loop-until-complete.prompt.yaml similarity index 95% rename from config/prompts/builtin/beads-issue-iterate-until-complete.prompt.yaml rename to config/prompts/builtin/beads-issue-loop-until-complete.prompt.yaml index b1819403..19950e37 100644 --- a/config/prompts/builtin/beads-issue-iterate-until-complete.prompt.yaml +++ b/config/prompts/builtin/beads-issue-loop-until-complete.prompt.yaml @@ -1,5 +1,5 @@ -icon: periodic -name: Iterate until issue complete +icon: loop +name: Loop until issue complete menus: beadsIssues, conversation parameters: - name: IssueID @@ -9,11 +9,11 @@ parameters: - name: Commit type: boolean description: Have the delegated worker commit its changes at the end of each increment -description: Auto-periodic — keep advancing this bead toward completion, then self-terminate when nothing ready remains +description: Auto-loop — keep advancing this bead toward completion, then self-terminate when nothing ready remains backgroundColor: '#C8E6C9' group: Tasks -enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*") && Item.Status != "closed"' -periodic: +enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Permissions.CanStartConversation && Item.Status != "closed"' +loop: mode: always trigger: onCompletion delay: 30 @@ -26,18 +26,18 @@ prompt: | Available ACP servers: `{{ .ACP.AvailableText }}` Existing children: `{{ .Children.MCPText }}` - # Beads: Iterate Until Issue Complete + # Beads: Loop Until Issue Complete Beads is a CLI issue tracker (`bd`). Issues are called "beads" and have IDs like `bd-xyz`. {{ if .Session.BeadsIssue -}} - The **target bead** for this run is `{{ .Session.BeadsIssue }}` (from this conversation's linked beads issue — preferred, durable across periodic runs). + The **target bead** for this run is `{{ .Session.BeadsIssue }}` (from this conversation's linked beads issue — preferred, durable across loop runs). {{- else if .Args.IssueID -}} The **target bead** for this run is `{{ .Args.IssueID }}` (supplied as the `IssueID` argument). {{- else -}} The **target bead** for this run is **not explicitly specified** — infer it from this conversation (recent messages, current git branch, linked PRs). If you cannot determine it confidently, **do not ask the user which ticket to work on** — just pick **any ready, unblocked bead** in scope (apply the Step 2 ranking: highest blocking leverage, then highest declared priority). Any ready, not-blocked bead is fair game. Only if there is no ready, unblocked bead at all do you stop (Step 5). {{- end }} - This is the **automated, non-interactive** sibling of "Start work": on every scheduled run you advance the target **one concrete increment** toward completion, and when there is **nothing ready left to do in scope**, you remove your own periodic flag and stop. + This is the **automated, non-interactive** sibling of "Start work": on every scheduled run you advance the target **one concrete increment** toward completion, and when there is **nothing ready left to do in scope**, you remove your own loop flag and stop. {{- if .Iteration.IsUninterrupted }} ## Continuation — uninterrupted scheduled run @@ -62,9 +62,9 @@ prompt: | ## Interaction Mode — READ THIS FIRST This prompt almost always runs **unattended on a schedule**. - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode — a scheduled periodic run.** + **Silent mode — a scheduled loop run.** - Use **only** `mitto_ui_notify` — non-blocking notifications. - Do **NOT** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. Nobody is watching. Never block waiting for input. @@ -137,7 +137,7 @@ prompt: | ## Step 1b — Keep the durable conversation link correct - This conversation's linked beads issue is the **durable anchor** that future periodic + This conversation's linked beads issue is the **durable anchor** that future loop runs resolve from (see the target resolution above), so it must always point at the **top-level target** — the epic or standalone issue — never at a per-run child: @@ -254,11 +254,11 @@ prompt: | When there is **nothing ready** left within the target bead's scope (the issue itself is done/deferred/blocked, and — for an epic — no child is ready), the - loop is finished. **Self-terminate**: remove your own periodic flag so this - becomes a regular (non-periodic) conversation, and post a final summary. + loop is finished. **Self-terminate**: remove your own loop flag so this + becomes a regular (non-loop) conversation, and post a final summary. ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` Then notify the user (works in both modes): diff --git a/config/prompts/builtin/beads-issue-work.prompt.yaml b/config/prompts/builtin/beads-issue-work.prompt.yaml index 2b78c45d..8bf5bcc7 100644 --- a/config/prompts/builtin/beads-issue-work.prompt.yaml +++ b/config/prompts/builtin/beads-issue-work.prompt.yaml @@ -9,7 +9,7 @@ parameters: description: Plan this bead and spawn parallel Mitto conversations to implement it backgroundColor: '#B2DFDB' group: Tasks -enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*") && Item.Status != "closed"' +enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Permissions.CanStartConversation && Item.Status != "closed"' prompt: | ## Session Context diff --git a/config/prompts/builtin/beads-new-issue.prompt.yaml b/config/prompts/builtin/beads-new-issue.prompt.yaml index b62e0e2d..3adadb36 100644 --- a/config/prompts/builtin/beads-new-issue.prompt.yaml +++ b/config/prompts/builtin/beads-new-issue.prompt.yaml @@ -1,6 +1,6 @@ icon: plus name: New issue -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Create a beads issue — from the current conversation context or from scratch backgroundColor: '#C8E6C9' group: Tasks diff --git a/config/prompts/builtin/beads-publish-post.prompt.yaml b/config/prompts/builtin/beads-publish-post.prompt.yaml index ac199bed..704038de 100644 --- a/config/prompts/builtin/beads-publish-post.prompt.yaml +++ b/config/prompts/builtin/beads-publish-post.prompt.yaml @@ -1,4 +1,4 @@ -icon: periodic +icon: loop name: Publish post menus: beadsIssues, conversation parameters: @@ -6,11 +6,11 @@ parameters: type: beadsId required: false description: The beads issue ID to act on -description: Auto-periodic — drive a blog-post bead through draft → review → publish (one label per run), then self-terminate +description: Auto-loop — drive a blog-post bead through draft → review → publish (one label per run), then self-terminate backgroundColor: '#BBDEFB' group: Tasks -enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*") && "blog" in Item.Labels && Item.Status != "closed"' -periodic: +enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && "blog" in Item.Labels && Item.Status != "closed"' +loop: mode: always trigger: onCompletion delay: 30 @@ -28,7 +28,7 @@ prompt: | Beads is a CLI issue tracker (`bd`). Issues are called "beads" and have IDs like `bd-xyz`. This prompt drives a single bead carrying the `blog` label through a **label-encoded state machine** — `drafted` → `reviewed` → `published` — advancing **exactly one stage - per run**, then removing its own periodic flag once `published` is reached. + per run**, then removing its own loop flag once `published` is reached. Unlike the `Iterate fixing bug` driver, this prompt does the per-stage work **inline** (no per-phase model tiering / dispatch). Each `onCompletion` re-fire reads the bead's @@ -37,7 +37,7 @@ prompt: | {{ $target := "" -}} {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }}{{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} {{ if $target -}} - The **target post** for this run is `{{ $target }}`{{ if .Session.BeadsIssue }} (from this conversation's linked beads issue — preferred, durable across periodic runs){{ else }} (supplied as the `IssueID` argument){{ end }}. + The **target post** for this run is `{{ $target }}`{{ if .Session.BeadsIssue }} (from this conversation's linked beads issue — preferred, durable across loop runs){{ else }} (supplied as the `IssueID` argument){{ end }}. {{- else -}} The **target post** for this run is **not explicitly specified**. This prompt requires one specific `blog`-labelled bead — do not guess an ID and do not scan the backlog for @@ -66,9 +66,9 @@ prompt: | ## Interaction Mode — READ THIS FIRST This prompt almost always runs **unattended on a schedule**. - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode — a scheduled periodic run.** + **Silent mode — a scheduled loop run.** - Use **only** `mitto_ui_notify` — non-blocking notifications. - Do **NOT** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. Nobody is watching. Never block waiting for input. @@ -144,7 +144,7 @@ prompt: | Re-read the drafted comment from Step 1's `bd show ... --include-comments` output and self-review it for tone, factual accuracy, working links, code fences, front-matter, headings, and formatting. Note any concrete edits you apply. - {{- if or .Iteration.IsFirst (not .Session.IsPeriodic) .Session.IsPeriodicForced }} + {{- if or .Iteration.IsFirst (not .Session.IsLoop) .Session.IsLoopForced }} On this interactive run you *may* surface the reviewed draft for sign-off via `mitto_ui_options` (Approve / Request changes) or `mitto_ui_notify` before adding the @@ -202,7 +202,7 @@ prompt: | All three stages are complete. Stop this conversation from re-running: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) mitto_ui_notify(self_id: "{{ .Session.ID }}", title: "Publish post — done", message: "", style: "success") ``` @@ -235,13 +235,13 @@ prompt: | 3. End the iteration with a concise handoff message naming the blocker and the single thing needed (interactive runs also `mitto_ui_notify`), and disable this - conversation's own periodic flag so the loop does not spin on the blocker: + conversation's own loop flag so the loop does not spin on the blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` - The user resumes the loop (re-enabling periodic, or force-running) after clearing the + The user resumes the loop (re-enabling the loop, or force-running) after clearing the `needs-human` label and addressing the handoff. ## Guidelines diff --git a/config/prompts/builtin/beads-refine-implementation.prompt.yaml b/config/prompts/builtin/beads-refine-implementation.prompt.yaml new file mode 100644 index 00000000..f27ea9fa --- /dev/null +++ b/config/prompts/builtin/beads-refine-implementation.prompt.yaml @@ -0,0 +1,123 @@ +icon: list +name: Refine issue implementation details +menus: prompts, beadsList +description: Analyze open beads that have not yet been refined, fill in enough implementation detail for a faster model to implement them, label the refined ones, and defer the ones that cannot be refined. Runs on a smart model as an onTasks loop that fires whenever beads change. +backgroundColor: '#C5CAE9' +group: Tasks +singleton: true +enabledWhen: CommandExists("bd") && DirExists(".beads") +preferredModels: + - modelTag: Smart +loop: + mode: always + trigger: onTasks + condition: 'Changes.Touched.exists(i, i.status == "open" && !("implementation-refined" in i.labels))' + maxIterations: 20 + maxDuration: "4h" +prompt: | + ## Session Context + + Your session ID is `{{ .Session.ID }}` — use this as `self_id` for all `mitto_*` MCP tool calls. + + # Beads: Refine Implementation Details + + Beads is a CLI issue tracker (`bd`). Issues are called "beads" and have IDs like `bd-xyz`. + + You are running on a **smart / high-reasoning model**. Your job is to take beads that have + **not yet been refined** and leave each one with **enough concrete implementation detail that a + faster, cheaper model can fully implement it without further design work** — or, when that is not + possible, **defer** the bead so it drops out of the active queue until a human clarifies it. + + The label **`implementation-refined`** is the flag that marks a bead as already refined. A bead + is a candidate for this pass when it is **open**, **not deferred**, and does **not** carry the + `implementation-refined` label. + + {{- if or .Iteration.IsUninterrupted (and .Session.IsLoop (not .Session.IsLoopForced)) }} + **Silent mode** (scheduled loop run — the user is not watching): act autonomously and safely. Do + **not** use interactive tools (`mitto_ui_options`, `mitto_ui_form`, `mitto_ui_textbox`). Report + outcomes and failures with `mitto_ui_notify` only. + {{- else }} + **Interactive mode** (forced run or first/normal send): you may summarize your findings and use + interactive tools where helpful, but the refinement itself should still be applied autonomously. + {{- end }} + + ## Step 1 — Select the beads to refine + + Load the candidate beads (open, not deferred, not yet refined): + + ```bash + bd list --status open --exclude-label implementation-refined -n 0 --json + ``` + + If the list is empty, there is nothing to do — skip to Step 5 and report "no unrefined beads". + + **Cap this run at ~5 beads.** Prefer the highest-priority (lowest P number) and oldest beads first. + The label/defer flags below make the pass idempotent, so remaining beads are picked up on the next run. + + ## Step 2 — Analyze each selected bead + + For each selected bead, load its full state and understand what it really requires: + + ```bash + bd show --long --json # full description, acceptance, design, notes, metadata + bd dep tree # blockers and what it blocks + ``` + + Then investigate the **actual codebase** so the plan is grounded in reality — find the files, + functions, types, and existing patterns the work will touch (use your code-search and file-reading + tools). Do not guess at structure you can verify. + + Decide whether the bead can be turned into an **implementation-ready** spec. It is ready only when a + faster model could implement it end-to-end **without further design decisions**, i.e. you can state: + + - **Scope**: precisely what to change and, importantly, what is out of scope. + - **Files & symbols**: the concrete files, functions, types, and call sites to touch (with paths). + - **Approach**: the step-by-step implementation plan, in order. + - **Edge cases & downstream impact**: callers, tests, migrations, config that must be updated. + - **Acceptance criteria**: how to verify it is done (tests to add/run, expected behavior). + + ## Step 3 — When the bead CAN be refined + + Write the implementation detail back into the bead and flag it as refined: + + ```bash + bd update --design "" + bd update --acceptance "" # only if missing or thin + bd update --append-notes "Refined for implementation on {{ .Session.ID }}: plan added to design; ready for a faster model to implement." + bd label add implementation-refined + ``` + + Keep the design **self-contained** — a faster model should not need to re-derive context. Reference + exact paths and existing patterns rather than vague descriptions. + + ## Step 4 — When the bead CANNOT be refined → defer it + + If, after honest analysis, the bead is not implementation-ready — **open questions remain**, + **key details are missing**, requirements are **ambiguous or contradictory**, it needs a + **product/human decision**, or the design cannot be pinned down — do **not** guess and do **not** + add the `implementation-refined` label. Instead, record why and defer it: + + ```bash + bd update --append-notes "Deferred during refinement on {{ .Session.ID }}: cannot produce an implementation-ready spec. Blockers/questions: ." + bd defer + ``` + + Deferred beads leave the active queue (they no longer match the open + unrefined filter) until a + human revisits them, so they will not be re-processed on the next run. + + ## Step 5 — Report + + Finish with a concise summary: + + ### Refined + For each bead you refined: `bead-id` · title · one line on the plan you wrote. + + ### Deferred + For each bead you deferred: `bead-id` · title · the specific open question(s) blocking refinement. + + {{- if or .Iteration.IsUninterrupted (and .Session.IsLoop (not .Session.IsLoopForced)) }} + Deliver this summary via `mitto_ui_notify` (silent mode). Use `style: "warning"` if any bead was + deferred or any `bd` command failed, otherwise `style: "success"`. Report failing commands verbatim. + {{- else }} + Present this summary directly. Report any `bd` command that failed and why. + {{- end }} diff --git a/config/prompts/builtin/beads-triage-bugs.prompt.yaml b/config/prompts/builtin/beads-triage-bugs.prompt.yaml index 39381931..d649c8da 100644 --- a/config/prompts/builtin/beads-triage-bugs.prompt.yaml +++ b/config/prompts/builtin/beads-triage-bugs.prompt.yaml @@ -1,11 +1,11 @@ icon: tag name: Triage untriaged bugs menus: prompts, beadsList -description: Auto-periodic — triage every open untriaged bug in a single pass, mark each `triaged` so future runs skip it, and self-terminate once no untriaged bugs remain +description: Auto-loop — triage every open untriaged bug in a single pass, mark each `triaged` so future runs skip it, and self-terminate once no untriaged bugs remain backgroundColor: '#B3E5FC' group: Tasks -enabledWhen: 'CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*")' -periodic: +enabledWhen: 'CommandExists("bd") && DirExists(".beads")' +loop: mode: always trigger: onCompletion delay: 30 @@ -52,9 +52,9 @@ prompt: | This prompt is designed to run **unattended on a schedule**, but the first send (and any force-triggered run) may have a user present. - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode — a scheduled periodic run.** + **Silent mode — a scheduled loop run.** - Use **only** `mitto_ui_notify` — non-blocking notifications. - Do **NOT** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. Nobody is watching. **Auto-apply** all triage actions. @@ -103,7 +103,7 @@ prompt: | so it doesn't keep re-firing on an empty backlog: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) mitto_ui_notify(self_id: "{{ .Session.ID }}", title: "Bug triage — done", message: "No untriaged open bugs remain.", style: "success") ``` @@ -157,7 +157,7 @@ prompt: | ``` Log a running tally as you go so the closing summary in Step 6 is accurate. - {{- else if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- else if and .Session.IsLoop (not .Session.IsLoopForced) }} **Silent scheduled run — auto-apply.** Same as the uninterrupted case above: for each bug run `bd comment` then `bd update --add-label triaged --add-label severity/` plus the `area/*` and `needs-info` labels decided in 4b. Log a running tally for @@ -211,7 +211,7 @@ prompt: | 3. **Do not** add the `triaged` label on this bug (it is genuinely un-triaged until the human answers). **Continue with the remaining untriaged bugs** — this Blocked path is per-bug and does **not** stop the whole pass, and does **not** disable - this conversation's periodic schedule (a future run may still find newly-filed + this conversation's loop schedule (a future run may still find newly-filed bugs to triage). ## Step 6 — Closing summary @@ -241,7 +241,7 @@ prompt: | (label applied + missing-info checklist in the comment); the reporter, not this pass, unblocks it. - **Blocked is per-bug.** A single blocker defers only that bug — it does not stop - the pass and does not disable this conversation's periodic schedule. + the pass and does not disable this conversation's loop schedule. - **Decide autonomously in silent mode; confirm once in interactive mode.** Never prompt the user in silent mode; never auto-apply in interactive mode without the Step 4c sign-off. diff --git a/config/prompts/builtin/beads-work.prompt.yaml b/config/prompts/builtin/beads-work.prompt.yaml index 6f5c71cb..1736f5dd 100644 --- a/config/prompts/builtin/beads-work.prompt.yaml +++ b/config/prompts/builtin/beads-work.prompt.yaml @@ -4,8 +4,8 @@ menus: prompts, beadsList description: Review ready (not-in-progress) beads, present a prioritized recommendation, claim the chosen one, then analyze and plan it in this conversation and dispatch the implementation work to child conversations backgroundColor: '#B2DFDB' group: Tasks -enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*")' -periodic: +enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Permissions.CanStartConversation' +loop: mode: optional default: false trigger: onCompletion diff --git a/config/prompts/builtin/check-ci.prompt.yaml b/config/prompts/builtin/check-ci.prompt.yaml index 82743d7a..56073516 100644 --- a/config/prompts/builtin/check-ci.prompt.yaml +++ b/config/prompts/builtin/check-ci.prompt.yaml @@ -7,7 +7,7 @@ backgroundColor: '#BBDEFB' preferredModels: - modelTag: Cheap - modelTag: Coding -periodic: +loop: mode: optional default: false trigger: onCompletion diff --git a/config/prompts/builtin/child-cleanup.prompt.yaml b/config/prompts/builtin/child-cleanup.prompt.yaml index 7d40c121..07b02953 100644 --- a/config/prompts/builtin/child-cleanup.prompt.yaml +++ b/config/prompts/builtin/child-cleanup.prompt.yaml @@ -4,7 +4,7 @@ description: Review child conversations and delete the finished ones that are no group: Work flow menus: prompts, conversation backgroundColor: '#FFF9C4' -enabledWhen: Children.Exists && Tools.HasPattern("mitto_conversation_*") && !Session.IsPeriodicConversation +enabledWhen: Children.Exists && !Session.IsLoopConversation preferredModels: - modelTag: Cheap - modelTag: Coding diff --git a/config/prompts/builtin/child-continue-new.prompt.yaml b/config/prompts/builtin/child-continue-new.prompt.yaml index d6e1368c..b482e4e4 100644 --- a/config/prompts/builtin/child-continue-new.prompt.yaml +++ b/config/prompts/builtin/child-continue-new.prompt.yaml @@ -4,7 +4,7 @@ description: Continue the current work in a new conversation — in this or anot group: Work flow menus: prompts, conversation backgroundColor: '#FFF9C4' -enabledWhen: Session.HasMessages && Tools.HasPattern("mitto_conversation_*") && !Session.IsPeriodicConversation +enabledWhen: Session.HasMessages && Permissions.CanStartConversation && !Session.IsLoopConversation prompt: | Continue the current work in a brand-new conversation. Let the user choose which workspace to start it in (this one or another), optionally with a different model. diff --git a/config/prompts/builtin/child-continue.prompt.yaml b/config/prompts/builtin/child-continue.prompt.yaml index c9f17fdc..4cb25993 100644 --- a/config/prompts/builtin/child-continue.prompt.yaml +++ b/config/prompts/builtin/child-continue.prompt.yaml @@ -9,7 +9,7 @@ parameters: description: The child conversation to continue (one you spawned from this conversation) required: true backgroundColor: '#FFF9C4' -enabledWhen: Session.HasMessages && Children.Exists && Tools.HasPattern("mitto_conversation_*") && !Session.IsPeriodicConversation +enabledWhen: Session.HasMessages && Children.Exists && Permissions.CanSendPrompt && !Session.IsLoopConversation prompt: | Continue working on this by sending instructions to the existing conversation you selected (`{{ .Args.TargetConversation }}` — typically a child you spawned). Build on what it diff --git a/config/prompts/builtin/child-create-minions.prompt.yaml b/config/prompts/builtin/child-create-minions.prompt.yaml index 309bd380..d0f16d59 100644 --- a/config/prompts/builtin/child-create-minions.prompt.yaml +++ b/config/prompts/builtin/child-create-minions.prompt.yaml @@ -4,7 +4,7 @@ description: Break down a complex problem into parallel tasks, coordinate worker group: Work flow menus: prompts, conversation backgroundColor: '#FFF9C4' -enabledWhen: '!Session.IsChild && Tools.HasPattern("mitto_conversation_*") && !Session.IsPeriodicConversation' +enabledWhen: '!Session.IsChild && Permissions.CanStartConversation && !Session.IsLoopConversation' prompt: | Decompose the current problem into parallel subtasks, dispatch to child conversations, collect results, and iterate until solved. diff --git a/config/prompts/builtin/cleanup-code.prompt.yaml b/config/prompts/builtin/cleanup-code.prompt.yaml index 3699e52f..b4dd9ecf 100644 --- a/config/prompts/builtin/cleanup-code.prompt.yaml +++ b/config/prompts/builtin/cleanup-code.prompt.yaml @@ -1,6 +1,6 @@ icon: broom name: Cleanup Code -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Remove dead code, unused imports, and outdated documentation group: Code Quality backgroundColor: '#C8E6C9' diff --git a/config/prompts/builtin/continue.prompt.yaml b/config/prompts/builtin/continue.prompt.yaml index 49923e96..733938ef 100644 --- a/config/prompts/builtin/continue.prompt.yaml +++ b/config/prompts/builtin/continue.prompt.yaml @@ -18,9 +18,9 @@ prompt: | Your session ID is `{{ .Session.ID }}` — use as `self_id` for all `mitto_*` MCP tool calls. ## Interaction Mode - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Scheduled mode** — a periodic run; the user is not watching. + **Scheduled mode** — a loop run; the user is not watching. - Decide **autonomously**; do not ask. Use only `mitto_ui_notify`. - Keep making the next concrete increment of progress each run. - **Commit** completed increments (stage only changed files by path; never @@ -28,10 +28,10 @@ prompt: | push** — leave that to the user. - If you become genuinely blocked (ambiguous requirement, missing decision), do **not** guess — notify what's blocked and stop iterating - (`mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false)`). + (`mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false)`). {{- else }} - **Interactive mode** — a force-triggered run or a non-periodic conversation; the user may be present. + **Interactive mode** — a force-triggered run or a non-loop conversation; the user may be present. {{- end }} Rules: @@ -41,7 +41,7 @@ prompt: | 3. Execute that step 4. Report progress and what remains - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode, if blocked or unclear: notify and stop (see above) — do not ask. {{- else }} If blocked or unclear, ask for clarification before proceeding. @@ -80,7 +80,7 @@ prompt: | When you switch to a different bead (child, dependent, or sibling), keep the conversation link on the right anchor: - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Standalone follow-up** (dependent/sibling): claim it (`bd update --claim`) and **relink** this conversation — `mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", beads_issue: "")`. - **Child of an epic**: leave the link on the **epic** (the durable anchor) so the next run still @@ -93,7 +93,7 @@ prompt: | ### B3. If a bead becomes complete this run When the bead you worked (``) meets all its acceptance criteria: - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode, act autonomously to keep the loop moving: - **Commit** its work (stage only changed files by path; never `git add -A`/`.`) with a message referencing the bead. Do **NOT push** — leave that to the user. @@ -113,9 +113,9 @@ prompt: | ### B4. When nothing ready remains The bead and all its in-scope follow-ups are done, blocked, or deferred: - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} notify completion and **stop iterating** — - `mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false)`. + `mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false)`. {{- else }} say so and suggest closing/committing anything outstanding. Do not take git actions without approval. {{- end }} diff --git a/config/prompts/builtin/create-commits.prompt.yaml b/config/prompts/builtin/create-commits.prompt.yaml index 91f40458..a8f8e944 100644 --- a/config/prompts/builtin/create-commits.prompt.yaml +++ b/config/prompts/builtin/create-commits.prompt.yaml @@ -1,6 +1,6 @@ icon: save name: Commit changes -menus: prompts, !promptsPeriodic +menus: prompts, conversation, !promptsLoop description: Stage and commit changes with descriptive messages group: Submission of changes backgroundColor: '#B2DFDB' diff --git a/config/prompts/builtin/create-spec.prompt.yaml b/config/prompts/builtin/create-spec.prompt.yaml index dc21cba3..a049f72d 100644 --- a/config/prompts/builtin/create-spec.prompt.yaml +++ b/config/prompts/builtin/create-spec.prompt.yaml @@ -1,6 +1,6 @@ icon: list name: Create spec -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Interactively build a developer-ready specification through guided questions group: Planning backgroundColor: '#FFECB3' diff --git a/config/prompts/builtin/document-arch.prompt.yaml b/config/prompts/builtin/document-arch.prompt.yaml index 608e80c5..a138ad61 100644 --- a/config/prompts/builtin/document-arch.prompt.yaml +++ b/config/prompts/builtin/document-arch.prompt.yaml @@ -1,6 +1,6 @@ icon: layers name: Document Architecture -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Update developer/architecture documentation for the changes we just made group: Documentation backgroundColor: '#CE93D8' diff --git a/config/prompts/builtin/document-code.prompt.yaml b/config/prompts/builtin/document-code.prompt.yaml index 071b17f0..eaa82439 100644 --- a/config/prompts/builtin/document-code.prompt.yaml +++ b/config/prompts/builtin/document-code.prompt.yaml @@ -1,6 +1,6 @@ icon: edit name: Document Code -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Add inline documentation and comments to the code we just wrote group: Documentation backgroundColor: '#B39DDB' diff --git a/config/prompts/builtin/document.prompt.yaml b/config/prompts/builtin/document.prompt.yaml index 725c7ba6..288eec8c 100644 --- a/config/prompts/builtin/document.prompt.yaml +++ b/config/prompts/builtin/document.prompt.yaml @@ -1,6 +1,6 @@ icon: edit name: Document -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Update user-facing documentation for the changes we just made group: Documentation backgroundColor: '#E1BEE7' diff --git a/config/prompts/builtin/explain.prompt.yaml b/config/prompts/builtin/explain.prompt.yaml index ceffe83e..8804bc14 100644 --- a/config/prompts/builtin/explain.prompt.yaml +++ b/config/prompts/builtin/explain.prompt.yaml @@ -1,6 +1,6 @@ icon: chat-bubble name: Explain -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Explain the code or concept we just discussed group: Documentation backgroundColor: '#E1BEE7' diff --git a/config/prompts/builtin/fix-ci.prompt.yaml b/config/prompts/builtin/fix-ci.prompt.yaml index ec77600d..7cfdbf22 100644 --- a/config/prompts/builtin/fix-ci.prompt.yaml +++ b/config/prompts/builtin/fix-ci.prompt.yaml @@ -5,9 +5,9 @@ description: Diagnose and fix CI pipeline failures group: CI backgroundColor: '#B2DFDB' tags: -- periodic +- loop - ci -periodic: +loop: mode: optional default: false trigger: onCompletion @@ -23,9 +23,9 @@ prompt: | Your session ID is `{{ .Session.ID }}` — use this as `self_id` for all `mitto_*` MCP tool calls. ## Interaction Mode - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Scheduled mode** — a periodic run; the user is not watching. + **Scheduled mode** — a loop run; the user is not watching. - Use **only** `mitto_ui_notify` for all communication — non-blocking notifications only. - Do **NOT** use `mitto_ui_options`, `mitto_ui_form`, `mitto_ui_textbox`, or any interactive/blocking UI tool. They will stall an unattended run. @@ -33,12 +33,12 @@ prompt: | (tests, build, lint) since you will **not** push. - When local verification passes (nothing left to fix), notify success and **stop iterating** so the loop doesn't spin: - `mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false)`. + `mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false)`. - If a failure is ambiguous or needs a human decision (flaky test, infra outage, CI-config/dependency change), do not guess — notify and stop. {{- else }} - **Interactive mode** — a force-triggered run or a non-periodic conversation; the user may be present. + **Interactive mode** — a force-triggered run or a non-loop conversation; the user may be present. - You may use `mitto_ui_options`, `mitto_ui_form`, and other interactive tools in addition to `mitto_ui_notify`. Ask before risky changes (CI config, dependencies). {{- end }} @@ -80,14 +80,14 @@ prompt: | ``` If passing, report success and stop. - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} - In scheduled mode: also disable your own periodic so the loop stops - (`mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false)`), + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} + In scheduled mode: also disable your own loop so the loop stops + (`mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false)`), then send a `mitto_ui_notify` success. {{- end }} - ### 3b. Track failures durably (periodic mode) - {{- if .Session.IsPeriodic }} + ### 3b. Track failures durably (loop mode) + {{- if .Session.IsLoop }} CI is failing. Set up **durable cross-run state in beads** so the loop remembers what it already tried (attempt counts, known flakes, escalations) — the beads-native @@ -132,9 +132,9 @@ prompt: | 1. Quote exact error from logs 2. Diagnose root cause: test failure, build error, lint/format, dependency, config/environment - {{- if .Session.IsPeriodic }} + {{- if .Session.IsLoop }} - **Classify before fixing (periodic, when beads is available):** + **Classify before fixing (loop, when beads is available):** - **Flake** — same test failed then passed on retry **without a code change**, or the matching bead is already labelled `flake`: do **not** auto-fix. Label the bead `flake`, `bd comment` the evidence, and notify for human quarantine. @@ -154,9 +154,9 @@ prompt: | Per issue: implement fix, explain the change, verify locally (tests, build, lint). Fix in dependency order — causes before symptoms. - {{- if .Session.IsPeriodic }} + {{- if .Session.IsLoop }} - **Periodic-mode fix safety + escalation (when beads is available):** + **Loop-mode fix safety + escalation (when beads is available):** - **Protect the working checkout.** If the working tree has **unrelated uncommitted changes**, make the fix in a **temporary git worktree** on its own branch (verify and commit there) instead of the live checkout — never mix the user's work with the CI fix. @@ -170,8 +170,8 @@ prompt: | - **On success** (local verification passes), **close** the failure bead so it's pruned from the active list: `bd close --reason "fixed: "`. - **Stop spinning:** if every current failure is already `needs-human` or `flake` - (nothing this loop can act on), disable periodic - (`mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false)`) + (nothing this loop can act on), disable the loop + (`mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false)`) and notify — the user re-runs after addressing the handoffs. {{- end }} @@ -181,10 +181,10 @@ prompt: | **Do NOT delegate** for: a single failure, cascading failures from one root cause, or simple lint/format fixes. - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode: before spawning, check `{{ .Children.AllText }}` and reuse an existing child for the same failure instead of duplicating. Spawn at most **3 per - run**. **Spawned conversations must never be periodic** — they are one-off tasks. + run**. **Spawned conversations must never be loops** — they are one-off tasks. {{- end }} **Session context for delegation:** @@ -221,7 +221,7 @@ prompt: | ``` ### 7. Commit and Push - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode: **commit** your fixes, but do **NOT push** — pushing is left to the user. Stage only the files you changed, explicitly by path (`git add ...`); @@ -244,21 +244,21 @@ prompt: | - Report flaky tests as flaky rather than retrying blindly - Note infrastructure-related failures explicitly - Group related fixes in a single commit - {{- if .Session.IsPeriodic }} - - **Beads is the durable state store** in periodic mode (when `bd` + `.beads` exist): a + {{- if .Session.IsLoop }} + - **Beads is the durable state store** in loop mode (when `bd` + `.beads` exist): a `ci-sweeper` tracker epic anchors the loop; each failure is a `ci-failure` child bead with attempt count in `bd comment`; never create a `ci-sweeper-state.md` or any state file. - **Attempt cap:** escalate to a human (`needs-human` + `bd update --defer`) after 3 attempts on the same failure — never retry the same broken fix forever. - **Flakes:** label `flake` and quarantine via a bead; do not auto-fix tests that pass on retry. - **Never clobber the working checkout** — use a temporary worktree when it has unrelated - uncommitted changes; spawned children must never be periodic. + uncommitted changes; spawned children must never be loops. {{- end }} - **Interaction mode** (see "Interaction Mode" section above): - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} - - **Scheduled periodic**: notify-only; fix + commit (never push); verify locally; - stop iterating when green; spawned children must never be periodic. + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} + - **Scheduled loop**: notify-only; fix + commit (never push); verify locally; + stop iterating when green; spawned children must never be loops. {{- else }} - - **Force-triggered or non-periodic**: you may use interactive UI; ask before + - **Force-triggered or non-loop**: you may use interactive UI; ask before risky changes; suggest commit + push at the end. {{- end }} diff --git a/config/prompts/builtin/fix-errors.prompt.yaml b/config/prompts/builtin/fix-errors.prompt.yaml index 81668084..4cbaba4c 100644 --- a/config/prompts/builtin/fix-errors.prompt.yaml +++ b/config/prompts/builtin/fix-errors.prompt.yaml @@ -1,11 +1,11 @@ icon: error name: Fix errors -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Analyze and fix the errors shown group: Development backgroundColor: '#FFE0B2' tags: -- periodic +- loop prompt: | Read relevant source files to understand code context around each error. Do not speculate about code you haven't opened. @@ -15,9 +15,9 @@ prompt: | Your session ID is `{{ .Session.ID }}` — use as `self_id` for all `mitto_*` MCP tool calls. ## Interaction Mode - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Scheduled mode** — a periodic run; the user is not watching. + **Scheduled mode** — a loop run; the user is not watching. - Decide **autonomously**; do not ask the user. Use only `mitto_ui_notify`. - Determine the errors to fix from the latest build/test output yourself. - **Commit** your fixes (stage only changed files by path; never `git add -A`/`.` @@ -27,7 +27,7 @@ prompt: | what's blocked and stop, rather than asking. {{- else }} - **Interactive mode** — a force-triggered run or a non-periodic conversation; the + **Interactive mode** — a force-triggered run or a non-loop conversation; the user may be present. Ask for clarification if intent is unclear before changing code. {{- end }} @@ -55,10 +55,10 @@ prompt: | **Do NOT delegate** for: a single error, errors sharing a root cause, cascading errors from one issue, or trivial one-line fixes. - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} In scheduled mode: before spawning, check `{{ .Children.AllText }}` and reuse an existing child instead of duplicating. Spawn at most **3 per run**. **Spawned - conversations must never be periodic.** + conversations must never be loops.** {{- end }} **Session context for delegation:** diff --git a/config/prompts/builtin/generate-agents-md.prompt.yaml b/config/prompts/builtin/generate-agents-md.prompt.yaml index f1944634..db8ddaa8 100644 --- a/config/prompts/builtin/generate-agents-md.prompt.yaml +++ b/config/prompts/builtin/generate-agents-md.prompt.yaml @@ -1,10 +1,10 @@ icon: robot name: Generate AGENTS.md -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Analyze project and generate an AGENTS.md file for AI coding agents group: Agents & Mitto backgroundColor: '#B3E5FC' -enabledWhen: '!Session.IsPeriodicConversation' +enabledWhen: '!Session.IsLoopConversation' preferredModels: - modelTag: Coding prompt: | diff --git a/config/prompts/builtin/github-babysit-contributions.prompt.yaml b/config/prompts/builtin/github-babysit-contributions.prompt.yaml index d7355cf0..ca031bb0 100644 --- a/config/prompts/builtin/github-babysit-contributions.prompt.yaml +++ b/config/prompts/builtin/github-babysit-contributions.prompt.yaml @@ -6,14 +6,14 @@ parameters: type: text required: false description: 'Optional GitHub repository (owner/repo) to monitor. If omitted, the repository of the current folder is used.' -description: Periodically check for pending review requests, bot dependency PRs ready to merge, and stale remote branches from merged PRs +description: Regularly check for pending review requests, bot dependency PRs ready to merge, and stale remote branches from merged PRs group: GitHub backgroundColor: '#C8E6C9' tags: -- periodic +- loop - github enabledWhen: FileExists(".git/config") && (Tools.HasPattern("github_*") || CommandExists("gh")) -periodic: +loop: mode: optional default: true trigger: onCompletion @@ -27,7 +27,7 @@ prompt: | parameter): pending review requests addressed to you, bot dependency PRs (Dependabot, Renovate) ready to merge, and stale remote branches from merged PRs. This prompt does **not** touch your own PRs — use "GitHub: babysit my - PRs" for that. Designed to be run periodically via `mitto_conversation_set_periodic`. + PRs" for that. Designed to be run as a loop via `mitto_conversation_set_loop`. ## Session Context @@ -37,15 +37,15 @@ prompt: | {{ .ACP.AvailableText }} ## Interaction Mode - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode** — a scheduled periodic run; the user is not watching. + **Silent mode** — a scheduled loop run; the user is not watching. - Use **only** `mitto_ui_notify` for all communication — non-blocking notifications only. - Do **NOT** use `mitto_ui_options`, `mitto_ui_form`, `mitto_ui_textbox`, or any interactive/blocking UI tool. {{- else }} - **Interactive mode** — a force-triggered run or a non-periodic conversation; the user may be present. + **Interactive mode** — a force-triggered run or a non-loop conversation; the user may be present. - You may freely interact with the user using `mitto_ui_options`, `mitto_ui_form`, and other interactive tools in addition to `mitto_ui_notify`. {{- end }} @@ -196,7 +196,7 @@ prompt: | Pending reviews for you: Bot PRs ready to merge: Merged branches to clean up: - Next check: + Next check: ``` ## Guidelines @@ -211,17 +211,17 @@ prompt: | {{- end }} - If `gh` authentication fails, stop immediately and inform the user. - **Interaction mode** (see "Interaction Mode" section above): - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} - - **Scheduled periodic**: Use only `mitto_ui_notify`. No interactive UI. Skip the + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} + - **Scheduled loop**: Use only `mitto_ui_notify`. No interactive UI. Skip the summary — only send notifications for actionable items. {{- else }} - - **Force-triggered or non-periodic**: You may use `mitto_ui_options`, + - **Force-triggered or non-loop**: You may use `mitto_ui_options`, `mitto_ui_form`, and other interactive tools. Ask the user before risky actions (merges, branch deletions). Show the full summary at the end. {{- end }} - **Notification batching**: batch repetitive items (pending reviews, bot PRs) into a single notification per category to avoid spamming the user — - especially important in periodic mode. + especially important in loop mode. - In **scheduled mode**: do not merge PRs or delete branches automatically — only notify. In **interactive mode**: offer to merge or delete with user confirmation. diff --git a/config/prompts/builtin/github-babysit-my-prs.prompt.yaml b/config/prompts/builtin/github-babysit-my-prs.prompt.yaml index d9e8f762..416ca8bf 100644 --- a/config/prompts/builtin/github-babysit-my-prs.prompt.yaml +++ b/config/prompts/builtin/github-babysit-my-prs.prompt.yaml @@ -6,14 +6,14 @@ parameters: type: text required: false description: 'Optional GitHub repository (owner/repo) to babysit. If omitted, the repository of the current folder is used.' -description: 'Periodically check your own open PRs: rebase stale branches, report CI failures, flag ready-to-merge PRs, and address review comments' +description: 'Regularly check your own open PRs: rebase stale branches, report CI failures, flag ready-to-merge PRs, and address review comments' group: GitHub backgroundColor: '#BBDEFB' tags: -- periodic +- loop - github enabledWhen: FileExists(".git/config") && (Tools.HasPattern("github_*") || CommandExists("gh")) -periodic: +loop: mode: optional default: true trigger: onCompletion @@ -25,7 +25,7 @@ prompt: | Monitor your own open pull requests for the target repository (the current folder's repo by default, or the one supplied via the `Repository` parameter), keeping them up-to-date and reporting issues. Only acts on PRs where you are - the author. Designed to be run periodically via `mitto_conversation_set_periodic`. + the author. Designed to be run as a loop via `mitto_conversation_set_loop`. ## Session Context @@ -36,7 +36,7 @@ prompt: | When spawning new conversations to fix issues, prefer `"coding"` or `"fast"` tagged servers for straightforward fixes. **Never** configure spawned conversations - as periodic — they are one-off tasks. + as loops — they are one-off tasks. ## Spawn Deduplication @@ -54,16 +54,16 @@ prompt: | comments. Notify the user about any remaining items that were not spawned. ## Interaction Mode - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode** — a scheduled periodic run; the user is not watching. + **Silent mode** — a scheduled loop run; the user is not watching. - Use **only** `mitto_ui_notify` for all communication — non-blocking notifications only. - Do **NOT** use `mitto_ui_options`, `mitto_ui_form`, `mitto_ui_textbox`, or any interactive/blocking UI tool. - Act autonomously when safe (e.g., clean rebases), otherwise just notify. {{- else }} - **Interactive mode** — a force-triggered run or a non-periodic conversation; the user may be present. + **Interactive mode** — a force-triggered run or a non-loop conversation; the user may be present. - You may freely interact with the user using `mitto_ui_options`, `mitto_ui_form`, and other interactive tools in addition to `mitto_ui_notify`. - For example: ask the user whether to proceed with a risky rebase, which failing @@ -149,7 +149,7 @@ prompt: | **If the PR is behind its target branch and needs rebasing:** - 1. **In interactive mode** (force-triggered or non-periodic), ask the user first: + 1. **In interactive mode** (force-triggered or non-loop), ask the user first: ``` mitto_ui_options(self_id: "{{ .Session.ID }}", question: "PR # () is behind <baseRefName>. Rebase now?", @@ -395,7 +395,7 @@ prompt: | | #2 | Add feature | — Up to date | ❌ Failing | Changes requested | 3 unresolved threads | | #3 | Refactor | ⚠️ Conflicts | ✅ Passing | Pending | 🕸️ Stale (18 days) | - Next check: <if periodic, mention the schedule> + Next check: <if loop, mention the schedule> ``` ## Guidelines @@ -414,25 +414,25 @@ prompt: | - Use `--force-with-lease` when force-pushing (never `--force`). - If `gh` authentication fails, stop immediately and inform the user. - **Interaction mode** (see "Interaction Mode" section above): - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} - - **Scheduled periodic**: Use only `mitto_ui_notify`. No interactive UI. Act + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} + - **Scheduled loop**: Use only `mitto_ui_notify`. No interactive UI. Act autonomously when safe, otherwise notify. Skip the summary table — only send notifications for actionable items. {{- else }} - - **Force-triggered or non-periodic**: You may use `mitto_ui_options`, + - **Force-triggered or non-loop**: You may use `mitto_ui_options`, `mitto_ui_form`, and other interactive tools. Ask the user before risky actions (merges). Show the full summary table at the end. {{- end }} - **Spawn deduplication** (see "Spawn Deduplication" section above): Check `{{ .Children.MCPText }}` for existing child conversations before spawning. Skip if a child for the same PR already exists. Max 3 spawns per run. - - **Spawned conversations must never be periodic.** They are one-off tasks that - should complete and stop. Do not call `mitto_conversation_set_periodic` on them. + - **Spawned conversations must never be loops.** They are one-off tasks that + should complete and stop. Do not call `mitto_conversation_set_loop` on them. - Only spawn conversations when `mitto_conversation_new` is available. If Mitto MCP tools are not present, just send notifications and let the user act. - **Notification batching**: batch repetitive informational items (stale PRs, draft PRs) into a single notification per category to avoid spamming the - user — especially important in periodic mode. + user — especially important in loop mode. - Use `sound: true, native: true` for critical notifications (CI failures, rebase conflicts) so the user notices immediately. - In **scheduled mode**: do not merge PRs automatically — only notify. diff --git a/config/prompts/builtin/github-iterate-babysit-new-prs.prompt.yaml b/config/prompts/builtin/github-iterate-babysit-new-prs.prompt.yaml index 9f567847..87d5be42 100644 --- a/config/prompts/builtin/github-iterate-babysit-new-prs.prompt.yaml +++ b/config/prompts/builtin/github-iterate-babysit-new-prs.prompt.yaml @@ -6,14 +6,14 @@ parameters: type: text required: false description: 'Optional GitHub repository (owner/repo) whose recently-created PRs to babysit. If omitted, the repository of the current folder is used.' -description: Auto-periodic — keep babysitting the PRs you recently created (rebase, fix CI, address comments, merge when ready), then self-terminate when nothing actionable remains +description: Auto-loop — keep babysitting the PRs you recently created (rebase, fix CI, address comments, merge when ready), then self-terminate when nothing actionable remains group: GitHub backgroundColor: '#BBDEFB' tags: -- periodic +- loop - github -enabledWhen: '!Session.IsChild && FileExists(".git/config") && (Tools.HasPattern("github_*") || CommandExists("gh")) && Tools.HasPattern("mitto_conversation_*")' -periodic: +enabledWhen: '!Session.IsChild && FileExists(".git/config") && (Tools.HasPattern("github_*") || CommandExists("gh")) && Permissions.CanStartConversation' +loop: mode: always trigger: onCompletion delay: 3600 @@ -22,11 +22,11 @@ periodic: prompt: | {{- $repoFlag := "" -}} {{- if .Args.Repository }}{{ $repoFlag = printf " --repo %s" .Args.Repository }}{{ end -}} - The auto-periodic, self-driving sibling of **"GitHub: babysit my PRs"**. On + The auto-loop, self-driving sibling of **"GitHub: babysit my PRs"**. On every run this conversation advances the **PRs you recently created** (the ones it has been babysitting) one step toward done — rebasing stale branches, fixing CI, addressing review comments, and merging when ready — and when there is - **nothing actionable left**, it removes its own periodic flag and stops. + **nothing actionable left**, it removes its own loop flag and stops. Operates on the current folder's repository by default, or the one supplied via the `Repository` parameter. Only ever acts on PRs where **you are the author**. @@ -42,13 +42,13 @@ prompt: | {{ .Children.MCPText }} When spawning conversations to fix issues, prefer `"coding"` or `"fast"` tagged - servers. **Never** configure spawned conversations as periodic — they are + servers. **Never** configure spawned conversations as loops — they are one-off tasks. ## Interaction Mode — READ THIS FIRST - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode — scheduled periodic run.** + **Silent mode — scheduled loop run.** - Use **only** `mitto_ui_notify`. Do **NOT** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox` — nobody is watching, never block. - Act autonomously when safe (clean rebases); otherwise just notify. @@ -141,7 +141,7 @@ prompt: | allow_free_text: true, free_text_placeholder: "e.g. 123, 456", options: [ { label: "Enter PR number(s) to babysit" }, - { label: "Quit — stop the periodic loop" } + { label: "Quit — stop the loop" } ]) ``` If the user quits (or provides nothing), go to **Step 5 (stop)**. Otherwise @@ -159,7 +159,7 @@ prompt: | **Spawn rules:** before spawning, check `{{ .Children.MCPText }}` and **skip** if a child already exists for the same PR + task. Cap spawning at **3 per run**; - spawned conversations are one-off and **must never be periodic**. + spawned conversations are one-off and **must never be loops**. ### 3a. Rebase if behind base ```bash @@ -265,11 +265,11 @@ prompt: | When every monitored PR has been **merged or closed** (reached from Step 2 when no PRs were identified / the user quit, or from Step 4 when all target PRs are - done — none left open), remove this conversation's own periodic flag so it + done — none left open), remove this conversation's own loop flag so it becomes a regular conversation: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` Then notify the user (works in both modes): @@ -300,20 +300,20 @@ prompt: | - **Never modify the local checkout** — the user may have uncommitted work there. Always rebase in a temporary worktree and force-push with `--force-with-lease` (never `--force`). - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Interaction mode**: this is a **scheduled** run — use **only** `mitto_ui_notify`, never block on interactive UI, and do **not** auto-merge. {{- else }} - - **Interaction mode**: this is a **force-triggered or non-periodic** run — you may use + - **Interaction mode**: this is a **force-triggered or non-loop** run — you may use `mitto_ui_options`/`mitto_ui_form` and offer to merge with confirmation. {{- end }} - **Spawn rules**: check `{{ .Children.MCPText }}` before spawning and skip if a child already exists for the same PR + task; cap at **3 spawns per run**, prioritizing rebase conflicts > CI failures > unresolved comments. Spawned conversations are - one-off and **must never be periodic**. + one-off and **must never be loops**. - **Notify only when it matters** on scheduled runs (rebased, CI broke, ready to merge, merged, or final stop). Stay quiet on routine no-op runs. - - **Self-terminate** via `periodic_enabled: false` **only once all** monitored + - **Self-terminate** via `loop_enabled: false` **only once all** monitored PRs are merged or closed. Keep iterating while any PR is still open — even if it is steady (only waiting on a human to review/merge) — so it stays monitored until it lands. The user can re-run this prompt later to babysit a fresh batch diff --git a/config/prompts/builtin/github-post-merge-cleanup.prompt.yaml b/config/prompts/builtin/github-post-merge-cleanup.prompt.yaml index 43dc51cf..d693d324 100644 --- a/config/prompts/builtin/github-post-merge-cleanup.prompt.yaml +++ b/config/prompts/builtin/github-post-merge-cleanup.prompt.yaml @@ -5,22 +5,22 @@ parameters: - name: IssuesOnly type: boolean description: Triage only — file/update beads cleanup issues and notify, but never auto-fix or open PRs (leave unchecked to auto-fix small, low-risk items) -description: Auto-periodic — after merges to the default branch, sweep for follow-up work (TODOs, deprecations, stale flags, doc gaps), track it in beads, auto-fix small low-risk items, and self-terminate when quiet +description: Auto-loop — after merges to the default branch, sweep for follow-up work (TODOs, deprecations, stale flags, doc gaps), track it in beads, auto-fix small low-risk items, and self-terminate when quiet group: GitHub backgroundColor: '#BBDEFB' tags: -- periodic +- loop - github - cleanup -enabledWhen: '!Session.IsChild && FileExists(".git/config") && (Tools.HasPattern("github_*") || CommandExists("gh")) && CommandExists("bd") && DirExists(".beads") && Tools.HasPattern("mitto_conversation_*")' -periodic: +enabledWhen: '!Session.IsChild && FileExists(".git/config") && (Tools.HasPattern("github_*") || CommandExists("gh")) && CommandExists("bd") && DirExists(".beads") && Permissions.CanStartConversation' +loop: mode: always trigger: onCompletion delay: 21600 maxIterations: 20 maxDuration: "168h" prompt: | - The auto-periodic **post-merge cleanup sweeper**. After merges land on the + The auto-loop **post-merge cleanup sweeper**. After merges land on the default branch, each run sweeps for follow-up work — deprecations, `TODO`/`FIXME`, `// remove after`, stale feature flags, broken doc links, and explicit follow-ups named in merged PRs/issues — **without blocking or touching the merge itself**. @@ -36,7 +36,7 @@ prompt: | Existing children (spawned by previous runs): {{ .Children.MCPText }} When spawning fix conversations, prefer `"coding"` or `"fast"` tagged servers. - **Never** make a spawned conversation periodic — they are one-off tasks. + **Never** make a spawned conversation a loop — they are one-off tasks. ## Mode: triage-only vs. auto-fix {{- if eq .Args.IssuesOnly "true" }} @@ -53,9 +53,9 @@ prompt: | {{- end }} ## Interaction Mode — READ THIS FIRST - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode — scheduled periodic run.** Use **only** `mitto_ui_notify`. Do **NOT** + **Silent mode — scheduled loop run.** Use **only** `mitto_ui_notify`. Do **NOT** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox` — nobody is watching. Act autonomously when safe; otherwise file a bead and notify. Stay quiet on no-op runs. {{- else }} @@ -246,7 +246,7 @@ prompt: | **self-terminate**: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) mitto_ui_notify(self_id: "{{ .Session.ID }}", title: "Post-merge cleanup — idle", message: "<what was filed/fixed/deferred across runs; nothing actionable left, so iteration stopped>", style: "success") @@ -269,6 +269,6 @@ prompt: | - **Human handoff** for architectural debt, prod-config flag removal, deprecations with external API consumers, or anything attempted twice without passing tests. - **Caps:** at most **2 auto-fix PRs per run**; check `{{ .Children.MCPText }}` and skip - duplicate spawns; spawned conversations are one-off and **must never be periodic**. + duplicate spawns; spawned conversations are one-off and **must never be loops**. - **Notify only when it matters** on scheduled runs (filed / fixed / deferred / final stop); stay quiet on no-op runs. Always log to the tracker with `bd comment`. diff --git a/config/prompts/builtin/github-review-slack-prs.prompt.yaml b/config/prompts/builtin/github-review-slack-prs.prompt.yaml index b0e1ed08..aefb3d2d 100644 --- a/config/prompts/builtin/github-review-slack-prs.prompt.yaml +++ b/config/prompts/builtin/github-review-slack-prs.prompt.yaml @@ -8,15 +8,15 @@ parameters: - name: CheckoutsRoot type: text description: Root directory that contains the local checkouts of the repositories you may review -description: Scan a Slack channel for PR review requests, match each to a local checkout under a checkouts root, and review it — interactive when watched, silent & autonomous when run periodically +description: Scan a Slack channel for PR review requests, match each to a local checkout under a checkouts root, and review it — interactive when watched, silent & autonomous when run as a loop group: GitHub backgroundColor: '#BBDEFB' tags: - github - slack -- periodic +- loop enabledWhen: Tools.HasPattern("slack_*") && (Tools.HasPattern("github_*") || CommandExists("gh")) -periodic: +loop: mode: optional default: false trigger: onCompletion @@ -26,7 +26,7 @@ prompt: | to the matching **local checkout** under a given checkouts root, and review each PR the same careful way as the **"GitHub: review a Pull Request"** prompt — inspecting the PR **without disturbing any local changes** and reviewing against - the repository's own conventions. Designed to be safe to run **periodically**. + the repository's own conventions. Designed to be safe to run **as a loop**. ## Session Context @@ -39,13 +39,13 @@ prompt: | If either argument is missing: in **interactive** mode ask for it with `mitto_ui_options(self_id: "{{ .Session.ID }}", ..., allow_free_text: true)`; - in **silent periodic** mode, post a `mitto_ui_notify` explaining what is missing + in **silent loop** mode, post a `mitto_ui_notify` explaining what is missing and stop. ## Interaction Mode — READ THIS FIRST - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent autonomous mode — scheduled periodic run.** Nobody is watching. + **Silent autonomous mode — scheduled loop run.** Nobody is watching. - Use **only** `mitto_ui_notify`. Do **NOT** call `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox` — never block waiting for a human. - You **may post review comments without asking**, but only findings you are @@ -58,7 +58,7 @@ prompt: | just record that you looked (a brief `mitto_ui_notify` summary is enough). {{- else }} - **Interactive mode** (a normal send, or a force-triggered periodic run): a user + **Interactive mode** (a normal send, or a force-triggered loop run): a user may be present. - Present candidate comments in a `mitto_ui_form` and **confirm with `mitto_ui_options` before posting** anything. @@ -84,7 +84,7 @@ prompt: | Use the available **Slack MCP tools (`slack_*`)** to read the **most recent** messages from that channel (a recent window is enough — e.g. the last day or the - last ~50 messages; in periodic mode only look at messages new since the previous + last ~50 messages; in loop mode only look at messages new since the previous run is ideal, but a recent window with the Step 3 dedup is sufficient). Identify messages that are **review requests**: typically they mention a review @@ -115,7 +115,7 @@ prompt: | ## Step 3 — Deduplicate (skip already-reviewed / unchanged PRs) - For periodic safety, **do not re-review the same PR at the same point in time.** + For loop safety, **do not re-review the same PR at the same point in time.** Check, statelessly against GitHub, whether **you have already reviewed this PR at its current head commit**: @@ -185,9 +185,9 @@ prompt: | ``` ## Step 5 — Present and post the review - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent periodic mode** — no forms, no confirmations: + **Silent loop mode** — no forms, no confirmations: - Keep **only** the findings you are **really sure** about (drop everything uncertain/stylistic, per the Interaction Mode rules). - If at least one confident finding remains, post a single **COMMENT** review. @@ -262,7 +262,7 @@ prompt: | - **Process each PR at most once per run**, and skip PRs already reviewed at their current head commit (re-review only when new commits arrive). - Review against each **repo's conventions** first, generic best practice second. - - In **silent periodic** mode never block on UI, post only comments you are + - In **silent loop** mode never block on UI, post only comments you are **really sure** about, and only as a `COMMENT` review. - In **interactive** mode, pre-fill every textarea and **confirm before posting**; if the user cancels, post nothing. diff --git a/config/prompts/builtin/github-sync-tasks.prompt.yaml b/config/prompts/builtin/github-sync-tasks.prompt.yaml index c6deeaad..d76ec4ed 100644 --- a/config/prompts/builtin/github-sync-tasks.prompt.yaml +++ b/config/prompts/builtin/github-sync-tasks.prompt.yaml @@ -1,17 +1,17 @@ icon: globe name: 'GitHub: sync tasks' menus: prompts -description: Periodically pull GitHub issues from the project's repository into local beads issues +description: Regularly pull GitHub issues from the project's repository into local beads issues backgroundColor: '#B3E5FC' group: GitHub tags: -- periodic +- loop - github enabledWhen: FileExists(".git/config") && (Tools.HasPattern("github_*") || CommandExists("gh")) && CommandExists("bd") preferredModels: - modelTag: Cheap - modelTag: Coding -periodic: +loop: mode: optional default: true value: 1 @@ -22,7 +22,7 @@ prompt: | keeping the beads copy in sync with changes made on GitHub (body, comments, labels, state). This is a **one-way pull** (GitHub → beads) for now; two-way sync may be added later. Run it **on demand** in a regular conversation, or - schedule it to run periodically via `mitto_conversation_set_periodic` — it + schedule it to run as a loop via `mitto_conversation_set_loop` — it adapts its behaviour to whichever mode it is invoked in (see Interaction Mode). ## Session Context @@ -33,14 +33,14 @@ prompt: | {{ .Session.UserDataJSON }} ## Interaction Mode - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode** — a scheduled periodic run; the user is not watching. + **Silent mode** — a scheduled loop run; the user is not watching. - Use **only** `mitto_ui_notify` — non-blocking notifications only. - Do **NOT** use `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. {{- else }} - **Interactive mode** — a regular conversation or a force-triggered periodic run; the user is present. + **Interactive mode** — a regular conversation or a force-triggered loop run; the user is present. - Use interactive tools (`mitto_ui_options`, `mitto_ui_form`, `mitto_ui_textbox`) as well as `mitto_ui_notify`. This is the default when run on demand. {{- end }} @@ -59,7 +59,7 @@ prompt: | gh repo view --json nameWithOwner -q .nameWithOwner ``` - Confirm `gh` is authenticated (`gh auth status`); if not, in interactive mode tell the user and stop, in periodic mode notify and stop. Hold the resulting `owner/name` as `<repo>`. + Confirm `gh` is authenticated (`gh auth status`); if not, in interactive mode tell the user and stop, in loop mode notify and stop. Hold the resulting `owner/name` as `<repo>`. ## Step 3 — Ensure beads is ready @@ -68,7 +68,7 @@ prompt: | ``` - If `.beads` exists: continue. - - If it does **not** exist: in interactive mode run `bd init --non-interactive`; in periodic mode notify and **stop** (do not initialise unattended). + - If it does **not** exist: in interactive mode run `bd init --non-interactive`; in loop mode notify and **stop** (do not initialise unattended). ## Step 4 — Fetch matching GitHub issues @@ -149,7 +149,7 @@ prompt: | Report counts: issues matched, beads created, beads updated, comments mirrored, beads closed, unchanged (skipped). - - Silent mode (scheduled periodic): send a single `mitto_ui_notify` only if anything changed; stay silent otherwise. + - Silent mode (scheduled loop): send a single `mitto_ui_notify` only if anything changed; stay silent otherwise. - Interactive mode: print the full summary. ## Guidelines diff --git a/config/prompts/builtin/implement-spec.prompt.yaml b/config/prompts/builtin/implement-spec.prompt.yaml index 4bb0669d..e41236dd 100644 --- a/config/prompts/builtin/implement-spec.prompt.yaml +++ b/config/prompts/builtin/implement-spec.prompt.yaml @@ -1,6 +1,6 @@ icon: list name: Implement spec -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Create a detailed implementation plan from a specification group: Development backgroundColor: '#FFECB3' diff --git a/config/prompts/builtin/jira-decompose.prompt.yaml b/config/prompts/builtin/jira-decompose.prompt.yaml index 763acd58..6b7655b4 100644 --- a/config/prompts/builtin/jira-decompose.prompt.yaml +++ b/config/prompts/builtin/jira-decompose.prompt.yaml @@ -1,10 +1,10 @@ icon: tag name: 'JIRA: decompose' -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Break a JIRA ticket into sub-tickets and create them automatically backgroundColor: '#E1BEE7' group: JIRA -enabledWhen: '!Session.IsChild && Tools.HasPattern("jira_*")' +enabledWhen: '!Session.IsChild && (Tools.HasPattern("jira_*") || CommandExists("jira"))' prompt: | ## Session Context @@ -12,6 +12,28 @@ prompt: | # JIRA: Decompose a Ticket into Sub-Tickets + {{ if HasPattern "jira_*" }} + **Access method — JIRA MCP tools.** Every step below names a `jira_*` MCP tool; call each one directly as written. + {{- else }} + **Access method — the `jira` CLI** (ankitpokhrel/jira-cli). You do **not** have JIRA MCP tools, but the `jira` command is on PATH. Each step names a `jira_*` operation — run the CLI equivalent below instead. The `mitto_ui_*` steps and the decomposition reasoning are unchanged. + + | Canonical operation | `jira` CLI command | + |---|---| + | `jira_get_agile_boards_jira` | `jira board list` | + | `jira_get_sprints_from_board_jira` (active) | `jira sprint list --state active --table --plain --columns id,name,state` | + | `jira_search_jira` (JQL) | `jira issue list -q "<JQL>" --plain --no-headers` (append `--raw` for JSON) | + | `jira_get_issue_jira` (fields/renderedFields) | `jira issue list -q "key = <KEY>" --raw` for JSON (all fields, `issuelinks`); `jira issue view <KEY>` for a rendered read (set `PAGER=cat` to avoid the pager blocking) | + | `jira_get_issue_development_info_jira` | no equivalent — fall back to local `git log`/`git branch` (and `gh` if present) to spot existing branches/PRs | + | `jira_get_project_versions_jira` | `jira release list` (add `-p<PROJECT>` to target a project) | + | `jira_create_issue_jira` | `jira issue create -p<PROJECT> -t<TYPE> -s"<summary>" -y<PRIORITY> -l<label> -C<component> -P<PARENT-KEY> --template <desc.md> --no-input` | + | `jira_get_link_types_jira` + `jira_create_issue_link_jira` | `jira issue link <CHILD_KEY> <PARENT_KEY> <LinkType>` (e.g. `Relates`, `Blocks`) | + | `jira_add_issues_to_sprint_jira` | `jira sprint add <SPRINT_ID> <KEY>` | + + CLI notes: + - Pass `--no-input` on `create`; write each sub-ticket description to a temp `.md` file and pass it via `--template <file>`. + - Use `-P<PARENT-KEY>` on `issue create` to attach a sub-ticket to its parent/epic; the CLI has no dedicated "get link types" call, so use a common type name (`Relates`, `Blocks`). + {{- end }} + ## Step 1 — Find tickets to decompose {{ if UserData "JIRA Ticket" -}} diff --git a/config/prompts/builtin/jira-new-ticket.prompt.yaml b/config/prompts/builtin/jira-new-ticket.prompt.yaml index ab85440c..ef2461ca 100644 --- a/config/prompts/builtin/jira-new-ticket.prompt.yaml +++ b/config/prompts/builtin/jira-new-ticket.prompt.yaml @@ -1,10 +1,10 @@ icon: tag name: 'JIRA: new ticket' -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Create a JIRA ticket — from the current conversation context or from scratch backgroundColor: '#C8E6C9' group: JIRA -enabledWhen: Tools.HasPattern("jira_*") +enabledWhen: 'Tools.HasPattern("jira_*") || CommandExists("jira")' preferredModels: - modelTag: Coding prompt: | @@ -14,6 +14,29 @@ prompt: | # JIRA: Create a New Ticket + {{ if HasPattern "jira_*" }} + **Access method — JIRA MCP tools.** Every step below names a `jira_*` MCP tool; call each one directly as written. + {{- else }} + **Access method — the `jira` CLI** (ankitpokhrel/jira-cli). You do **not** have JIRA MCP tools, but the `jira` command is on PATH. Each step names a `jira_*` operation as the canonical action — run the CLI equivalent below instead. The `mitto_ui_*` form/textbox/options steps are unchanged; only the JIRA operations differ. + + | Canonical operation | `jira` CLI command | + |---|---| + | `jira_get_agile_boards_jira` | `jira board list` | + | `jira_get_sprints_from_board_jira` (active) | `jira sprint list --state active --table --plain --columns id,name,state` — take the active sprint's id | + | `jira_search_jira` (JQL) | `jira issue list -q "<JQL>" --plain --no-headers` (append `--raw` for JSON). `currentUser()` works inside JQL | + | `jira_get_server_info_jira` | no equivalent — `jira me` confirms auth/identity; the base URL lives in your jira config | + | `jira_create_issue_jira` | `jira issue create -p<PROJECT> -t<TYPE> -s"<summary>" -y<PRIORITY> -l<label> -C<component> --template <desc.md> --no-input` | + | `jira_assign_issue_jira` | `jira issue assign <KEY> "<user>"` (self: `$(jira me)`; unassign: `x`) | + | `jira_add_issues_to_sprint_jira` | `jira sprint add <SPRINT_ID> <KEY>` | + | `jira_get_transitions_jira` + `jira_transition_issue_jira` | `jira issue move <KEY> "In Progress"` | + | `jira_get_link_types_jira` + `jira_create_issue_link_jira` | `jira issue link <KEY> <TARGET_KEY> <LinkType>` (e.g. `Blocks`, `Relates`) | + + CLI notes: + - Pass `--no-input` on `create`/`assign`/`move` so they never block on the interactive TUI; use `--plain`/`--raw` on `list` for the same reason. + - Write the Markdown description to a temp file and pass it via `--template <file>` — do not inline multi-line descriptions on the command line. + - Labels/components repeat the flag (`-lbug -lurgent`, `-CBackend`); priority is `-yHigh`; attach to an epic/parent with `-P<EPIC-KEY>`. + {{- end }} + ## Step 1 — Determine ticket source First, check the conversation history for meaningful prior work context (investigation, debugging, feature discussion, research findings, etc.). diff --git a/config/prompts/builtin/jira-status-all-inprogress.prompt.yaml b/config/prompts/builtin/jira-status-all-inprogress.prompt.yaml index c311d6fc..cb48f36e 100644 --- a/config/prompts/builtin/jira-status-all-inprogress.prompt.yaml +++ b/config/prompts/builtin/jira-status-all-inprogress.prompt.yaml @@ -4,10 +4,10 @@ menus: prompts description: Fact-check implementation status for all in-progress sprint tickets relevant to this repo backgroundColor: '#FFE0B2' group: JIRA -enabledWhen: Tools.HasPattern("jira_*") +enabledWhen: 'Tools.HasPattern("jira_*") || CommandExists("jira")' preferredModels: - modelTag: Coding -periodic: +loop: mode: optional default: false trigger: onCompletion @@ -15,6 +15,24 @@ periodic: prompt: | # JIRA: Status Check — All In-Progress Tickets + {{ if HasPattern "jira_*" }} + **Access method — JIRA MCP tools.** Every JIRA-fetch step below names a `jira_*` MCP tool; call each one directly as written. + {{- else }} + **Access method — the `jira` CLI** (ankitpokhrel/jira-cli). You do **not** have JIRA MCP tools, but the `jira` command is on PATH. Run the CLI equivalent below wherever a step names a `jira_*` operation. This prompt is **read-only** either way — never mutate JIRA. + + | Canonical operation | `jira` CLI command | + |---|---| + | `jira_get_agile_boards_jira` | `jira board list` | + | `jira_get_sprints_from_board_jira` (active) | `jira sprint list --state active --table --plain --columns id,name,state` | + | `jira_search_jira` (JQL) | `jira issue list -q "<JQL>" --plain --no-headers` (append `--raw` for JSON) | + | `jira_get_issue_jira` (fields/renderedFields,changelog) | `jira issue list -q "key = <KEY>" --raw` for JSON (all fields, `issuelinks`); `jira issue view <KEY> --comments 10` for a rendered read (set `PAGER=cat`) | + | `jira_get_issue_development_info_jira` | no equivalent — rely on the local `git log`/`git branch` evidence this prompt already gathers (and `gh` if present) | + + CLI notes: + - Use `--plain`/`--raw` (and `PAGER=cat` for `view`) so nothing blocks on the interactive TUI. + - The dev-panel (linked PRs/branches/commits) is MCP-only; lean on the git cross-referencing already described and mark evidence "❓ Unknown" when the CLI cannot confirm it. + {{- end }} + ## Step 1 — Identify the current repository Run the following to determine the current repo's name and remote URL: diff --git a/config/prompts/builtin/jira-status-one-inprogress.prompt.yaml b/config/prompts/builtin/jira-status-one-inprogress.prompt.yaml index fe828800..339bf016 100644 --- a/config/prompts/builtin/jira-status-one-inprogress.prompt.yaml +++ b/config/prompts/builtin/jira-status-one-inprogress.prompt.yaml @@ -4,10 +4,10 @@ menus: prompts description: Pick one in-progress ticket relevant to this repo and fact-check its implementation status backgroundColor: '#FFF9C4' group: JIRA -enabledWhen: Tools.HasPattern("jira_*") +enabledWhen: 'Tools.HasPattern("jira_*") || CommandExists("jira")' preferredModels: - modelTag: Coding -periodic: +loop: mode: optional default: false trigger: onCompletion @@ -19,6 +19,24 @@ prompt: | Your session ID is `{{ .Session.ID }}` — use this as `self_id` for all `mitto_*` MCP tool calls. + {{ if HasPattern "jira_*" }} + **Access method — JIRA MCP tools.** Every JIRA-fetch step below names a `jira_*` MCP tool; call each one directly as written. + {{- else }} + **Access method — the `jira` CLI** (ankitpokhrel/jira-cli). You do **not** have JIRA MCP tools, but the `jira` command is on PATH. Run the CLI equivalent below wherever a step names a `jira_*` operation. This prompt is **read-only** either way — never mutate JIRA. + + | Canonical operation | `jira` CLI command | + |---|---| + | `jira_get_agile_boards_jira` | `jira board list` | + | `jira_get_sprints_from_board_jira` (active) | `jira sprint list --state active --table --plain --columns id,name,state` | + | `jira_search_jira` (JQL) | `jira issue list -q "<JQL>" --plain --no-headers` (append `--raw` for JSON) | + | `jira_get_issue_jira` (fields/renderedFields,changelog) | `jira issue list -q "key = <KEY>" --raw` for JSON (all fields, `issuelinks`); `jira issue view <KEY> --comments 10` for a rendered read (set `PAGER=cat`) | + | `jira_get_issue_development_info_jira` | no equivalent — rely on the local `git log`/`git branch` evidence this prompt already gathers (and `gh` if present) | + + CLI notes: + - Use `--plain`/`--raw` (and `PAGER=cat` for `view`) so nothing blocks on the interactive TUI. + - The dev-panel (linked PRs/branches/commits) is MCP-only; lean on git cross-referencing and mark evidence "❓ Unknown" when the CLI cannot confirm it. + {{- end }} + ## Step 1 — Identify the current repository Run the following to determine the current repo's name and remote URL: diff --git a/config/prompts/builtin/jira-sync-tasks.prompt.yaml b/config/prompts/builtin/jira-sync-tasks.prompt.yaml index e8f94ab6..8709c364 100644 --- a/config/prompts/builtin/jira-sync-tasks.prompt.yaml +++ b/config/prompts/builtin/jira-sync-tasks.prompt.yaml @@ -1,17 +1,17 @@ icon: tag name: 'JIRA: sync tasks' menus: prompts -description: Periodically pull JIRA tickets matching the project's saved query into local beads issues +description: Regularly pull JIRA tickets matching the project's saved query into local beads issues backgroundColor: '#D1C4E9' group: JIRA tags: -- periodic +- loop - jira -enabledWhen: Tools.HasPattern("jira_*") && CommandExists("bd") +enabledWhen: '(Tools.HasPattern("jira_*") || CommandExists("jira")) && CommandExists("bd")' preferredModels: - modelTag: Cheap - modelTag: Coding -periodic: +loop: mode: optional default: true value: 1 @@ -22,7 +22,7 @@ prompt: | keeping the beads copy in sync with changes made in JIRA (description, comments, priority, status). This is a **one-way pull** (JIRA → beads) for now; two-way sync may be added later. Run it **on demand** in a regular conversation, or - schedule it to run periodically via `mitto_conversation_set_periodic` — it + schedule it to run as a loop via `mitto_conversation_set_loop` — it adapts its behaviour to whichever mode it is invoked in (see Interaction Mode). ## Session Context @@ -30,17 +30,36 @@ prompt: | Your session ID is `{{ .Session.ID }}` — use this as `self_id` for all `mitto_*` MCP tool calls. ## Interaction Mode - {{- if and .Session.IsPeriodic (not .Session.IsPeriodicForced) }} + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} - **Silent mode** — a scheduled periodic run; the user is not watching. + **Silent mode** — a scheduled loop run; the user is not watching. - Use **only** `mitto_ui_notify` — non-blocking notifications only. - Do **NOT** use `mitto_ui_options`, `mitto_ui_form`, or `mitto_ui_textbox`. {{- else }} - **Interactive mode** — a regular conversation or a force-triggered periodic run; the user is present. + **Interactive mode** — a regular conversation or a force-triggered loop run; the user is present. - Use interactive tools (`mitto_ui_options`, `mitto_ui_form`, `mitto_ui_textbox`) as well as `mitto_ui_notify`. This is the default when run on demand. {{- end }} + ## Access Method + {{- if HasPattern "jira_*" }} + + **JIRA MCP tools.** Step 3 fetches tickets with `jira_search_jira` — call it directly. + {{- else }} + + **The `jira` CLI** (ankitpokhrel/jira-cli). You do **not** have JIRA MCP tools, but the `jira` command is on PATH. Wherever a step calls `jira_search_jira`, run the CLI instead: + + ```bash + jira issue list -q "<the saved JQL>" --raw --paginate 500 + ``` + + CLI notes: + - `--raw` returns JSON with most fields (key, summary, status, priority, issue type, labels, assignee, `updated`, `issuelinks`). Use `--plain --no-headers` if you only need columns. + - Comment bodies may be omitted from list `--raw`; fetch them per ticket with `jira issue view <KEY> --comments <N>` (set `PAGER=cat`), then feed each raw body through the Step 5.0 converter. + - Raise the result cap with `--paginate <N>` (the `from` offset is unsupported on Jira Cloud v3, so narrow the JQL rather than paging by offset). + - Everything else — the Step 5.0 wiki→Markdown converter, beads create/update, comment mirroring, dependency wiring — is unchanged. + {{- end }} + ## Step 1 — Get the "Jira Tasks" query {{ if UserData "Jira Tasks" -}} @@ -53,7 +72,7 @@ prompt: | No "Jira Tasks" query is saved for this conversation. - - **Silent mode** (scheduled periodic run): the query cannot be requested unattended. Send one `mitto_ui_notify` explaining the project has no "Jira Tasks" query configured, then **stop**. + - **Silent mode** (scheduled loop run): the query cannot be requested unattended. Send one `mitto_ui_notify` explaining the project has no "Jira Tasks" query configured, then **stop**. - **Interactive mode**: ask the user for the JQL query with `mitto_ui_form` (a single text field for the query, e.g. `project = ABC AND statusCategory != Done`). Once you have a non-empty value, persist it to the conversation's user data so future runs (including scheduled ones) reuse it: @@ -85,7 +104,7 @@ prompt: | ``` - If `.beads` exists: continue. - - If it does **not** exist: in interactive mode, run `bd init --non-interactive`; in periodic mode, notify and **stop** (do not initialise unattended). + - If it does **not** exist: in interactive mode, run `bd init --non-interactive`; in loop mode, notify and **stop** (do not initialise unattended). ## Step 3 — Fetch matching JIRA tickets @@ -262,13 +281,13 @@ prompt: | If a stale bead has local-only comments/notes (comments without the `[jira]` prefix), call that out in the list so the user does not discard local work unknowingly. - - **Silent mode** (scheduled periodic): **never delete unattended.** If there are stale beads, send a single `mitto_ui_notify` listing them so the user can review and remove them on the next interactive run; otherwise stay silent. + - **Silent mode** (scheduled loop): **never delete unattended.** If there are stale beads, send a single `mitto_ui_notify` listing them so the user can review and remove them on the next interactive run; otherwise stay silent. ## Step 8 — Summary Report counts: tickets matched, beads created, beads updated, comments mirrored, dependencies wired (blocking links), beads closed, beads deleted (stale), unchanged (skipped). - - Silent mode (scheduled periodic): send a single `mitto_ui_notify` only if anything changed; stay silent otherwise. + - Silent mode (scheduled loop): send a single `mitto_ui_notify` only if anything changed; stay silent otherwise. - Interactive mode: print the full summary. ## Guidelines diff --git a/config/prompts/builtin/jira-work.prompt.yaml b/config/prompts/builtin/jira-work.prompt.yaml index 18c6777e..50d4e0dc 100644 --- a/config/prompts/builtin/jira-work.prompt.yaml +++ b/config/prompts/builtin/jira-work.prompt.yaml @@ -4,8 +4,8 @@ menus: prompts description: Pick a JIRA ticket from the active sprint and spawn parallel Mitto conversations to implement it backgroundColor: '#BBDEFB' group: JIRA -enabledWhen: '!Session.IsChild && Tools.HasAllPatterns(["jira_*", "mitto_conversation_*"])' -periodic: +enabledWhen: '!Session.IsChild && (Tools.HasPattern("jira_*") || CommandExists("jira")) && Permissions.CanStartConversation' +loop: mode: optional default: false trigger: onCompletion @@ -21,6 +21,25 @@ prompt: | # JIRA: Start Work on a Ticket + {{ if HasPattern "jira_*" }} + **Access method — JIRA MCP tools.** The JIRA-fetch steps below name `jira_*` MCP tools; call each one directly. (The `mitto_*` orchestration is unaffected.) + {{- else }} + **Access method — the `jira` CLI** (ankitpokhrel/jira-cli). You do **not** have JIRA MCP tools, but the `jira` command is on PATH. Run the CLI equivalent below wherever a step names a `jira_*` operation. All `mitto_*` steps (ui, conversation spawn/wait) are unchanged. + + | Canonical operation | `jira` CLI command | + |---|---| + | `jira_get_agile_boards_jira` | `jira board list` | + | `jira_get_sprints_from_board_jira` (active) | `jira sprint list --state active --table --plain --columns id,name,state` | + | `jira_search_jira` (JQL) | `jira issue list -q "<JQL>" --plain --no-headers` (append `--raw` for JSON) | + | `jira_get_issue_jira` (fields/renderedFields) | `jira issue list -q "key = <KEY>" --raw` for JSON (all fields, `issuelinks`); `jira issue view <KEY>` for a rendered read (set `PAGER=cat`) | + | `jira_download_attachments_jira` | no equivalent — open the attachment URLs found in the `--raw` JSON | + | `jira_get_issue_development_info_jira` | no equivalent — use local `git log`/`git branch` (and `gh` if present) for linked branches/PRs | + + CLI notes: + - Use `--plain`/`--raw` so nothing blocks on the interactive TUI; set `PAGER=cat` before `jira issue view` to bypass the pager. + - Some MCP-only signals (attachments, dev-panel PRs/branches) have no CLI equivalent — gather what you can locally and note the gap rather than blocking. + {{- end }} + ## Step 0 — Check for prior ticket context {{ if UserData "JIRA Ticket" -}} diff --git a/config/prompts/builtin/iterate-fixing.prompt.yaml b/config/prompts/builtin/loop-fixing.prompt.yaml similarity index 53% rename from config/prompts/builtin/iterate-fixing.prompt.yaml rename to config/prompts/builtin/loop-fixing.prompt.yaml index f70aabff..3e6500f5 100644 --- a/config/prompts/builtin/iterate-fixing.prompt.yaml +++ b/config/prompts/builtin/loop-fixing.prompt.yaml @@ -1,6 +1,6 @@ icon: refresh -name: Iterate fixing -menus: promptsPeriodic +name: Loop fixing +menus: promptsLoop parameters: - name: Commit type: boolean @@ -8,7 +8,7 @@ parameters: description: Continue iterating to fix the problem we have been working on group: Development backgroundColor: '#BBDEFB' -periodic: +loop: mode: always trigger: onCompletion delay: 30 @@ -19,10 +19,13 @@ prompt: | file `implement-<problem>-<date>.md` and the relevant source first — do not speculate about code you haven't opened. - Do exactly one increment this run: + Do exactly one increment this run (a single increment may itself fan out into + parallel children — see "Parallelize independent subtasks" below): 1. Review the state file: what's been tried, what's still failing. - 2. Pick the highest-priority remaining issue and find its root cause. + 2. Pick the highest-priority remaining issue and find its root cause. If several + remaining issues are independent and non-conflicting, consider fixing them in + parallel (see "Parallelize independent subtasks" below). 3. Implement the fix — minimal and focused on the root cause, not symptoms. 4. Verify the fix. 5. Update the state file (Progress / Issues remaining). @@ -86,3 +89,42 @@ prompt: | Once fully fixed, verify against original problem description. {{- end }} + + ## Parallelize independent subtasks when possible + + A single increment may itself decompose into **two or more genuinely independent + subtasks** — ones that touch **different files/modules**, have **no ordering + dependencies** between them, and cannot conflict (e.g. unrelated root causes in + separate modules). When such a clean split exists, prefer to fix the subtasks **in + parallel across child conversations** rather than serially inline; this still counts as + one increment for the run. + + Only fan out when **all** of these hold; otherwise do the increment inline as above: + + - this conversation can spawn children — it is **top-level** (not itself a child) and + the **Can start conversation** + **Can Send Prompt** advanced-settings flags are on; + - the subtasks are **disjoint** — no shared files, no shared state, and none depends on + another's output. **Never** split work that has interdependencies or could produce + conflicting edits; + - splitting genuinely saves wall-clock time (skip it for one-file or trivial changes). + + To fan out (use `{{ .Session.ID }}` as `self_id` for every `mitto_*` call): + + 1. Define each subtask completely and self-contained: the root cause to fix, the + **disjoint** set of files/modules it owns (verify the sets do not overlap), how to + verify that fix, and a definition of done. Cap at **3–4** parallel subtasks per run. + 2. For each subtask, reuse a suitable **idle** existing child when available, otherwise + `mitto_conversation_new` one — preferring a faster/cheaper agent for routine work — + seeding a fully self-contained worker prompt that ends by reporting via + `mitto_children_tasks_report`. + 3. Block on all of them at once: + `mitto_children_tasks_wait(self_id: "{{ .Session.ID }}", children_list: [<child ids>], task_id: "<short label>", timeout_seconds: 1800)`. + On timeout, retry the pending children with the **same** `task_id` (omit the prompt + to avoid duplicates); after a second timeout treat those subtasks as failed. + 4. **Synthesize** the reports, verify the combined result, then update the state file + (and commit, if enabled) exactly as for an inline increment. Clean up finished + children you no longer need. + + If any subtask reports a conflict with another, stop parallelizing that work and finish + it inline on a subsequent run. If a spawn call errors because a flag is off, fall back + to doing the increment inline. diff --git a/config/prompts/builtin/iterate-implementing.prompt.yaml b/config/prompts/builtin/loop-implementing.prompt.yaml similarity index 52% rename from config/prompts/builtin/iterate-implementing.prompt.yaml rename to config/prompts/builtin/loop-implementing.prompt.yaml index 29bd83de..5a8905ae 100644 --- a/config/prompts/builtin/iterate-implementing.prompt.yaml +++ b/config/prompts/builtin/loop-implementing.prompt.yaml @@ -1,6 +1,6 @@ icon: refresh -name: Iterate implementing -menus: promptsPeriodic +name: Loop implementing +menus: promptsLoop parameters: - name: Commit type: boolean @@ -8,7 +8,7 @@ parameters: description: Continue iterating to implement the feature we have been working on group: Development backgroundColor: '#BBDEFB' -periodic: +loop: mode: always trigger: onCompletion delay: 30 @@ -19,10 +19,13 @@ prompt: | state file `implement-<problem>-<date>.md` and the relevant source first — do not speculate about code you haven't opened. - Do exactly one increment this run: + Do exactly one increment this run (a single increment may itself fan out into + parallel children — see "Parallelize independent subtasks" below): 1. Review the state file: what's done and what's still missing. - 2. Pick the next work item from the spec — no extra features or abstractions. + 2. Pick the next work item from the spec — no extra features or abstractions. Before + implementing, check whether it decomposes into independent parts you can run in + parallel (see "Parallelize independent subtasks" below). 3. Implement it. 4. Verify the implementation. 5. Update the state file (Progress / Issues remaining). @@ -83,3 +86,41 @@ prompt: | Once complete, verify against original problem description. {{- end }} + + ## Parallelize independent subtasks when possible + + A single increment may itself decompose into **two or more genuinely independent + subtasks** — ones that touch **different files/modules**, have **no ordering + dependencies** between them, and cannot conflict. When such a clean split exists, + prefer to run the subtasks **in parallel across child conversations** rather than + serially inline; this still counts as one increment for the run. + + Only fan out when **all** of these hold; otherwise do the increment inline as above: + + - this conversation can spawn children — it is **top-level** (not itself a child) and + the **Can start conversation** + **Can Send Prompt** advanced-settings flags are on; + - the subtasks are **disjoint** — no shared files, no shared state, and none depends on + another's output. **Never** split work that has interdependencies or could produce + conflicting edits; + - splitting genuinely saves wall-clock time (skip it for one-file or trivial changes). + + To fan out (use `{{ .Session.ID }}` as `self_id` for every `mitto_*` call): + + 1. Define each subtask completely and self-contained: what to do, the **disjoint** set + of files/modules it owns (verify the sets do not overlap), expected outcome, + constraints, and a definition of done. Cap at **3–4** parallel subtasks per run. + 2. For each subtask, reuse a suitable **idle** existing child when available, otherwise + `mitto_conversation_new` one — preferring a faster/cheaper agent for routine work — + seeding a fully self-contained worker prompt that ends by reporting via + `mitto_children_tasks_report`. + 3. Block on all of them at once: + `mitto_children_tasks_wait(self_id: "{{ .Session.ID }}", children_list: [<child ids>], task_id: "<short label>", timeout_seconds: 1800)`. + On timeout, retry the pending children with the **same** `task_id` (omit the prompt + to avoid duplicates); after a second timeout treat those subtasks as failed. + 4. **Synthesize** the reports, verify the combined result, then update the state file + (and commit, if enabled) exactly as for an inline increment. Clean up finished + children you no longer need. + + If any subtask reports a conflict with another, stop parallelizing that work and finish + it inline on a subsequent run. If a spawn call errors because a flag is off, fall back + to doing the increment inline. diff --git a/config/prompts/builtin/iterate-until.prompt.yaml b/config/prompts/builtin/loop-until.prompt.yaml similarity index 68% rename from config/prompts/builtin/iterate-until.prompt.yaml rename to config/prompts/builtin/loop-until.prompt.yaml index 2588f3aa..82282ea7 100644 --- a/config/prompts/builtin/iterate-until.prompt.yaml +++ b/config/prompts/builtin/loop-until.prompt.yaml @@ -1,6 +1,6 @@ -icon: periodic -name: Iterate until ... -menus: conversation +icon: loop +name: Loop until ... +menus: prompts, conversation parameters: - name: Condition type: text @@ -9,11 +9,11 @@ parameters: - name: Commit type: boolean description: Commit the changes made at the end of each iteration -description: Make this conversation periodic (on completion) and keep iterating until your condition is met, then self-terminate +description: Make this conversation a loop (on completion) and keep iterating until your condition is met, then self-terminate backgroundColor: '#D1C4E9' group: Work flow -enabledWhen: '!Session.IsChild && !Session.IsPeriodicConversation && Tools.HasPattern("mitto_conversation_*")' -periodic: +enabledWhen: '!Session.IsChild && !Session.IsLoopConversation' +loop: mode: always trigger: onCompletion delay: 30 @@ -37,18 +37,18 @@ prompt: | ## Step 1 — Arm the loop Make this conversation re-run automatically after each completion. Set - `periodic_prompt` to the continuation prompt below, with `<CONDITION>` replaced by + `loop_prompt` to the continuation prompt below, with `<CONDITION>` replaced by the **literal text** of the stop condition above — scheduled runs never receive this setup prompt, so the condition must be embedded: ``` mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", - periodic_trigger: "onCompletion", - periodic_completion_delay_seconds: 30, - periodic_max_iterations: 20, - periodic_max_duration_seconds: 14400, - periodic_enabled: true, - periodic_prompt: "<continuation prompt — built from the template below>") + loop_trigger: "onCompletion", + loop_completion_delay_seconds: 30, + loop_max_iterations: 20, + loop_max_duration_seconds: 14400, + loop_enabled: true, + loop_prompt: "<continuation prompt — built from the template below>") ``` Continuation prompt template (keep verbatim except `<CONDITION>`): @@ -67,11 +67,22 @@ prompt: | contents, command exit codes), never against your intentions. If you cannot verify it is true, treat it as not yet met. 3. If it is TRUE: disable the loop — - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) — + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) — then mitto_ui_notify(self_id: "{{ .Session.ID }}", title: "Iteration complete", message: "<how the condition was satisfied>", style: "success"). Do nothing further. 4. If it is NOT met: do exactly ONE concrete increment toward it, verify that increment, briefly note progress, then stop responding so the next run continues. + When that increment decomposes into two or more genuinely independent subtasks + (different files/modules, no ordering dependencies, no possible conflict) and + this conversation can spawn children (Can start conversation + Can Send Prompt + flags on), fan them out to run in parallel instead of serially: give each child a + self-contained task over a DISJOINT file set that ends by reporting via + mitto_children_tasks_report, block on them all with + mitto_children_tasks_wait(self_id: "{{ .Session.ID }}", children_list: [...], + task_id: "<label>", timeout_seconds: 1800), then synthesize the reports and + verify the combined result. Never split work with interdependencies or shared + files; do it inline instead. If a spawn call errors because a flag is off, fall + back to doing the increment inline. {{- if eq .Args.Commit "true" }} If you changed files and verified them, commit ONLY those files, staged explicitly by path (git add <file> ...); never git add -A, git add ., or @@ -90,5 +101,5 @@ prompt: | you just armed: review the state and evaluate the stop condition. If it is **already true**, disable the loop and notify — there is nothing to do. Otherwise perform exactly **one** increment toward it, verify it, and report what you - advanced and what remains. Then stop responding; the periodic engine arms the next + advanced and what remains. Then stop responding; the loop engine arms the next run automatically. diff --git a/config/prompts/builtin/optimize.prompt.yaml b/config/prompts/builtin/optimize.prompt.yaml index 4250c1ac..d072ba9b 100644 --- a/config/prompts/builtin/optimize.prompt.yaml +++ b/config/prompts/builtin/optimize.prompt.yaml @@ -1,6 +1,6 @@ icon: sliders name: Optimize -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Identify and propose performance improvements group: Code Quality backgroundColor: '#C8E6C9' diff --git a/config/prompts/builtin/propose-a-plan.prompt.yaml b/config/prompts/builtin/propose-a-plan.prompt.yaml index 7e764f0a..123e4cf5 100644 --- a/config/prompts/builtin/propose-a-plan.prompt.yaml +++ b/config/prompts/builtin/propose-a-plan.prompt.yaml @@ -1,6 +1,6 @@ icon: list name: Propose a plan -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Create a detailed plan for the current task group: Planning backgroundColor: '#BBDEFB' diff --git a/config/prompts/builtin/rebase-changes.prompt.yaml b/config/prompts/builtin/rebase-changes.prompt.yaml index 6f3067e0..37e2c171 100644 --- a/config/prompts/builtin/rebase-changes.prompt.yaml +++ b/config/prompts/builtin/rebase-changes.prompt.yaml @@ -1,6 +1,6 @@ icon: sync name: Rebase changes -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Rebase changes on top of main group: Submission of changes backgroundColor: '#B2DFDB' diff --git a/config/prompts/builtin/refactor.prompt.yaml b/config/prompts/builtin/refactor.prompt.yaml index bca5cc61..2a22b91f 100644 --- a/config/prompts/builtin/refactor.prompt.yaml +++ b/config/prompts/builtin/refactor.prompt.yaml @@ -1,6 +1,6 @@ icon: magic-wand name: Refactor -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Propose refactoring improvements for better code quality group: Code Quality backgroundColor: '#C8E6C9' diff --git a/config/prompts/builtin/report-to-parent.prompt.yaml b/config/prompts/builtin/report-to-parent.prompt.yaml index 6f87a5cb..bcf0a1c1 100644 --- a/config/prompts/builtin/report-to-parent.prompt.yaml +++ b/config/prompts/builtin/report-to-parent.prompt.yaml @@ -4,7 +4,7 @@ description: Send a status report to the parent conversation group: Work flow menus: prompts, conversation backgroundColor: '#FFF9C4' -enabledWhen: Session.IsChild && Parent.Exists && Tools.HasPattern("mitto_conversation_*") && !Session.IsPeriodicConversation +enabledWhen: Session.IsChild && Parent.Exists && Permissions.CanSendPrompt && !Session.IsLoopConversation preferredModels: - modelTag: Cheap - modelTag: Coding diff --git a/config/prompts/builtin/reproduce-bug.prompt.yaml b/config/prompts/builtin/reproduce-bug.prompt.yaml index 9c2809b6..bf499f62 100644 --- a/config/prompts/builtin/reproduce-bug.prompt.yaml +++ b/config/prompts/builtin/reproduce-bug.prompt.yaml @@ -1,6 +1,6 @@ icon: error name: Reproduce bug -menus: prompts, conversation, beadsIssues, !promptsPeriodic +menus: prompts, conversation, beadsIssues, !promptsLoop parameters: - name: IssueID type: beadsId diff --git a/config/prompts/builtin/review-changes.prompt.yaml b/config/prompts/builtin/review-changes.prompt.yaml index 38ba2860..c34de1a7 100644 --- a/config/prompts/builtin/review-changes.prompt.yaml +++ b/config/prompts/builtin/review-changes.prompt.yaml @@ -1,6 +1,6 @@ icon: check name: Review Changes -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: 'Review recent changes against requirements: completeness, correctness, tight scope' group: Code Quality backgroundColor: '#C8E6C9' diff --git a/config/prompts/builtin/review.prompt.yaml b/config/prompts/builtin/review.prompt.yaml index 34ea44f6..49f47e99 100644 --- a/config/prompts/builtin/review.prompt.yaml +++ b/config/prompts/builtin/review.prompt.yaml @@ -1,6 +1,6 @@ icon: search name: Review -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Review changes for quality and correctness group: Code Quality backgroundColor: '#C8E6C9' diff --git a/config/prompts/builtin/run-tests.prompt.yaml b/config/prompts/builtin/run-tests.prompt.yaml index a1eb252e..538469fb 100644 --- a/config/prompts/builtin/run-tests.prompt.yaml +++ b/config/prompts/builtin/run-tests.prompt.yaml @@ -6,7 +6,7 @@ group: Testing backgroundColor: '#FFE0B2' preferredModels: - modelTag: Coding -periodic: +loop: mode: optional default: false trigger: onCompletion diff --git a/config/prompts/builtin/simplify.prompt.yaml b/config/prompts/builtin/simplify.prompt.yaml index faaa87b4..abe9ae3e 100644 --- a/config/prompts/builtin/simplify.prompt.yaml +++ b/config/prompts/builtin/simplify.prompt.yaml @@ -1,6 +1,6 @@ icon: magic-wand name: Simplify -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Simplify implementation while preserving functionality group: Code Quality backgroundColor: '#C8E6C9' diff --git a/config/prompts/builtin/specialize-prompts.prompt.yaml b/config/prompts/builtin/specialize-prompts.prompt.yaml index ef745c62..90ad50a3 100644 --- a/config/prompts/builtin/specialize-prompts.prompt.yaml +++ b/config/prompts/builtin/specialize-prompts.prompt.yaml @@ -1,10 +1,10 @@ icon: magic-wand name: Specialize prompts -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Analyze and specialize workspace prompts for this project group: Agents & Mitto backgroundColor: '#B3E5FC' -enabledWhen: '!Session.IsPeriodicConversation' +enabledWhen: '!Session.IsLoopConversation' prompt: | Specialize the available prompts for this workspace by analyzing the project and tailoring generic prompts to its specific technologies, commands, and workflows. diff --git a/config/prompts/builtin/submit-changes.prompt.yaml b/config/prompts/builtin/submit-changes.prompt.yaml index 2b36d64c..c7da7983 100644 --- a/config/prompts/builtin/submit-changes.prompt.yaml +++ b/config/prompts/builtin/submit-changes.prompt.yaml @@ -1,6 +1,6 @@ icon: globe name: Submit changes -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop description: Submit changes group: Submission of changes backgroundColor: '#B2DFDB' diff --git a/config/prompts/builtin/support-check-status.prompt.yaml b/config/prompts/builtin/support-check-status.prompt.yaml new file mode 100644 index 00000000..202037de --- /dev/null +++ b/config/prompts/builtin/support-check-status.prompt.yaml @@ -0,0 +1,138 @@ +name: 'Support: check status' +description: 'Refresh a tracked support question from Slack, update its bead, and propose closing if resolved. Never posts to Slack.' +group: Support +backgroundColor: '#E1BEE7' +icon: sync +menus: beadsIssues +enabledWhen: 'CommandExists("bd") && DirExists(".beads") && Item.Status != "closed" && "support-question" in Item.Labels' +tags: +- support +parameters: + - name: IssueID + description: 'The beads issue ID to act on (auto-filled from the Beads issue menu)' + required: false + type: beadsId +prompt: |- + # Support — Check Status + + ## Session Context + + Your session ID is `{{ .Session.ID }}` — use it as `self_id` for all `mitto_*` MCP tool calls. + + ## Description + + Bring a tracked support question up to date. This prompt re-fetches the Slack thread, records any + new messages on the bead, updates the state to reflect whose turn it is, presents a summary of + where the conversation stands, and — if the customer looks satisfied and there is no open question + — proposes to **close** the bead. + + > **Applicable state:** any open bead (no gate). + + ## CRITICAL: User Interaction Rules + + > ⚠️ **NEVER use text-based interaction prompts.** + > + > - ❌ NEVER ask the user to type a number, command, or keyword to make a selection + > - ❌ NEVER present numbered options and ask the user to respond with text + > - ✅ ALWAYS use `mitto_ui_options` for choices, `mitto_ui_textbox` for review/editing, + > `mitto_ui_form` for structured input, and `mitto_ui_notify` for non-blocking notifications + > + > ⚠️ This prompt presents its summary **in the conversation**, not in Slack. It must **NEVER** post + > to the Slack channel. + + ## Slack tools (names vary by MCP server) + + Match Slack MCP tools **by capability, not exact name**. Here you only need to **read a thread's + replies** (e.g. `slack_get_thread_replies*`, `conversations_replies_slack`). No posting tool is + needed — this prompt never posts. + + ## Step 1: Identify the target bead + + {{ $target := "" -}} + {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }}{{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} + {{ if $target }} + - This prompt was launched from bead **`{{ $target }}`** — operate on it directly (skip the picker). + {{- else }} + - **No linked bead** — show a picker: list open tracked questions + `bd list -l support-question --status open,in_progress --all` and present them with + `mitto_ui_options` (first option `{label: "None - Cancel"}`, then one + `{label: "<id> [state] — <short summary>"}` per bead, timeout 300). If the user cancels, + acknowledge and stop. + {{- end }} + + ## Step 2: Load the bead history + + - Load `bd show <id>` and `bd comments <id>` so you know what is already captured and what the last + recorded state and message were. + - Note the current `state:*` label, the stored `slack_thread_ts` / `slack_channel` / `slack_url` + metadata, and the timestamp/author of the most recent comment. + + ## Step 3: Refresh the Slack thread + + - Re-fetch the full thread (read-thread-replies tool) using the stored `slack_thread_ts` and + `slack_channel` from the bead metadata / `# Links`. + - Build the chronological list of thread messages with author + UTC time. Identify which messages + are **new** (not yet captured as comments on the bead) since our last recorded activity. + - Note any reactions on our answers (a positive reaction = helpful, a negative one = not helpful). + + ## Step 4: Reconcile new messages onto the bead + + - For **each** new thread message, add a markdown comment (preserve layout with `$'...'` or + `printf '%s' "$c" | bd comment <id> --stdin`): + - Customer messages → `**[INBOUND @user · YYYY-MM-DD HH:MM UTC]**` + - Team / other participants → `**[CONTEXT @user · YYYY-MM-DD HH:MM UTC]**` + - Any of our own replies not yet logged → `**[OUTBOUND us · YYYY-MM-DD HH:MM UTC]**` + - Do **not** duplicate comments that are already on the bead. + + ## Step 5: Update the state to reflect reality + + Update the `state:*` label in place (same issue — never `bd set-state`), based on who spoke last + and what they said: + + - The **last message is from the customer** and it asks something / provides info we must act on → + `state:awaiting-us`. + - The **last message is ours** and we are waiting on the customer → `state:awaiting-customer`. + - Do **not** override `state:need-info` or `state:drafting` if the situation has not changed (those + reflect a pending action of ours). Only change the label when the thread clearly moved on. + + Apply with: `bd update <id> --remove-label state:<previous> --add-label state:<new>`, and add a + short `**[STATE · <time>]** <prev> → <new>` comment noting why. + + ## Step 6: Present the current-status summary (in the conversation) + + - Present a concise, chronological summary **to the user in this conversation** (not to Slack): + + ```markdown + ## Status — <id> ([thread](<slack_url>)) + + **Topic:** <brief topic> + **State:** <state:* label> (updated from <previous> if changed) + + ### Thread history + 1. [@user] (2d ago): <summary> + 2. [OUR REPLY] (1d ago): <summary> + 3. [@user] (2h ago): <summary> ← new + + ### Where it stands + <what is open / who owes the next move / any helpful/not-helpful reactions> + ``` + + ## Step 7: Propose closing if resolved + + - Judge whether the question is **resolved**. Signals: a positive reaction on our answer; the + customer's last message is a "thanks / that worked / got it"; the thread concluded with no + outstanding question. + - **If resolved**, use `mitto_ui_options`: + - **Question**: "This looks resolved. Close the bead?" + - **Options**: `[{label: "Close it"}, {label: "Keep open"}]`, timeout 300. + - On **"Close it"**: `bd update <id> --remove-label state:<previous> --add-label state:resolved` + then `bd close <id> -r "resolved: customer satisfied / question answered"`. + - On **"Keep open"**: leave as-is and acknowledge. + - **If not resolved**, `mitto_ui_notify` (info) with the next suggested action, e.g. run + **"Support: reply to user"** (if we can answer) or **"Support: gather more information"** (if we + still need details), then stop. + + ## Notes + + - This prompt is **read-and-record** on Slack — it never posts a reply to the channel. + - Keep the bead the single source of truth: log every new thread message and every state change. diff --git a/config/prompts/builtin/support-continue-conversation.prompt.yaml b/config/prompts/builtin/support-continue-conversation.prompt.yaml new file mode 100644 index 00000000..2483a1cd --- /dev/null +++ b/config/prompts/builtin/support-continue-conversation.prompt.yaml @@ -0,0 +1,152 @@ +name: 'Support: continue conversation' +description: 'Find a Slack channel conversation you have been participating in, summarize it, and help craft a reply. Posts to Slack only on your approval.' +group: Support +backgroundColor: '#C8E6C9' +icon: chat-bubble +menus: prompts +enabledWhen: 'Tools.HasAnyPattern(["slack_*", "*_slack"])' +tags: +- support +parameters: + - name: SlackChannelID + type: text + required: true + description: 'The Slack channel ID to scan for conversations you are participating in (e.g. C0XXXXXXX).' + - name: SlackWorkspaceURL + type: text + required: true + description: 'Base Slack workspace URL used to build message permalinks (e.g. https://yourteam.slack.com). No trailing slash.' + - name: LookbackDays + type: text + required: false + description: 'How many days of channel history to scan (default 7).' +prompt: |- + {{- $channel := .Args.SlackChannelID -}} + {{- $ws := .Args.SlackWorkspaceURL -}} + {{- $days := Arg "LookbackDays" "7" -}} + # Support — Continue Conversation + + ## Session Context + + Your session ID is `{{ .Session.ID }}` — use it as `self_id` for all `mitto_*` MCP tool calls. + + ## Description + + Find recent conversations in the Slack channel **`{{ $channel }}`** where the current user has been + participating, let the user pick one, summarize it, and help craft a reply — either by gathering an + answer via the project's documented method or by rephrasing user-provided text. + + ## CRITICAL: User Interaction Rules + + > ⚠️ **NEVER use text-based interaction prompts.** + > + > - ❌ NEVER write things like `Type "do #N"`, `Type "post #N"`, or any variation + > - ❌ NEVER ask the user to type a number, command, or keyword to make a selection + > - ✅ ALWAYS use `mitto_ui_options` (choices), `mitto_ui_textbox` (review/edit), + > `mitto_ui_form` (structured input), `mitto_ui_notify` (non-blocking notifications) + > + > ⚠️ **NEVER post to Slack without explicit user review and approval.** + + ## Slack tools (names vary by MCP server) + + Match Slack MCP tools **by capability, not exact name**. You need: (a) identify the current user, + (b) read recent channel history, (c) read a thread's replies, and (d) post a reply to a thread + (e.g. `slack_get_users*`, `slack_get_channel_history*`, `slack_get_thread_replies*`, + `slack_reply_to_thread*`, or their `*_slack` equivalents). + + ## Step 1: Identify the current user + + - Determine the current user's Slack user ID and display name (user-lookup / who-am-I tool). + - Store the user ID for filtering in the next step. + + ## Step 2: Fetch recent channel messages + + - Read the latest messages from `{{ $channel }}` (a reasonable limit, e.g. 50–100 messages). + - **Only consider messages from the last {{ $days }} days.** Discard anything older. + - For each message that has a thread (reply_count > 0), fetch the thread replies. + - **Filter**: keep ONLY threads where the current user has posted at least one message (the + original message or a reply in the thread). + - **Exclude resolved conversations.** Discard threads where the user appears satisfied or no longer + needs help. Signals include: + - A positive reaction on an answer + - The user's last message is a "thanks", "got it", "that worked", or similar acknowledgement + - The conversation naturally concluded with no outstanding questions + - Someone else already provided a comprehensive answer and the user accepted it + Only keep conversations where there is still an open question, an unresolved problem, or someone + is clearly waiting for a response. + + ## Step 3: Present conversations to the user + + - Build a permalink per thread from its ts: `{{ $ws }}/archives/{{ $channel }}/p<ts_without_dot>` + (e.g. `1234567890.123456` → `p1234567890123456`). + - Present a selection menu with `mitto_ui_options`: + - First option: `{label: "None - Cancel"}` + - Then one option per conversation: `{label: "<brief summary> — <last activity>"}` + - Timeout: 300 seconds + - If the user selects "None - Cancel", acknowledge and end. + + ## Step 4: Show conversation summary + + - Fetch the full thread (read-thread-replies tool) if not already fetched. + - Present a **chronological summary** to the user in this conversation: + + ```markdown + ## Conversation Summary + + **Topic:** <brief topic> + **Started:** <date/time> + **Participants:** <list> + + ### Thread history + 1. [@user1] (2d ago): <original question> + 2. [@you] (1d ago): <what you said> + 3. [@user1] (3h ago): <latest follow-up> + + ### Current status + <what is still open / unanswered / needs attention> + ``` + + ## Step 5: Propose next action + + - Use `mitto_ui_options`: + - **Question**: "How would you like to reply?" + - **Options**: `[{label: "Gather an answer for me"}, {label: "I'll provide the answer"}, {label: "Cancel"}]` + - **Timeout**: 300 seconds + + ### Option A — "Gather an answer for me" + + - Use the **project's documented method to gather answers** (see AGENTS.md → "How to answer + customer questions", or `.augment/rules/` / a docs-search MCP tool / runbook / knowledge base). + If you do **not** know a reliable method, ASK the user how to gather it (via `mitto_ui_form` / + `mitto_ui_textbox`) and persist their answer to AGENTS.md under a + `## How to answer customer questions` section before proceeding. + - Feed the full conversation context (original question, thread history, the specific unanswered + question) and ask for a response that addresses the open point, avoids repeating what was said, + and links docs/tickets if relevant. Proceed to Step 6. + + ### Option B — "I'll provide the answer" + + - Use `mitto_ui_textbox` (Title "Type your reply", Text "", Result "full", Abort true, Timeout 300). + - If the user submits, rephrase their answer to be clearer/professional: fix grammar and typos, add + structure if complex, keep the technical accuracy and the user's voice/intent. Proceed to Step 6. + - If the user aborts, acknowledge and end. + + ## Step 6: Review and post reply + + - Show the thread link so the user can review context: + `📤 Replying to [this thread]({{ $ws }}/archives/{{ $channel }}/p<thread_ts_without_dot>)`. + - **Response formatting guidelines:** + 1. **No direct addressing** — do not address the user by name. + 2. **No follow-up offers** — do not end with "let me know if you need anything else". + 3. **Express uncertainty when appropriate** — "I think that…", "Based on my understanding…". + - Use `mitto_ui_textbox` to present the proposed reply for review/editing (Title "📤 Review reply + before posting to Slack", Result "full", Abort true, Timeout 300). + - **If the user submits:** the returned text (possibly edited) is the final message. Post it with + the post-reply-to-thread tool using channel `{{ $channel }}` and the thread's parent `thread_ts`. + Confirm success and show the permalink. + - **If the user aborts:** acknowledge and end. Do **NOT** post anything to Slack. + + ## Notes + + - **Slack message approval**: NEVER post to Slack without explicit user approval. + - Thread timestamps: convert `1234567890.123456` → `p1234567890123456` for URLs. diff --git a/config/prompts/builtin/support-gather-info.prompt.yaml b/config/prompts/builtin/support-gather-info.prompt.yaml new file mode 100644 index 00000000..1ad9466c --- /dev/null +++ b/config/prompts/builtin/support-gather-info.prompt.yaml @@ -0,0 +1,131 @@ +name: 'Support: gather more information' +description: 'Ask the customer for missing details on a tracked support question (bead in state:need-info). Posts to Slack only on your approval.' +group: Support +backgroundColor: '#FFE0B2' +icon: question +menus: beadsIssues +enabledWhen: 'CommandExists("bd") && DirExists(".beads") && Item.Status != "closed" && "support-question" in Item.Labels && "state:need-info" in Item.Labels' +tags: +- support +parameters: + - name: IssueID + description: 'The beads issue ID to act on (auto-filled from the Beads issue menu)' + required: false + type: beadsId +prompt: |- + # Support — Gather More Information + + ## Session Context + + Your session ID is `{{ .Session.ID }}` — use it as `self_id` for all `mitto_*` MCP tool calls. + + ## Description + + Ask the customer for the details we are missing before we can help. Use this when we **cannot + answer yet** because the question is unclear or we need more context (environment, identifiers, + error codes, timing, a reproduction, etc.). It drafts a short, targeted clarifying message, lets + you edit it, posts it to the Slack thread **on your approval**, and records it on the bead. + + > **Applicable state:** `state:need-info`. If the bead is in a different state, warn and let the + > user decide whether to continue or stop (see Step 2). + + ## CRITICAL: User Interaction Rules + + > ⚠️ **NEVER use text-based interaction prompts.** + > + > - ❌ NEVER ask the user to type a number, command, or keyword to make a selection + > - ❌ NEVER present numbered options and ask the user to respond with text + > - ✅ ALWAYS use `mitto_ui_options` for choices, `mitto_ui_textbox` for review/editing, + > `mitto_ui_form` for structured input, and `mitto_ui_notify` for non-blocking notifications + > + > ⚠️ **NEVER post to Slack without explicit user review and approval.** + + ## Slack tools (names vary by MCP server) + + Match Slack MCP tools **by capability, not exact name**. You need: (a) **read a thread's replies** + and (b) **post a reply to a thread** (e.g. `slack_reply_to_thread*`). Read the target channel and + parent `thread_ts` from the bead metadata (`slack_channel` / `slack_thread_ts`). + + ## Step 1: Identify the target bead + + {{ $target := "" -}} + {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }}{{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} + {{ if $target }} + - This prompt was launched from bead **`{{ $target }}`** — operate on it directly (skip the picker). + {{- else }} + - **No linked bead** — show a picker: list candidates and keep only those in `state:need-info`: + `bd list -l support-question --status open,in_progress --all` (read each `state:*` label from + `bd show`). Present them with `mitto_ui_options` (first option `{label: "None - Cancel"}`, then + one `{label: "<id> — <short summary>"}` per bead, timeout 300). + - If there are no `state:need-info` beads, `mitto_ui_notify` (info) that nothing needs clarification + and stop. If the user cancels, acknowledge and stop. + {{- end }} + + ## Step 2: Load history + verify state (self-check) + + - Load the full history: `bd show <id>` and `bd comments <id>`. Note the stored `slack_thread_ts` + / `slack_channel` / `slack_url` metadata. + - Confirm the `state:*` label is `need-info`. If it is **not**, use `mitto_ui_options` to warn: + - **Question**: "This bead is in `state:<current>`, not `need-info`. Continue anyway?" + - **Options**: `[{label: "Continue anyway"}, {label: "Cancel"}]`, timeout 300. + - On "Cancel", stop. On "Continue anyway", proceed. + + ## Step 3: Refresh the thread + identify the gap + + - Re-fetch the Slack thread (read-thread-replies tool) using the stored `slack_thread_ts` so the + ask reflects the latest state (the customer may have already provided some of it). + - From the bead history + thread, determine **exactly what is missing**. Be specific — list the + concrete facts we need, for example: + - Affected **environment** / host / namespace or service + - Exact **error code(s)** and a sample request/response identifier or body + - **When** it started and whether it is continuous or intermittent + - A **reproduction** (a `curl`/Postman example) if relevant + + ## Step 4: Draft the clarifying message + + - Write a **short, friendly, specific** message that asks only for what we actually need — ideally + one or two concrete questions, not a long checklist. Follow the reply style: + 1. **No direct addressing** — do not use the user's name. + 2. **Hedge** — "To help dig into this, could you share…". + 3. **Explain briefly why** the detail helps, if it is not obvious. + 4. **Keep it short** — avoid walls of text; make it easy to answer. + - Show a clickable link to the thread for context (use the stored `slack_url`): + `📤 Asking in [this thread](<slack_url>)`. + + ## Step 5: Review in an editable textbox + + - Use `mitto_ui_textbox`: + - **Title**: "📤 Review clarifying question before posting to Slack" + - **Text**: the proposed message + - **Result**: "full" + - **Abort**: true + - **Timeout**: 300 + + ## Step 6: Post + log (on submit) + + - The returned text (possibly edited) is the final message. Post it with the post-reply-to-thread + tool, using the stored `slack_channel` and the thread's parent `slack_thread_ts`. + - Confirm success and show the permalink (build it from the posted ts: `<slack_url base>/archives/ + <slack_channel>/p<posted_ts_without_dot>`). + - **Log to the bead** — add a markdown `[OUTBOUND]` comment with the final text and the permalink. + Preserve the markdown layout with `$'...'` (real `\n`) or stdin: + ``` + printf '%s' "$comment_markdown" | bd comment <id> --stdin + ``` + Header: `**[OUTBOUND us · YYYY-MM-DD HH:MM UTC]**` — and note in the body that this is a request + for more information. + - **Transition state** (same issue, no subtasks — never `bd set-state`): + `bd update <id> --remove-label state:need-info --add-label state:awaiting-customer`. + (If Step 2 continued from a different state, remove that state label instead.) + + ## On abort + + - Do **NOT** post anything to Slack. Leave the bead in `state:need-info`. + - Acknowledge and stop. + + ## Notes + + - Load the bead (`bd show` + `bd comments`) **before** drafting — it is the source of truth. + - When the customer replies, the thread moves to `state:awaiting-us`; use **"Support: check + status"** to pull the reply onto the bead, then **"Support: reply to user"** once we can answer. + - **NEVER** post in the support channel without explicit review and approval. diff --git a/config/prompts/builtin/support-reply-to-user.prompt.yaml b/config/prompts/builtin/support-reply-to-user.prompt.yaml new file mode 100644 index 00000000..16799381 --- /dev/null +++ b/config/prompts/builtin/support-reply-to-user.prompt.yaml @@ -0,0 +1,132 @@ +name: 'Support: reply to user' +description: 'Draft, review and post our answer to a tracked support question (bead in state:drafting). Posts to Slack only on your approval.' +group: Support +backgroundColor: '#B3E5FC' +icon: chat-bubble +menus: beadsIssues +enabledWhen: 'CommandExists("bd") && DirExists(".beads") && Item.Status != "closed" && "support-question" in Item.Labels && "state:drafting" in Item.Labels' +tags: +- support +parameters: + - name: IssueID + description: 'The beads issue ID to act on (auto-filled from the Beads issue menu)' + required: false + type: beadsId +prompt: |- + # Support — Reply to User + + ## Session Context + + Your session ID is `{{ .Session.ID }}` — use it as `self_id` for all `mitto_*` MCP tool calls. + + ## Description + + Post our answer to a tracked support question. Use this when the bead is **ready to be answered** — + we already have enough information (typically after gathering info via the project's documented + method or an investigation). It shows the proposed reply, lets you edit it, posts it to the Slack + thread **on your approval**, and records it on the bead. + + > **Applicable state:** `state:drafting`. If the bead is in a different state, warn and let the + > user decide whether to continue or stop (see Step 2). + + ## CRITICAL: User Interaction Rules + + > ⚠️ **NEVER use text-based interaction prompts.** + > + > - ❌ NEVER ask the user to type a number, command, or keyword to make a selection + > - ❌ NEVER present numbered options and ask the user to respond with text + > - ✅ ALWAYS use `mitto_ui_options` for choices, `mitto_ui_textbox` for review/editing, + > `mitto_ui_form` for structured input, and `mitto_ui_notify` for non-blocking notifications + > + > ⚠️ **NEVER post to Slack without explicit user review and approval.** + + ## Slack tools (names vary by MCP server) + + Match Slack MCP tools **by capability, not exact name**. You need: (a) **read a thread's replies** + and (b) **post a reply to a thread**. Read the target channel and parent `thread_ts` from the bead + metadata (`slack_channel` / `slack_thread_ts`). + + ## Step 1: Identify the target bead + + {{ $target := "" -}} + {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }}{{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} + {{ if $target }} + - This prompt was launched from bead **`{{ $target }}`** — operate on it directly (skip the picker). + {{- else }} + - **No linked bead** — show a picker: list candidates and keep only those in `state:drafting`: + `bd list -l support-question --status open,in_progress --all` (read each `state:*` label from + `bd show`). Present them with `mitto_ui_options` (first option `{label: "None - Cancel"}`, then + one `{label: "<id> — <short summary>"}` per bead, timeout 300). + - If there are no `state:drafting` beads, `mitto_ui_notify` (info) that nothing is ready to answer + and stop. If the user cancels, acknowledge and stop. + {{- end }} + + ## Step 2: Load history + verify state (self-check) + + - Load the full history: `bd show <id>` and `bd comments <id>`. The bead is the source of truth + for what has already been said. Note the stored `slack_thread_ts` / `slack_channel` / + `slack_url` metadata. + - Confirm the `state:*` label is `drafting`. If it is **not**, use `mitto_ui_options` to warn: + - **Question**: "This bead is in `state:<current>`, not `drafting`. Continue anyway?" + - **Options**: `[{label: "Continue anyway"}, {label: "Cancel"}]`, timeout 300. + - On "Cancel", stop. On "Continue anyway", proceed. + + ## Step 3: Refresh the thread (guard against new customer messages) + + - Re-fetch the Slack thread (read-thread-replies tool) using the stored `slack_thread_ts`. + - If **new customer messages** appeared since our last activity and are **not** yet captured on the + bead, they may change the answer. Use `mitto_ui_options` to recommend running **"Support: check + status"** first: `[{label: "Run Check status first (stop here)"}, {label: "Continue drafting"}]`. + Respect the choice. + + ## Step 4: Build the proposed reply + + - Draw from the bead history — especially the recorded draft (the `state:drafting` proposal) and + any investigation / info-gathering notes — plus the refreshed thread. Do not repeat what we + already said. + - Show a clickable link to the thread so the user can review context (use the stored `slack_url`): + `📤 Replying to [this thread](<slack_url>)`. + - **Formatting guidelines for the Slack reply:** + 1. **No direct addressing** — do not use the user's name. + 2. **No follow-up offers** — do not end with "let me know if you need anything else". + 3. **Hedge** — start with "I think that…", "It seems like…"; we are never 100% sure. + 4. **Ask if unclear** — if scope/context is missing, ask rather than guess. + 5. **Run simple commands** — if we suggest e.g. `curl some.domain.com` and we can run it, run it + locally and show the command **and** the result. + 6. **Keep it short and conversational** — lead with the single most likely solution or next step; + avoid walls of text. + + ## Step 5: Review in an editable textbox + + - Use `mitto_ui_textbox`: + - **Title**: "📤 Review reply before posting to Slack" + - **Text**: the proposed reply + - **Result**: "full" + - **Abort**: true + - **Timeout**: 300 + + ## Step 6: Post + log (on submit) + + - The returned text (possibly edited) is the final message. Post it with the post-reply-to-thread + tool, using the stored `slack_channel` and the thread's parent `slack_thread_ts`. + - Confirm success and show the permalink (build it from the posted ts: `<slack_url base>/archives/ + <slack_channel>/p<posted_ts_without_dot>`). + - **Log to the bead** — add a markdown `[OUTBOUND]` comment with the final text and the permalink. + Preserve the markdown layout with `$'...'` (real `\n`) or stdin: + ``` + printf '%s' "$comment_markdown" | bd comment <id> --stdin + ``` + Header: `**[OUTBOUND us · YYYY-MM-DD HH:MM UTC]**`. + - **Transition state** (same issue, no subtasks — never `bd set-state`): + `bd update <id> --remove-label state:drafting --add-label state:awaiting-customer`. + (If Step 2 continued from a different state, remove that state label instead.) + + ## On abort + + - Do **NOT** post anything to Slack. Leave the bead in its current state (the draft stays pending). + - Acknowledge and stop. + + ## Notes + + - Load the bead (`bd show` + `bd comments`) **before** drafting — it is the source of truth. + - **NEVER** send a reply in the support channel without explicit review and approval. diff --git a/config/prompts/builtin/support-watch-channel.prompt.yaml b/config/prompts/builtin/support-watch-channel.prompt.yaml new file mode 100644 index 00000000..621e178d --- /dev/null +++ b/config/prompts/builtin/support-watch-channel.prompt.yaml @@ -0,0 +1,212 @@ +icon: loop +name: 'Support: watch channel' +menus: prompts +description: 'Watch a Slack channel for customer questions: triage each new question (and its thread) into a beads issue with state labels, gather information to answer, and keep every bead in sync with the real Slack conversation. Never posts to Slack.' +backgroundColor: '#C8E6C9' +group: Support +tags: +- loop +- support +parameters: + - name: SlackChannelID + type: text + required: true + description: 'The Slack channel ID to watch for customer questions (e.g. C0XXXXXXX).' + - name: SlackWorkspaceURL + type: text + required: true + description: 'Base Slack workspace URL used to build message permalinks (e.g. https://yourteam.slack.com). No trailing slash.' + - name: LookbackDays + type: text + required: false + description: 'How many days of channel history to scan each run (default 2).' +enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Tools.HasAnyPattern(["slack_*", "*_slack"])' +loop: + mode: optional + default: true + value: 15 + unit: minutes +prompt: |- + {{- $channel := .Args.SlackChannelID -}} + {{- $ws := .Args.SlackWorkspaceURL -}} + {{- $days := Arg "LookbackDays" "2" -}} + # Support — Watch Channel (Triage + Keep Beads in Sync) + + ## Session Context + + Your session ID is `{{ .Session.ID }}` — use it as `self_id` for all `mitto_*` MCP tool calls. + + ## What this does + + Listen to the Slack channel **`{{ $channel }}`**. For every **new open customer question** + (and its thread) create/maintain a **beads** (`bd`) issue whose `state:*` label tracks where the + conversation stands. Gather the information needed to answer, and keep every tracked bead in sync + with the real Slack thread. + + > ⚠️ **This prompt NEVER posts to Slack.** Posting a reply is done — with your explicit approval — + > by the **"Support: reply to user"** and **"Support: gather more information"** prompts. + + ## Run mode + {{- if and .Session.IsLoop (not .Session.IsLoopForced) }} + + **Silent scheduled run** — nobody is watching. + - Use **only** `mitto_ui_notify`. Do **NOT** call `mitto_ui_options` / `mitto_ui_form` / + `mitto_ui_textbox` — they would block with no one to answer. + - Do triage + reconcile beads normally. If you do **not** yet know how to gather answers (see the + **Knowledge self-check** below), notify and **skip drafting** this run. + {{- else }} + + **Interactive run** (first send, or force-triggered ▶️) — a user may be present, so you MAY use the + interactive `mitto_ui_*` tools. This is the moment to run the **Knowledge self-check** and, if + needed, ask the user how to gather answers and persist it to AGENTS.md. + {{- end }} + + ## CRITICAL: user interaction rules + + > - ❌ NEVER present numbered options and ask the user to type a number/keyword. + > - ✅ Use `mitto_ui_options` (choices), `mitto_ui_textbox` (review/edit), `mitto_ui_form` + > (structured input), `mitto_ui_notify` (non-blocking notifications). + > - ⚠️ NEVER post a reply to the Slack channel from this prompt. + + ## Slack tools (names vary by MCP server) + + Different Slack MCP servers expose different tool names (e.g. `slack_get_channel_history*`, + `conversations_history_slack`, `slack_get_thread_replies*`). **Match by capability, not exact name.** + You need tools that can: (a) read recent channel history, and (b) read a thread's replies. You do + **not** need a posting tool here — this prompt never posts. + + ## Beads tracking model (one bead per Slack thread) + + Run `bd` from the workspace root. If `bd list` reports no database, run `bd init` first. + + - **Deduplicate by thread.** Before creating, look up the thread: + `bd list --metadata-field slack_thread_ts=<ts> --all`. If a match exists, **reuse** it. Never + create a second bead for the same thread. + - **Permalink.** Build it from the thread ts by removing the dot and prefixing `p`: + `{{ $ws }}/archives/{{ $channel }}/p<ts_without_dot>` (e.g. `1770802214.391359` → `p1770802214391359`). + - **Create** with the `support, support-question` labels, a triage priority (`-p`), the fixed + markdown description via `-d`, and metadata. Use `$'...'` so `\n` become real newlines: + ``` + bd create "<short question summary>" -t task -p <P0-P3> -l support,support-question \ + -d $'# Question\n\n<question text>\n\n# User\n\n<display name>\n\n# Links\n\n[Slack]({{ $ws }}/archives/{{ $channel }}/p<ts_without_dot>)\n' \ + --metadata '{"slack_thread_ts":"<ts>","slack_channel":"{{ $channel }}","slack_url":"<permalink>"}' + ``` + - **Triage priority (`-p`):** `P0` outage/broad impact · `P1` urgent/blocking a customer · + `P2` standard question (default) · `P3` minor/how-to. + - **Comments = full history.** Log every relevant message as a markdown comment (preserve layout + with `$'...'` or `printf '%s' "$c" | bd comment <id> --stdin`). Header then blank line then body: + ``` + **[TAG @author · YYYY-MM-DD HH:MM UTC]** + + <content in markdown> + ``` + Tags: `[INBOUND @user]` customer messages · `[OUTBOUND us]` our posted replies · + `[CONTEXT @other]` messages from other participants · `[STATE]` a state transition + reason. + - **State via a single `state:*` label** (never `bd set-state` — it spawns child event beads). + Read the current label from `bd show <id>`, then swap in place: + `bd update <id> --remove-label state:<previous> --add-label state:<new>` (for the first + transition just `--add-label state:triaged`). Lifecycle: + + | state | meaning | + |-------|---------| + | `triaged` | auto-created during triage, not yet worked (initial) | + | `engaged` | actively working it | + | `gathering-info` | gathering information to answer (docs/tools/investigation) | + | `need-info` | need more details from the customer before we can answer | + | `drafting` | have a draft answer, pending your review | + | `awaiting-customer` | we posted a reply, waiting on the customer | + | `awaiting-us` | customer responded, our turn to act | + | `resolved` | answered/accepted (also `bd close <id>`) | + | `stale` | auto-closed after 10+ days of inactivity (also `bd close <id>`) | + + ## Knowledge self-check: how to answer customer questions + + Before you can help a customer you must know a **reliable way to gather answers** for this + project's domain. This knowledge lives in your project rules — **AGENTS.md** (or `CLAUDE.md` / + `.augment/rules/` / equivalent). + + 1. **Self-check.** Read the rules/docs you already have and ask yourself honestly: *"Do I know a + reliable way to gather information to answer questions about this project?"* A reliable method + could be a documented MCP tool (a Q&A / knowledge / docs-search assistant), a runbook, a docs + site, a specific set of commands, or a knowledge base. Look for a section such as + **"## How to answer customer questions"** in AGENTS.md. + 2. **If you DO know** — use that method to gather what you need, then draft (Step 4). + 3. **If you do NOT know** — you must find out; do not silently guess: + - **Interactive run:** ASK the user (via `mitto_ui_form` / `mitto_ui_textbox`) exactly how to + gather information to answer customer questions reliably — which tools, docs, commands, + knowledge bases, or people to consult. **Do not be satisfied until you get a concrete, usable + answer** — if the reply is vague, re-ask for specifics. Once you have it, **persist it to + AGENTS.md** under a `## How to answer customer questions` section (append, or update in place + if it already exists), then confirm the write. From then on, future runs will find it in + step 1. + - **Silent scheduled run:** nobody is watching, so do **NOT** block. Send a `mitto_ui_notify` + (warning) explaining that you don't yet know how to gather answers and the user should + force-run this prompt (▶️) once to teach you. Continue triage + keeping beads in sync, but + **skip drafting** answers this run. + + ## Instructions + + ### 0. Ensure beads ready + housekeeping + + - Verify the database (`bd list`; if none, `bd init`). + - Load tracked questions: `bd list -l support-question --all` and review their `state:*` labels. + - **Auto-close stale:** find tracked issues with no activity for **10+ days** + (`bd list -l support-question --status open,in_progress --updated-before <cutoff> --all`, e.g. + cutoff `date -u -v-10d +%Y-%m-%d`). For each, verify it is truly stale (no recent comments, no + open blockers, no unanswered customer message), then + `bd update <id> --remove-label state:<prev> --add-label state:stale` and + `bd close <id> -r "auto-closed: no activity for 10+ days"`. + + ### 1. Fetch recent questions + + - Read the last **{{ $days }} days** of history from `{{ $channel }}`. + - Keep messages that look like **open** questions/requests for help. Skip ones that already look + resolved — the asker said "thanks / that worked", someone gave an accepted answer, or a positive + reaction marks it helpful. Treat a clearly-negative reaction (answer not helpful) as still open. + + ### 2. Triage — create a bead per NEW open question + + - For each candidate, dedup by thread ts. If a bead exists, it is ongoing → handle in Step 3. + - If none exists, **create** one now (see **Beads tracking model**): priority `-p`, labels + `support,support-question`, the fixed `# Question / # User / # Links` description via `-d`, + metadata with `slack_thread_ts` / `slack_channel` / `slack_url`, an `[INBOUND]` comment with the + original question, and `bd update <id> --add-label state:triaged`. + - If a **tracked** thread got a helpful reaction / accepted answer, close it: + `bd update <id> --remove-label state:<prev> --add-label state:resolved` then `bd close <id>`. + + ### 3. Reconcile ongoing threads with their beads + + For each tracked, still-open bead (`bd list -l support-question --status open,in_progress --all`): + + - Load `bd show <id>` + `bd comments <id>`, then re-fetch its Slack thread using the stored + `slack_thread_ts`. + - Add a markdown comment for **each new** thread message not yet captured (`[INBOUND]` customer / + `[CONTEXT]` others / `[OUTBOUND]` any of our own replies). Do not duplicate existing comments. + - Update the `state:*` label to reflect reality: last message from the customer needing action → + `state:awaiting-us`; last message is ours, waiting on them → `state:awaiting-customer`. Do not + override `need-info` / `drafting` if our pending action still stands. Add a short `[STATE]` + comment noting any change. + + ### 4. Gather info + draft (only if the Knowledge self-check passed) + + - For beads where it is **our turn** (`state:triaged` / `awaiting-us` / `engaged`) and we can + answer: set `state:gathering-info`, use the project's documented method to gather the answer, + then record a **proposed reply** as an `[OUTBOUND]`-style *draft* comment (clearly marked + "DRAFT — not yet posted") and set `state:drafting`. + - If you cannot answer without more detail from the customer, set `state:need-info` and note what + is missing (the interactive **"Support: gather more information"** prompt will ask them). + - **Never post to Slack here.** Drafts wait for review in **"Support: reply to user"**. + - If the Knowledge self-check did **not** pass, skip this step (see that section). + + ### 5. Summary + + - Send a `mitto_ui_notify` summarizing the run: new questions triaged, beads updated, how many are + `drafting` (ready for your review) vs `need-info` vs `awaiting-customer`. On silent runs keep + inline output concise. On interactive runs you may also print a short table. + + ## Notes + + - The bead is the single source of truth — always load it (`bd show` + `bd comments`) before acting. + - Per-bead follow-ups: **"Support: check status"** (refresh a thread onto its bead), + **"Support: gather more information"** (ask the customer), **"Support: reply to user"** (post our + answer). All posting requires your explicit approval. diff --git a/config/prompts/builtin/whats-next.prompt.yaml b/config/prompts/builtin/whats-next.prompt.yaml index ab43a28d..3c31bede 100644 --- a/config/prompts/builtin/whats-next.prompt.yaml +++ b/config/prompts/builtin/whats-next.prompt.yaml @@ -4,7 +4,7 @@ description: Analyze progress and suggest next steps group: Work flow menus: prompts, conversation backgroundColor: '#FFF9C4' -enabledWhen: '!Session.IsPeriodicConversation && (Session.HasMessages || Session.HasBeadsIssue)' +enabledWhen: '!Session.IsLoopConversation && (Session.HasMessages || Session.HasBeadsIssue)' prompt: | {{- if .Session.BeadsIssue }} This conversation is linked to beads issue `{{ .Session.BeadsIssue }}` — frame everything diff --git a/docs/config/conversations.md b/docs/config/conversations.md index 22a850e0..ad20d23d 100644 --- a/docs/config/conversations.md +++ b/docs/config/conversations.md @@ -74,15 +74,15 @@ When external images are enabled: **Recommendation:** Keep external images disabled unless you specifically need them. -## Periodic Conversation Iteration Limit +## Loop Conversation Iteration Limit -Periodic conversations run on a schedule indefinitely by default. To prevent runaway loops, Mitto enforces a two-layer safeguard: +Loop conversations run on a schedule indefinitely by default. To prevent runaway loops, Mitto enforces a two-layer safeguard: -1. **Per-prompt cap** (`max_iterations` on the periodic prompt itself) — set via the API or `mitto_conversation_update`. -2. **User-configurable default cap** (`max_periodic_iterations` in settings) — applies when no per-prompt cap is set. -3. **Hardcoded backstop** (`GlobalMaxPeriodicIterations = 1000`) — an absolute ceiling that always applies, even when both the per-prompt cap and user cap are set to 0 (unlimited). +1. **Per-prompt cap** (`max_iterations` on the loop prompt itself) — set via the API or `mitto_conversation_update`. +2. **User-configurable default cap** (`max_loop_iterations` in settings) — applies when no per-prompt cap is set. +3. **Hardcoded backstop** (`GlobalMaxLoopIterations = 1000`) — an absolute ceiling that always applies, even when both the per-prompt cap and user cap are set to 0 (unlimited). -The **effective cap** is the smallest positive value among the three: per-prompt `max_iterations`, the configured `max_periodic_iterations`, and the hardcoded backstop of 1000. +The **effective cap** is the smallest positive value among the three: per-prompt `max_iterations`, the configured `max_loop_iterations`, and the hardcoded backstop of 1000. Examples: - Per-prompt cap = 0 (unlimited), config cap = 0 (unlimited) → effective cap = 1000 (backstop) @@ -94,38 +94,38 @@ Examples: ```yaml conversations: - max_periodic_iterations: 100 # Default cap for all periodic conversations (default: 100, 0 = unlimited) + max_loop_iterations: 100 # Default cap for all loop conversations (default: 100, 0 = unlimited) ``` | Field | Type | Default | Description | |---|---|---|---| -| `max_periodic_iterations` | integer | `100` | Default maximum number of scheduled runs for any periodic conversation. `0` means unlimited (still bounded by the built-in backstop of 1000). | +| `max_loop_iterations` | integer | `100` | Default maximum number of scheduled runs for any loop conversation. `0` means unlimited (still bounded by the built-in backstop of 1000). | **Via Settings UI:** 1. Open Settings (⚙️ button) 2. Go to the **Conversations** tab -3. Under **Periodic Conversations**, set **Max Periodic Iterations** +3. Under **Loop Conversations**, set **Max Loop Iterations** 4. Save your settings ## On-Completion Trigger and Max Duration -Periodic conversations can fire on a fixed schedule (the default) or **after the agent stops responding** (`trigger: onCompletion`). On-completion runs are event-driven: when the agent finishes a turn and the conversation goes idle, the next run is armed after a `delay`. Each run's completion arms the next, forming a self-sustaining loop. +Loop conversations can fire on a fixed schedule (the default) or **after the agent stops responding** (`trigger: onCompletion`). On-completion runs are event-driven: when the agent finishes a turn and the conversation goes idle, the next run is armed after a `delay`. Each run's completion arms the next, forming a self-sustaining loop. To prevent runaway hot loops, the on-completion `delay` is clamped up to a global floor: ```yaml conversations: - min_periodic_completion_delay_seconds: 5 # Floor for the onCompletion delay (default: 5) + min_loop_completion_delay_seconds: 5 # Floor for the onCompletion delay (default: 5) ``` | Field | Type | Default | Description | |---|---|---|---| -| `min_periodic_completion_delay_seconds` | integer | `5` | Lower bound (seconds) applied to every on-completion periodic `delay`. A per-prompt `delay` below this floor is raised to it. `0` disables the floor (not recommended). | +| `min_loop_completion_delay_seconds` | integer | `5` | Lower bound (seconds) applied to every on-completion loop `delay`. A per-prompt `delay` below this floor is raised to it. `0` disables the floor (not recommended). | -A conversation can also be bounded by **wall-clock time** via the periodic prompt's `maxDuration` (a duration string such as `30m`, `4h`, `1d`). Measured from the first run, once it elapses the conversation auto-stops (the periodic prompt is **disabled**, not deleted) on the next check — for both `schedule` and `onCompletion` triggers. This complements the iteration limit above: a loop stops at whichever bound (max iterations or max duration) is reached first. +A conversation can also be bounded by **wall-clock time** via the loop prompt's `maxDuration` (a duration string such as `30m`, `4h`, `1d`). Measured from the first run, once it elapses the conversation auto-stops (the loop prompt is **disabled**, not deleted) on the next check — for both `schedule` and `onCompletion` triggers. This complements the iteration limit above: a loop stops at whichever bound (max iterations or max duration) is reached first. -See the prompt-side schema in [Periodic Prompts → Triggers](prompts.md#triggers-schedule-vs-on-completion). +See the prompt-side schema in [Loop Prompts → Triggers](prompts.md#triggers-schedule-vs-on-completion). ## Related Documentation diff --git a/docs/config/mcp.md b/docs/config/mcp.md index 7fc25d97..9fd07780 100644 --- a/docs/config/mcp.md +++ b/docs/config/mcp.md @@ -72,7 +72,7 @@ These tools require the **"Can Send Prompt"** flag or appropriate permissions: | `mitto_conversation_archive` | Archive or unarchive a conversation | | `mitto_conversation_delete` | Delete a child conversation (caller must be parent) | | `mitto_conversation_wait` | Wait until an event occurs in a conversation (e.g., agent finishes responding) | -| `mitto_conversation_update` | Update conversation properties: title, user-defined metadata, and periodic prompt configuration. | +| `mitto_conversation_update` | Update conversation properties: title, user-defined metadata, and loop prompt configuration. | ### Parent-Child Task Coordination Tools diff --git a/docs/config/processors.md b/docs/config/processors.md index 74c0f9ac..8bdcddf3 100644 --- a/docs/config/processors.md +++ b/docs/config/processors.md @@ -434,7 +434,7 @@ pattern — see [Builtin Processors](#builtin-processors). ```yaml name: progress-summary -description: "Summarizes session progress periodically" +description: "Summarizes session progress at regular intervals" when: on: userPrompt match: first @@ -495,7 +495,7 @@ when: # Trigger condition — always a block stopReasons: # which ACP stop reasons trigger this processor (default: ["end_turn"]) - end_turn # valid values: end_turn, max_tokens, max_turn_requests, refusal, cancelled excludeOrigins: # skip processor when message origin matches any of these - - periodic-runner + - loop-runner # --- Text-mode (use ONE of the three modes) --- # Only valid for on:userPrompt; forbidden for on:agentResponded and on:agentIdle @@ -578,7 +578,7 @@ The `when:` block is required for all processors. Both `on:` and `match:` are re | ----------------- | ---------------------------------------------------- | | `user` | Message sent by the user via the UI | | `queue` | Message dispatched from the conversation queue | -| `periodic-runner` | Message triggered by the periodic runner | +| `loop-runner` | Message triggered by the loop runner | | `mcp-send-prompt` | Message sent via the `mitto_conversation_send_prompt` MCP tool | ### Phase/Field Rules @@ -627,7 +627,7 @@ expression must evaluate to `true`. - `ACP.Name`, `ACP.Type`, `ACP.Tags`, `ACP.AutoApprove` - `ACP.MatchesServerType("type")`, `ACP.MatchesServerType(["a", "b"])` — matches ACP server type only, not display name -- `Session.ID`, `Session.Name`, `Session.IsChild`, `Session.IsAutoChild`, `Session.ParentID`, `Session.IsPeriodic` +- `Session.ID`, `Session.Name`, `Session.IsChild`, `Session.IsAutoChild`, `Session.ParentID`, `Session.IsLoop` - `Parent.Exists`, `Parent.Name`, `Parent.ACPServer` - `Children.Count`, `Children.Exists`, `Children.MCPCount`, `Children.Names`, `Children.ACPServers` - `Workspace.UUID`, `Workspace.Folder`, `Workspace.Name` @@ -642,7 +642,7 @@ expression must evaluate to `true`. Processors with `on: userPrompt` and `match: first` normally fire only once (on the first message after session start or resume). The `rerun` field allows them to fire again -periodically, refreshing context for the LLM. Thresholds can be based on time, message +at regular intervals, refreshing context for the LLM. Thresholds can be based on time, message count, or token usage. ```yaml @@ -962,8 +962,8 @@ The `@mitto:` prefix followed by a lowercase, underscored variable name. This is | `@mitto:workspace_uuid` | Workspace UUID | | `@mitto:available_acp_servers` | Human-readable list of ACP servers with workspaces for this folder — see below | | `@mitto:children` | Human-readable list of child sessions — see below | -| `@mitto:periodic` | `"true"` if this prompt was triggered by the periodic runner, `"false"` otherwise | -| `@mitto:periodic_forced` | `"true"` if this is a manually-triggered periodic run (via "run now"), `"false"` otherwise | +| `@mitto:loop` | `"true"` if this prompt was triggered by the loop runner, `"false"` otherwise | +| `@mitto:loop_forced` | `"true"` if this is a manually-triggered loop run (via "run now"), `"false"` otherwise | ### `@mitto:available_acp_servers` format @@ -1180,4 +1180,4 @@ The conversation properties panel displays real-time processor statistics: - **Activations** — Total number of times the processor pipeline has run - **Last activation** — Relative time since the last processor execution (e.g., "2m ago") -These statistics are updated after each prompt completes and during periodic keepalive messages. They are tracked in-memory and reset when the session restarts. +These statistics are updated after each prompt completes and during loop keepalive messages. They are tracked in-memory and reset when the session restarts. diff --git a/docs/config/prompts.md b/docs/config/prompts.md index 009eae0d..953da9e5 100644 --- a/docs/config/prompts.md +++ b/docs/config/prompts.md @@ -259,7 +259,7 @@ prompt: | | `name` | No\* | string | Display name for the button. If omitted, derived from filename. | | `description` | No | string | Tooltip text shown on hover | | `group` | No | string | Group name for organizing prompts in the menu (e.g., `"Git"`, `"Testing"`) | -| `menus` | No | string | Comma-separated list of menus the prompt appears in: `prompts` (ChatInput dropup), `promptsPeriodic` (periodic prompt selector), `conversation` (per-conversation context menu), `beadsIssues` (per-issue context menu in the Beads list), and/or `beadsList` (list-level prompts button in the Beads list footer). Defaults to `prompts` if omitted. See [below](#menus). | +| `menus` | No | string | Comma-separated list of menus the prompt appears in: `prompts` (ChatInput dropup), `promptsLoop` (loop prompt selector), `conversation` (per-conversation context menu), `beadsIssues` (per-issue context menu in the Beads list), and/or `beadsList` (list-level prompts button in the Beads list footer). Defaults to `prompts` if omitted. See [below](#menus). | | `parameters` | No | list | Typed input declarations. Each entry: `{ name, type, description?, required? }`. The menu must supply every declared type or the prompt is hidden. See [below](#parameters-typed-inputs--type-based-gating). | | `backgroundColor` | No | string | Hex color for the button (e.g., `"#E8F5E9"`) | | `icon` | No | string | Icon name shown next to the prompt in menus. See [valid names](#icon-names). Unknown names fall back to the default icon. | @@ -268,7 +268,7 @@ prompt: | | `acps` | No | string | Comma-separated ACP server types this prompt belongs to. Makes the prompt server-specific. | | `enabled` | No | bool | Set to `false` to disable the prompt. Default: `true` | | `enabledWhen` | No | string | CEL expression for conditional enablement. See [below](#enabledwhen-conditional-enablement). | -| `periodic` | No | mapping | Opt-in periodic mode — presence makes the prompt behave **context-sensitively** when selected (start a new recurring conversation, convert an existing one to periodic, or send a single one-shot run). See [below](#periodic-prompts). | +| `loop` | No | mapping | Opt-in loop mode — presence makes the prompt behave **context-sensitively** when selected (start a new recurring conversation, convert an existing one to loop, or send a single one-shot run). See [below](#loop-prompts). | | `prompt` | Yes\*\* | string | The prompt body text, written as a YAML literal block scalar (`\|`). | \*If `name` is not specified, it's derived from the filename (e.g., `code-review.prompt.yaml` → @@ -288,7 +288,7 @@ Available names: `magic-wand`, `lightning`, `robot`, `person`, `image`, `folder`, `folder-open`, `terminal`, `server`, `globe`, `chat-bubble`, `shield`, `layers`, `list`, `tag`, `check`, `question`, `error`, `plus`, `hourglass`, `refresh`, `sync`, `keyboard`, -`duplicate`, `pin`, `archive`, `periodic`, `queue`, `play`. +`duplicate`, `pin`, `archive`, `loop`, `queue`, `play`. The registry is defined in `web/static/components/Icons.js` (`PROMPT_ICONS`); add an entry there to expose additional icons by name. @@ -359,7 +359,7 @@ prompt appears in. The available menu values are: | Menu | Where it appears | | ----------------- | ------------------------------------------------------------------------------------------------- | | `prompts` | The **ChatInput dropup** — the "Insert predefined prompt" menu (the `^` button) above the chat input. | -| `promptsPeriodic` | The **periodic prompt selector** — the prompt dropdown shown in the inline editor of a periodic conversation. | +| `promptsLoop` | The **loop prompt selector** — the prompt dropdown shown in the inline editor of a loop conversation. | | `conversation` | The **per-conversation context menu** — shown when you right-click a conversation in the sidebar. | | `beadsIssues` | The **per-issue context menu** — shown when you right-click an issue in the Beads list view. | | `beadsList` | The **list-level prompts button** — the dropdown next to the `+` button in the Beads list footer. | @@ -380,30 +380,30 @@ Whitespace around each entry is ignored. Because `menus` is an explicit list, a prompt with `menus: conversation` (without `prompts`) appears **only** in the conversation context menu and is **excluded** from the ChatInput dropup. -### Periodic Prompt Selector Menu +### Loop Prompt Selector Menu -Prompts whose `menus` list includes `promptsPeriodic` appear in the **periodic -prompt selector** — the prompt dropdown shown in the inline editor of a periodic +Prompts whose `menus` list includes `promptsLoop` appear in the **loop +prompt selector** — the prompt dropdown shown in the inline editor of a loop conversation, where you pick which prompt the scheduler runs on each tick. -The periodic selector shows the **union** of `prompts` and `promptsPeriodic`: any +The loop selector shows the **union** of `prompts` and `promptsLoop`: any prompt available in the ChatInput dropup also appears in the selector, so existing prompts keep working without changes. To make a prompt appear **only** in the -periodic selector (and hide it from the regular dropup), set `menus: -promptsPeriodic` without `prompts`: +loop selector (and hide it from the regular dropup), set `menus: +promptsLoop` without `prompts`: ```yaml name: "Babysit PRs" description: "Check for pending reviews and stale branches" group: "GitHub" -menus: promptsPeriodic +menus: promptsLoop prompt: | Check the repository for pending review requests and stale branches. ``` -Pair this with the `Session.IsPeriodicConversation` CEL variable (see +Pair this with the `Session.IsLoopConversation` CEL variable (see [enabledWhen](#enabledwhen-conditional-enablement)) if you also want the prompt -hidden everywhere outside periodic conversations. +hidden everywhere outside loop conversations. ### Exclusion Syntax (`!menu`) @@ -411,26 +411,26 @@ A `!`-prefixed token in `menus` **explicitly excludes** the prompt from that men even when a union or implicit rule would otherwise include it. Exclusions take precedence over inclusions. -**Motivating case:** the periodic prompt selector uses a union rule — every +**Motivating case:** the loop prompt selector uses a union rule — every `prompts` prompt also appears in the selector. To suppress a one-shot prompt from -the periodic selector without removing it from the regular dropup, add -`!promptsPeriodic`: +the loop selector without removing it from the regular dropup, add +`!promptsLoop`: ```yaml name: "JIRA: decompose" description: "Break a JIRA epic into subtasks — one-shot only, not for recurring runs" group: "JIRA" -menus: prompts, !promptsPeriodic +menus: prompts, !promptsLoop prompt: | Analyze the current JIRA epic and decompose it into actionable subtasks. ``` This prompt appears in the ChatInput dropup (`prompts`) but is hidden from the -periodic prompt selector (`!promptsPeriodic`). +loop prompt selector (`!promptsLoop`). **Rules:** - A bare token (`prompts`) opts the prompt **into** that menu. -- A `!`-prefixed token (`!promptsPeriodic`) opts the prompt **out of** that menu. +- A `!`-prefixed token (`!promptsLoop`) opts the prompt **out of** that menu. - Exclusions take precedence over inclusions and union rules. - If all non-`!` tokens are stripped and nothing positive remains, `menus` defaults to `["prompts"]` (the prompt still appears in the dropup). @@ -605,29 +605,30 @@ that depends on an issue ID) in `{{ if $target }} … {{ end }}` so mode 3 emits {{- end }} ``` -The built-in `beads-issue-investigate`, `beads-issue-discuss`, +The built-in `beads-issue-investigate`, `beads-issue-assess`, `beads-issue-status`, `beads-issue-resolved`, `beads-issue-work`, and `beads-followup-work` prompts all follow this three-mode pattern. -## Periodic Prompts +## Loop Prompts -A prompt can declare a `periodic:` mapping to opt into **periodic mode**. How a -periodic-declaring prompt behaves when selected is **context-sensitive** — it +A prompt can declare a `loop:` mapping to opt into **loop mode**. How a +loop-declaring prompt behaves when selected is **context-sensitive** — it depends on the conversation it targets. It can start a new recurring conversation, -convert an existing conversation to periodic, or send a single one-shot run (see +convert an existing conversation to loop, or send a single one-shot run (see [Behavior](#behavior) below). -### Periodic Fields +### Loop Fields ```yaml -periodic: +loop: value: 1 # number of time units between runs (integer ≥ 1); used by trigger: schedule unit: hours # minutes | hours | days; used by trigger: schedule at: "09:00" # optional — time of day in HH:MM (local time in the UI, stored as UTC); only valid for unit: days maxIterations: 10 # optional; 0/absent = unlimited scheduled runs - trigger: schedule # optional — schedule (default) | onCompletion + trigger: schedule # optional — schedule (default) | onCompletion | onTasks delay: 30 # optional — seconds to wait after the agent stops, before the next onCompletion run maxDuration: "4h" # optional — wall-clock cap (e.g. 30m, 4h, 1d); 0/absent = unlimited + condition: '' # optional — CEL expression gating which beads/task changes fire the run; only meaningful for trigger: onTasks mode: always # optional — always (default) | optional default: true # optional — only meaningful for mode: optional; nil/absent = true ``` @@ -638,35 +639,36 @@ periodic: | `unit` | Yes¹ | `minutes`, `hours`, or `days` | | `at` | No | Time of day (`HH:MM`) for daily schedules only. Ignored for other units. | | `maxIterations` | No | Cap on the number of scheduled runs (integer ≥ 0). `0` or absent means unlimited at the prompt level. See [Max iterations and auto-stop](#max-iterations-and-auto-stop). | -| `trigger` | No | How runs fire: `schedule` (default — frequency-based) or `onCompletion` (fire after the agent stops responding). See [Triggers](#triggers-schedule-vs-on-completion). | -| `delay` | No | For `trigger: onCompletion` only — seconds to wait after the agent finishes before the next run. Clamped up to the global floor (`min_periodic_completion_delay_seconds`, default 5). Ignored for `schedule`. | +| `trigger` | No | How runs fire: `schedule` (default — frequency-based), `onCompletion` (fire after the agent stops responding), or `onTasks` (fire when beads/tasks in the workspace change). See [Triggers](#triggers-schedule-vs-on-completion). | +| `delay` | No | For `trigger: onCompletion` only — seconds to wait after the agent finishes before the next run. Clamped up to the global floor (`min_loop_completion_delay_seconds`, default 5). Ignored for `schedule`. | | `maxDuration` | No | Wall-clock cap as a duration string (`30m`, `4h`, `1d`). Once it elapses (measured from the first run), the conversation auto-stops. `0`/absent = unlimited. | +| `condition` | No | For `trigger: onTasks` only — a CEL expression gating which beads/task changes fire the run. Empty/absent = fire on any change. Validated at parse time; a syntactically invalid or unknown-identifier expression fails prompt load. | | `mode` | No | `always` (default — not user-toggleable) or `optional` (user-choosable per send). Unknown values are rejected at load time. See [Always / optional / never](#always--optional--never). | | `default` | No | Initial per-send toggle state when `mode: optional`. `true`/absent = on, `false` = off. Ignored (with a load-time warning) when `mode` is `always` or absent. | -¹ Required for `trigger: schedule` (the default). Ignored for `trigger: onCompletion`, which fires off the agent-idle event rather than a fixed period. +¹ Required for `trigger: schedule` (the default). Ignored for `trigger: onCompletion` and `trigger: onTasks`, which fire off events rather than a fixed period. -**Presence implies opt-in** — omitting the `periodic:` block entirely keeps the prompt as a regular one-time prompt. +**Presence implies opt-in** — omitting the `loop:` block entirely keeps the prompt as a regular one-time prompt. The `value` / `unit` / `at` fields double as the **default period** applied -whenever a conversation is made periodic (see [Default period](#default-period)). +whenever a conversation is made loop (see [Default period](#default-period)). #### Always / optional / never Every prompt falls into one of three categories: -- **Never periodic** — no `periodic:` block at all. Regular one-time prompt (unchanged). -- **Always periodic** — `periodic:` block with `mode: always` (or `mode` absent). Periodic behavior is mandatory whenever the prompt is selected; not user-toggleable. -- **Optionally periodic** — `periodic:` block with `mode: optional`. The user can choose whether this send is periodic; `default` sets the initial toggle state. +- **Never loop** — no `loop:` block at all. Regular one-time prompt (unchanged). +- **Always loop** — `loop:` block with `mode: always` (or `mode` absent). Loop behavior is mandatory whenever the prompt is selected; not user-toggleable. +- **Optionally loop** — `loop:` block with `mode: optional`. The user can choose whether this send is loop; `default` sets the initial toggle state. ```yaml -# Always periodic (mode omitted == always) -periodic: +# Always loop (mode omitted == always) +loop: trigger: onCompletion delay: 30 -# Optionally periodic, off by default -periodic: +# Optionally loop, off by default +loop: mode: optional default: false trigger: onCompletion @@ -675,35 +677,35 @@ periodic: ### Behavior -A periodic-declaring prompt is **context-sensitive**: what happens when you select +A loop-declaring prompt is **context-sensitive**: what happens when you select it depends on the conversation it targets. The decision is made by -`decidePeriodicAction` (see `web/static/hooks/useConversationSeeding.js`). +`decideLoopAction` (see `web/static/hooks/useConversationSeeding.js`). | Context | What happens | | ------- | ------------ | -| **No active conversation** (selecting the prompt to start fresh) | A **frequency dialog** (`PeriodicScheduleDialog`) opens, pre-filled from the prompt's `periodic` defaults (period, `at`, and **max runs**). On confirm, a **new periodic conversation** is created (no queue seed) and `PUT /api/sessions/{id}/periodic` configures the named prompt on the declared schedule. | -| **Regular (running, non-periodic, top-level) conversation** | The conversation is made **immediately periodic** using the prompt's declared defaults — **no dialog** — and the **first run fires right away** (PUT periodic, then `POST /api/sessions/{id}/periodic/run-now`). The scheduled prompt is now this prompt. | -| **Already-periodic conversation, or a child conversation** | The prompt contents are sent **once** (a one-shot enqueue) and the conversation's configured periodic prompt, schedule, and iteration cap are **left untouched**. | +| **No active conversation** (selecting the prompt to start fresh) | A **frequency dialog** (`LoopScheduleDialog`) opens, pre-filled from the prompt's `loop` defaults (period, `at`, and **max runs**). On confirm, a **new loop conversation** is created (no queue seed) and `PUT /api/sessions/{id}/loop` configures the named prompt on the declared schedule. | +| **Regular (running, non-loop, top-level) conversation** | The conversation is made **immediately loop** using the prompt's declared defaults — **no dialog** — and the **first run fires right away** (PUT loop, then `POST /api/sessions/{id}/loop/run-now`). The scheduled prompt is now this prompt. | +| **Already-loop conversation, or a child conversation** | The prompt contents are sent **once** (a one-shot enqueue) and the conversation's configured loop prompt, schedule, and iteration cap are **left untouched**. | #### Default period The `value` / `unit` / `at` fields are the **default period** applied whenever a -conversation is made periodic — both when creating a new periodic conversation +conversation is made loop — both when creating a new loop conversation (pre-filled into the dialog, where the user may adjust them) and when converting a -regular conversation (`makePeriodicNow` uses them directly, without showing the +regular conversation (`makeLoopNow` uses them directly, without showing the dialog). #### Max iterations and auto-stop `maxIterations` caps the number of **scheduled runs** before the conversation -auto-stops. The periodic engine counts each delivered run (`iteration_count`) and, -when the cap is reached, **disables** the periodic prompt so it stops firing. The +auto-stops. The loop engine counts each delivered run (`iteration_count`) and, +when the cap is reached, **disables** the loop prompt so it stops firing. The prompt is **not** deleted or archived — you can re-enable it at any time. The binding cap is the **smallest positive** of: - the prompt's `maxIterations`, -- the server's `conversations.max_periodic_iterations` setting (default `100`, +- the server's `conversations.max_loop_iterations` setting (default `100`, `0` = unlimited), and - a hardcoded absolute backstop of `1000`. @@ -712,18 +714,24 @@ config setting and the backstop still apply. #### Triggers: schedule vs on-completion -The `trigger` field selects **when** a periodic run fires: +The `trigger` field selects **when** a loop run fires: - **`schedule`** (default) — runs fire on a fixed period defined by `value`/`unit` (and optional `at` for daily). This is the classic interval behavior. - **`onCompletion`** — the next run is armed **after the agent stops responding**, waiting `delay` seconds first. Each delivered run's completion arms the following one, so the loop is event-driven rather than clock-driven. The `delay` is clamped - up to the global floor (`min_periodic_completion_delay_seconds`, default 5 s) to + up to the global floor (`min_loop_completion_delay_seconds`, default 5 s) to prevent hot loops. - -`maxDuration` applies to **both** triggers: it is a wall-clock cap measured from the -first run. Once exceeded, the periodic prompt is **disabled** (not deleted) on the +- **`onTasks`** — runs fire when beads/tasks in the workspace change. An optional + `condition` (CEL expression) gates which changes actually fire the run; when empty + or absent, any change fires it. The expression is validated at prompt load time + and evaluated against the `Tasks`, `Prev`, and `Changes` variables at runtime. + Example: `condition: 'Tasks.Open > Prev.Open'` fires only when the open task + count grows. + +`maxDuration` applies to all three triggers: it is a wall-clock cap measured from the +first run. Once exceeded, the loop prompt is **disabled** (not deleted) on the next check, exactly like the [max-iterations auto-stop](#max-iterations-and-auto-stop). Combine `maxDuration` with `maxIterations` to bound a loop by either time or count, whichever comes first. See @@ -731,7 +739,7 @@ whichever comes first. See for the server-side floor and defaults. **Restrictions:** -- Periodic conversations can only be **top-level** (not child) conversations. Selecting a periodic prompt on a child conversation falls through to the one-shot send; the backend also returns HTTP 400 for periodic-on-child. +- Loop conversations can only be **top-level** (not child) conversations. Selecting a loop prompt on a child conversation falls through to the one-shot send; the backend also returns HTTP 400 for loop-on-child. - The `at` field is only sent for `unit: days`; it is ignored otherwise (matches `Frequency.Validate()` on the backend). ### Example @@ -741,7 +749,7 @@ name: "Daily Standup" description: "Run the daily team standup" group: "Workflow" menus: conversation, beadsIssues -periodic: +loop: value: 1 unit: days at: "09:00" @@ -752,31 +760,31 @@ prompt: | Selecting **Daily Standup** with **no active conversation** opens a dialog pre-filled with "every 1 day at 09:00" and "max runs 0 (unlimited)"; confirming -creates a new periodic conversation that runs this prompt daily at 09:00 UTC. +creates a new loop conversation that runs this prompt daily at 09:00 UTC. Selecting it on a **regular running conversation** instead converts that -conversation to periodic immediately (using the same defaults) and fires the first -run. Selecting it on an **already-periodic** conversation just runs it once, +conversation to loop immediately (using the same defaults) and fires the first +run. Selecting it on an **already-loop** conversation just runs it once, leaving the schedule unchanged. -### Real-world example: auto-periodic, self-terminating +### Real-world example: auto-loop, self-terminating -The builtin **"Iterate until issue complete"** prompt +The builtin **"Loop until issue complete"** prompt (`config/prompts/builtin/beads-iterate-until-complete.prompt.yaml`) is a real -auto-periodic example: a `menus: beadsIssues` prompt with a `periodic:` block +auto-loop example: a `menus: beadsIssues` prompt with a `loop:` block (`trigger: onCompletion`, `delay: 30`, `maxIterations: 20`, `maxDuration: 4h`). -Selecting it on a beads issue or epic starts a periodic conversation that, on each +Selecting it on a beads issue or epic starts a loop conversation that, on each run, **delegates** one concrete increment to a child conversation (for an epic, the next ready child) and logs progress to the tracker; the next run fires shortly after the agent stops responding. Scheduled runs are **non-interactive** (branch on -`@mitto:periodic` / `@mitto:periodic_forced`; use `mitto_ui_notify` only). When +`@mitto:loop` / `@mitto:loop_forced`; use `mitto_ui_notify` only). When nothing ready remains in scope, it **self-terminates** — -`mitto_conversation_update(conversation_id: "self", periodic_enabled: false)` turns +`mitto_conversation_update(conversation_id: "self", loop_enabled: false)` turns it back into a regular conversation. It is the automated sibling of the interactive "Start work" (`beads-issue-work`) prompt. For the general design pattern behind this kind of self-driving, self-terminating loop — encoding workflow progress as `bd` labels — see -[Label-as-state-machine pattern for periodic beads prompts](../devel/prompt-templates.md#13-label-as-state-machine-pattern-for-periodic-beads-prompts). +[Label-as-state-machine pattern for loop beads prompts](../devel/prompt-templates.md#13-label-as-state-machine-pattern-for-loop-beads-prompts). ## Prompt Arguments @@ -914,7 +922,7 @@ which maps each `{ name, type }` to the value supplied for its type by the menu. | Menu | Supplied types | | ---- | -------------- | | `prompts` (ChatInput dropup) | *(none)* | -| `promptsPeriodic` (periodic prompt selector) | *(none)* | +| `promptsLoop` (loop prompt selector) | *(none)* | | `conversation` (per-conversation context menu) | *(none)* | | `beadsIssues` (Beads issue context menu) | `beadsId`, `beadsTitle` | | `beadsList` (Beads list-level prompts button) | *(none)* | @@ -993,8 +1001,8 @@ The following fields are available at send time. They are the **same fields used | `{{ .Session.ParentID }}` | Parent conversation ID (empty if root) | | `{{ .Session.Name }}` | Conversation title/name | | `{{ .Session.IsChild }}` | `true` in child conversations | -| `{{ .Session.IsPeriodic }}` | `true` when triggered by the periodic runner | -| `{{ .Session.IsPeriodicForced }}` | `true` when a periodic run was manually triggered ("run now") | +| `{{ .Session.IsLoop }}` | `true` when triggered by the loop runner | +| `{{ .Session.IsLoopForced }}` | `true` when a loop run was manually triggered ("run now") | | `{{ .Session.HasMessages }}` | `true` once the conversation has any user message | | `{{ .Session.BeadsIssue }}` | Linked beads issue ID (empty if none) | | `{{ .Session.ModelName }}` | Current model's display name (empty if unknown) | @@ -1008,9 +1016,9 @@ The following fields are available at send time. They are the **same fields used | `{{ .Children.MCPCount }}` | Number of MCP-spawned children | | `{{ .Args.NAME }}` | Argument value for `NAME` (from prompt arguments) | | `{{ index .UserData "NAME" }}` | Per-conversation user-data field `NAME` (empty if unset); see also the `UserData` function below | -| `{{ .Iteration.Number }}` | 0-based index of the current periodic run (0 for non-periodic) | -| `{{ .Iteration.Max }}` | Configured max runs (0 = unlimited; 0 for non-periodic) | -| `{{ .Iteration.IsPeriodic }}` | `true` when triggered by the periodic runner | +| `{{ .Iteration.Number }}` | 0-based index of the current loop run (0 for non-loop) | +| `{{ .Iteration.Max }}` | Configured max runs (0 = unlimited; 0 for non-loop) | +| `{{ .Iteration.IsLoop }}` | `true` when triggered by the loop runner | | `{{ .Iteration.IsFirst }}` | `true` when `Number == 0` | | `{{ .Iteration.IsLast }}` | `true` when `Max > 0 && Number == Max-1` | @@ -1105,8 +1113,8 @@ substitution system used by [message processors](processors.md#variable-substitu | `@mitto:beads_issue` | Linked beads issue ID (e.g. "bd-123"), empty if none | | `@mitto:available_acp_servers` | ACP servers for this workspace, comma-separated with tags and current marker | | `@mitto:children` | Child sessions, comma-separated with names and ACP servers | -| `@mitto:periodic` | `"true"` if this prompt was triggered by the periodic runner, `"false"` otherwise | -| `@mitto:periodic_forced` | `"true"` if this is a manually-triggered periodic run (via "run now"), `"false"` otherwise | +| `@mitto:loop` | `"true"` if this prompt was triggered by the loop runner, `"false"` otherwise | +| `@mitto:loop_forced` | `"true"` if this is a manually-triggered loop run (via "run now"), `"false"` otherwise | ### Migration Table @@ -1123,15 +1131,15 @@ For each deprecated token, the recommended Go template replacement is listed. To | `@mitto:workspace_uuid` | `{{ .Workspace.UUID }}` | migrate | | `@mitto:beads_issue` | `{{ .Session.BeadsIssue }}` | migrate | | `@mitto:mcp_children_count` | `{{ .Children.MCPCount }}` | migrate | -| `@mitto:periodic` | `{{ .Session.IsPeriodic }}` | migrate | -| `@mitto:periodic_forced` | `{{ .Session.IsPeriodicForced }}` | migrate | +| `@mitto:loop` | `{{ .Session.IsLoop }}` | migrate | +| `@mitto:loop_forced` | `{{ .Session.IsLoopForced }}` | migrate | | `@mitto:available_acp_servers` | *(no template equivalent yet)* | **keep** — no warning | | `@mitto:children` | *(no template equivalent yet)* | **keep** — no warning | | `@mitto:mcp_children` | *(no template equivalent yet)* | **keep** — no warning | | `@mitto:user_data` | *(no template equivalent yet)* | **keep** — no warning | | `@mitto:user_data_schema` | *(no template equivalent yet)* | **keep** — no warning | -Note: `@mitto:periodic` renders as a Go `bool` (`true`/`false`), identical in string form to the old `"true"`/`"false"` output. +Note: `@mitto:loop` renders as a Go `bool` (`true`/`false`), identical in string form to the old `"true"`/`"false"` output. ### Behavior @@ -1249,8 +1257,8 @@ Information about the current conversation/session. | `Session.IsChild` | bool | `true` if this is a child conversation | | `Session.IsAutoChild` | bool | `true` if created automatically by parent | | `Session.ParentID` | string | Parent session ID (empty if not a child) | -| `Session.IsPeriodic` | bool | `true` if this prompt was triggered by the periodic runner | -| `Session.IsPeriodicConversation` | bool | `true` if this is a periodic conversation (it has a periodic prompt configuration) | +| `Session.IsLoop` | bool | `true` if this prompt was triggered by the loop runner | +| `Session.IsLoopConversation` | bool | `true` if this is a loop conversation (it has a loop prompt configuration) | | `Session.HasMessages` | bool | `true` if the conversation has at least one user message (empty conversations are false) | | `Session.HasBeadsIssue` | bool | `true` if the conversation has a beads issue associated | | `Session.BeadsIssue` | string | Linked beads issue ID (empty if none) | diff --git a/docs/config/workspace.md b/docs/config/workspace.md index 6cce6faf..a4b35868 100644 --- a/docs/config/workspace.md +++ b/docs/config/workspace.md @@ -82,6 +82,7 @@ The following fields are stored in `workspaces.json` and edited through the UI. |-------|------|-------------| | `acp_server` | string | Name of the ACP server for this workspace | | `auxiliary_model_selection` | object | Optional model selection for auxiliary sessions (title generation, follow-up analysis, etc.). When set, auxiliary sessions start on the workspace's main ACP server and switch to the best-matching available model. When unset, the ACP server's default model is used. Object has two fields: `matchMode` (one of `contains`, `exact`, `startsWith`, `regex`, `lookAlike`) and `pattern` (the text to match against model names). | +| `auxiliary_model_tag` | string | Selects the auxiliary-session model by capability tag (e.g. `Fast`), resolved to the first Model profile (`Config.Models`, in definition order) carrying this tag whose criteria matches an available model. Precedence is `auxiliary_model_profile` > `auxiliary_model_tag` > `auxiliary_model_selection`. | | `restricted_runner` | string | Sandbox type: `exec` (default), `sandbox-exec`, `firejail`, `docker` | | `auto_approve` | boolean | Auto-approve all agent tool-call permission requests | | `is_default` | boolean | Marks this workspace as the default for its folder. When several workspaces share the same directory (e.g. different ACP servers or model variants), the default is preferred when a workspace must be resolved from the folder alone (no ACP server specified). At most one workspace per folder should set this. | diff --git a/docs/devel/README.md b/docs/devel/README.md index a8c71594..49bf489f 100644 --- a/docs/devel/README.md +++ b/docs/devel/README.md @@ -26,11 +26,11 @@ This directory contains technical documentation for developers working on Mitto. - **[Follow-up Suggestions](follow-up-suggestions.md)** — AI-generated response suggestions, persistence, multi-client sync, and lifecycle -- **[Callbacks](callbacks.md)** — HTTP callback endpoints for triggering periodic conversations on-demand, token management, security model +- **[Callbacks](callbacks.md)** — HTTP callback endpoints for triggering loop conversations on-demand, token management, security model ### Infrastructure -- **[ACP Architecture](acp.md)** — Shared process model, concurrent RPC handling, MultiplexClient routing, auxiliary sessions, content blocks, multi-tier process GC (periodic suspend, memory-bloat recycling), and the prompt inactivity watchdog +- **[ACP Architecture](acp.md)** — Shared process model, concurrent RPC handling, MultiplexClient routing, auxiliary sessions, content blocks, multi-tier process GC (loop suspend, memory-bloat recycling), and the prompt inactivity watchdog - **[Restricted Runner Integration](restricted-runners.md)** — Runner system architecture, sandbox types, configuration hierarchy, and ACP subprocess integration @@ -52,7 +52,7 @@ This directory contains technical documentation for developers working on Mitto. | Configuration | [Architecture](architecture.md) | `internal/config` | | ACP architecture | [ACP Architecture](acp.md) | Shared process, multiplexing, concurrency | | ACP client | [ACP Architecture](acp.md) | `internal/acp` | -| Process GC tiers | [ACP Architecture](acp.md) | Multi-Tier GC, periodic suspend, memory recycle | +| Process GC tiers | [ACP Architecture](acp.md) | Multi-Tier GC, loop suspend, memory recycle | | Memory recycling | [ACP Architecture](acp.md) | Tier 4 — Memory-Bloat Recycling, Configuration | | Inactivity watchdog | [ACP Architecture](acp.md) | Prompt Inactivity Watchdog | | Feature flags | [Architecture](architecture.md) | Advanced Settings | @@ -60,8 +60,8 @@ This directory contains technical documentation for developers working on Mitto. | Session settings | [Session Management](session-management.md) | Advanced Settings | | Queue API | [Message Queue](message-queue.md) | REST API | | Queue titles | [Message Queue](message-queue.md) | Title Generation | -| Periodic onCompletion | [Message Queue](message-queue.md) | Periodic Prompts: On-Completion Delivery | -| Periodic onTasks | [Message Queue](message-queue.md) | Periodic Prompts: On-Tasks Delivery | +| Loop onCompletion | [Message Queue](message-queue.md) | Loop Prompts: On-Completion Delivery | +| Loop onTasks | [Message Queue](message-queue.md) | Loop Prompts: On-Tasks Delivery | | Prompt menus | [Prompt Menus & Dispatch](prompts.md) | The `menus` routing key | | Prompt dispatch | [Prompt Menus & Dispatch](prompts.md) | The two start behaviors, deferred resolution | | REST endpoints | [Web Interface](web-interface.md) | REST API Endpoints | diff --git a/docs/devel/acp.md b/docs/devel/acp.md index 29768f11..d09a65c9 100644 --- a/docs/devel/acp.md +++ b/docs/devel/acp.md @@ -350,14 +350,14 @@ process. Without cleanup, these processes live until server exit, wasting resour 1. **Queue processing** — `ProcessPendingQueues()` starts a process that stays alive after the queue is drained -2. **Periodic prompts** — `PeriodicRunner` starts a process for delivery, never stops it +2. **Loop prompts** — `LoopRunner` starts a process for delivery, never stops it 3. **Brief UI visits** — Opening a conversation starts a process permanently 4. **Auxiliary pre-warming** — 4 auxiliary sessions are eagerly spawned on process creation -### Solution: Multi-Tier Periodic Garbage Collection +### Solution: Multi-Tier Loop Garbage Collection Instead of reference counting (error-prone, requires wiring into every lifecycle path), -use a periodic GC loop that is self-healing: even if something goes wrong, the next +use a loop GC loop that is self-healing: even if something goes wrong, the next cycle cleans up. `RunGCOnce()` executes the tiers below in order each cycle. > The tier numbers reflect the order they were added, not their execution order. The @@ -370,7 +370,7 @@ A session is considered **idle** when ALL of the following are true: - Zero WebSocket observers (`!bs.HasObservers()`) - Not currently prompting (`!bs.IsPrompting()`) - Queue is empty (no pending messages) -- No periodic prompt due within the next GC interval +- No loop prompt due within the next GC interval - Not closed (not already cleaned up) When a session is idle, the GC calls `CloseSession()`, which: @@ -378,25 +378,25 @@ When a session is idle, the GC calls `CloseSession()`, which: - Removes it from `SessionManager.sessions` - Calls `bs.Close()` (unregisters from shared process, stops recorder) -**Important**: Sessions with active periodic prompts should NOT be closed if their +**Important**: Sessions with active loop prompts should NOT be closed if their next scheduled delivery is within 2× the GC interval. This avoids the overhead of repeatedly closing and re-creating sessions that will be needed again shortly. -#### Periodic Suspend (within Tier 1) +#### Loop Suspend (within Tier 1) -Tier 1 also **suspends idle periodic conversations** to save memory. A periodic -session whose next prompt is farther away than `PeriodicSuspendThreshold` is eligible +Tier 1 also **suspends idle loop conversations** to save memory. A loop +session whose next prompt is farther away than `LoopSuspendThreshold` is eligible for suspension **even if it has active WebSocket observers** (i.e. the user has it open in the sidebar). When suspended, its ACP connection is closed but the session is **not archived** — it stays visible and resumes transparently via `ensure_resumed` (on user -focus) or the `PeriodicRunner` (when the prompt is due). A generous -`PeriodicSuspendGracePeriod` protects sessions that recently finished a turn from being +focus) or the `LoopRunner` (when the prompt is due). A generous +`LoopSuspendGracePeriod` protects sessions that recently finished a turn from being suspended too aggressively (using the most recent of `LastResponseCompleteAt` and `LastActivityAt`). Before closing, the session is marked `MarkGCSuspended` so the WebSocket auto-resume handler skips it and avoids a suspend/resume thrash loop. -Defaults: `PeriodicSuspendThreshold` = 30m (configurable; 0/negative disables), -`PeriodicSuspendGracePeriod` = 10m. +Defaults: `LoopSuspendThreshold` = 30m (configurable; 0/negative disables), +`LoopSuspendGracePeriod` = 10m. ### Tier 2 — Idle Process Cleanup @@ -431,7 +431,7 @@ it is fully idle** — all of the following must hold: - `p.ActiveRPCs() == 0` (no in-flight RPCs) - No session is `IsPrompting` - All sessions have empty queues (`QueueLength == 0`) -- No session has a periodic prompt due within 2× the GC interval +- No session has a loop prompt due within 2× the GC interval When a bloated process passes every safety gate, each of its sessions is marked `MarkGCSuspended` (to prevent the WebSocket reconnect/resume thrash loop), closed via @@ -449,7 +449,7 @@ that will resume automatically. The payload carries `workspace_uuid`, `workspace `working_dir`, `rss_bytes`, `threshold_bytes`, and `session_count`. This reuses the exact idle-safety and anti-thrash machinery already proven in Tier 1's -periodic-suspend path. The threshold is configurable per the +loop-suspend path. The threshold is configurable per the [Configuration](#configuration) section below. ### Tier 3 — Auxiliary Session Cleanup @@ -477,10 +477,10 @@ So it does NOT start a process for sessions with empty queues. The problem is th after the queue is processed, the session (and its process) remain alive. The GC fixes this. -#### `PeriodicRunner` — Already Safe +#### `LoopRunner` — Already Safe -`PeriodicRunner.checkSession()` only calls `ResumeSession()` when a periodic prompt -is actually due (line ~329 in `periodic_runner.go`). It correctly skips archived +`LoopRunner.checkSession()` only calls `ResumeSession()` when a loop prompt +is actually due (line ~329 in `loop_runner.go`). It correctly skips archived sessions and sessions that aren't due yet. Again, the problem is cleanup after delivery — which the GC handles. @@ -488,12 +488,12 @@ delivery — which the GC handles. Currently, `GetOrCreateProcess()` eagerly pre-warms 4 auxiliary sessions. With the GC in place, this should be **deferred**: pre-warm only when the process is created -for an actual user conversation, not for transient queue/periodic work. +for an actual user conversation, not for transient queue/loop work. Change `GetOrCreateProcess()` to accept a `prewarm bool` parameter: - `true` when called from `CreateSession`/`ResumeSession` for user conversations -- `false` when called from `ProcessPendingQueues` or `PeriodicRunner` paths +- `false` when called from `ProcessPendingQueues` or `LoopRunner` paths Alternatively, keep pre-warming always-on and let the GC clean up the process shortly after — simpler but wastes ~5 seconds of Claude startup for no reason. @@ -513,9 +513,9 @@ session while an aux prompt is in-flight, the aux prompt will fail with "no shar process" on the next attempt. This is acceptable — the failure is logged and the aux result is simply lost (title generation, follow-up suggestions are non-critical). -### Process stopped while PeriodicRunner is about to deliver +### Process stopped while LoopRunner is about to deliver -If the GC stops a process and the PeriodicRunner immediately tries to deliver, +If the GC stops a process and the LoopRunner immediately tries to deliver, `ResumeSession()` will call `GetOrCreateProcess()` and restart the process. This is the correct behavior — the process is started on demand. @@ -547,7 +547,7 @@ path handles killing all processes. The GC does not interfere. 3. **Integration test**: Start a session, close it, wait for GC, verify the shared process is stopped. -4. **Periodic session preservation**: Verify that sessions with upcoming periodic +4. **Loop session preservation**: Verify that sessions with upcoming loop prompts are NOT closed by the GC. ## Configuration @@ -559,13 +559,13 @@ defaults from `defaultGCConfig()`. Two user-facing knobs are exposed via setting | Setting (JSON key) | Valid values | Default | Effect | | -------------------------- | ------------------------------------- | -------------- | ---------------------------------------------------------------------- | -| `periodic_suspend_timeout` | `""`, `disabled`, `15m`, `30m`, `1h`, `2h` | `""` → 30m | Tier 1 periodic-suspend threshold. `disabled` turns the heuristic off. | +| `loop_suspend_timeout` | `""`, `disabled`, `15m`, `30m`, `1h`, `2h` | `""` → 30m | Tier 1 loop-suspend threshold. `disabled` turns the heuristic off. | | `memory_recycle_threshold` | `""`, `disabled`, `3g`, `4g`, `6g`, `8g` | `""` → disabled (opt-in) | Tier 4 RSS threshold above which an idle bloated process is recycled. | -Parsing lives in `ParsePeriodicSuspendTimeout()` and `ParseMemoryRecycleThreshold()` +Parsing lives in `ParseLoopSuspendTimeout()` and `ParseMemoryRecycleThreshold()` (both return `(value, enabled)`). At startup, `server.go` reads these into `GCConfig` when calling `StartGC`. Both can also be updated live on the running GC without a -restart via `UpdatePeriodicSuspendThreshold()` and `UpdateMemoryRecycleThreshold()` +restart via `UpdateLoopSuspendThreshold()` and `UpdateMemoryRecycleThreshold()` (wired from `config_handlers.go` when settings change). A threshold of `0` disables the corresponding tier. @@ -589,13 +589,26 @@ a per-prompt goroutine that watches `lastAgentActivityAt`, a timestamp bumped by - Cancels the in-flight prompt once idle time crosses `promptInactivityWatchdogTimeout` (unblocking the RPC so `is_prompting` clears and the session recovers). -**Defaults (WARN-only):** `promptInactivityWatchdogWarnDelay = 2m`, and -`promptInactivityWatchdogTimeout = 0`. A timeout of `0` **disables automatic -cancellation** — out of the box the watchdog only warns. This is intentional: it avoids -ever cancelling a legitimate long-running tool call that produces no intermediate -streamed output (e.g. a multi-minute build). Setting the timeout to a positive duration -opts in to automatic cancellation. Both values are package vars (overridable in tests); -there is currently no settings/UI exposure. +**Defaults:** `promptInactivityWatchdogWarnDelay = 2m` (package var, WARN-only, not +configurable). The cancellation timeout is exposed via settings as +`agent_inactivity_timeout` (`SessionConfig`), defaulting to **10m enabled** — unlike +`memory_recycle_threshold`, this feature is opt-out rather than opt-in, since a wedged +prompt otherwise deadlocks GC recycling of the process (mitto-54y). A timeout of `0` +(`"disabled"`) turns off automatic cancellation entirely (WARN-only). This avoids ever +cancelling a legitimate long-running tool call that produces no intermediate streamed +output (e.g. a multi-minute build) — the watchdog already pauses its idle clock while a +tool call or UI prompt is in flight, so a live-but-busy agent is never cancelled. + +| Setting (JSON key) | Valid values | Default | Effect | +| ---------------------------- | ------------------------------------- | --------- | ----------------------------------------------------------------------- | +| `agent_inactivity_timeout` | `""`, `disabled`, `5m`, `10m`, `15m`, `30m` | `""` → 10m | Cancels a prompt with zero streamed activity after this long, clearing `is_prompting`. | + +Parsing lives in `ParseAgentInactivityTimeout()` (returns `(value, enabled)`). The +runtime timeout is stored in `conversation.promptInactivityWatchdogTimeoutNanos` (an +`atomic.Int64`, race-safe against concurrent watchdog goroutines) and set via the +exported `conversation.SetPromptInactivityTimeout()`. `server.go` applies it at startup; +`config_handlers.go` re-applies it live when settings change, mirroring the GC threshold +wiring above. When the timeout fires, the prompt error path treats it as a **recoverable** error: it emits an `OnError` to the user and skips the auto-restart / queue-advance logic, so the @@ -605,12 +618,12 @@ session simply returns to idle rather than churning the process. | Component | Change | | -------------------------- | ----------------------------------------------------------------------- | -| `ACPProcessManager` | GC loop, `lastSessionSeen` tracking, `StartGC`/`StopGC`/`RunGCOnce`; Tier 4 memory recycle + live `UpdateMemoryRecycleThreshold`/`UpdatePeriodicSuspendThreshold` | +| `ACPProcessManager` | GC loop, `lastSessionSeen` tracking, `StartGC`/`StopGC`/`RunGCOnce`; Tier 4 memory recycle + live `UpdateMemoryRecycleThreshold`/`UpdateLoopSuspendThreshold` | | `acp_process_memory.go` | New — cross-platform process-tree RSS sampling via `gopsutil/v4` | | `SharedACPProcess` | New `RSSBytes()` (process-tree RSS for the recycle tier) | | `BackgroundSession` | Prompt inactivity watchdog (`startPromptInactivityWatchdog`, `signalAgentActivity`, `lastAgentActivityAt`) | | `SessionManager` | `GetSessionInfoByWorkspace()` method | -| `server.go` | Wire up GC start/stop; read `periodic_suspend_timeout` + `memory_recycle_threshold` into `GCConfig` | +| `server.go` | Wire up GC start/stop; read `loop_suspend_timeout` + `memory_recycle_threshold` into `GCConfig` | | `config_handlers.go` | Live-update GC thresholds when settings change | | `SettingsDialog.js` | UI controls for Suspend Settings + Memory recycling | | Existing session lifecycle | **No changes** — GC and watchdog are purely additive | diff --git a/docs/devel/architecture.md b/docs/devel/architecture.md index 994c694f..48e0cb35 100644 --- a/docs/devel/architecture.md +++ b/docs/devel/architecture.md @@ -156,7 +156,7 @@ Dependency rule: `internal/web` depends on `internal/conversation`; `internal/co **Key Components:** - **BackgroundSession**: Manages an ACP session lifecycle (prompt, streaming, cancel, reconnect) independently of WebSocket connections (moved here from `internal/web`). -- **SessionManager**: Multi-session / multi-workspace orchestration — create/resume/archive/delete, periodic prompts, prompt routing, GC integration. +- **SessionManager**: Multi-session / multi-workspace orchestration — create/resume/archive/delete, loop prompts, prompt routing, GC integration. - **QueueTitleWorker**: Async title generation for queued prompts. - **Streaming buffers**: `StreamBuffer`, `MarkdownBuffer`, `ThoughtBuffer` — buffer and transform ACP streaming events. - **Domain interfaces** (`interfaces.go`): `SharedProcess`, `ProcessManager`, `EventsBroadcaster`, `PromptResolver` — abstractions that let the domain interact with infrastructure remaining in `internal/web` (implemented there via small adapter types), so the domain never imports web. diff --git a/docs/devel/callbacks.md b/docs/devel/callbacks.md index 7c921f52..2f6f3788 100644 --- a/docs/devel/callbacks.md +++ b/docs/devel/callbacks.md @@ -2,22 +2,22 @@ ## Overview -HTTP callback endpoints allow external systems to trigger an on-demand run of a periodic conversation's configured prompt. A callback is equivalent to the periodic scheduler firing — it calls `PeriodicRunner.TriggerNow(sessionID)`. +HTTP callback endpoints allow external systems to trigger an on-demand run of a loop conversation's configured prompt. A callback is equivalent to the loop scheduler firing — it calls `LoopRunner.TriggerNow(sessionID)`. **Key characteristics:** - **Capability URL authentication**: The token in the URL IS the credential (no session cookies required) -- **Independent lifecycle**: Callback tokens survive periodic config changes (disable/enable/reconfigure) -- **Separate storage**: Callback config stored in `callback.json`, not `periodic.json` +- **Independent lifecycle**: Callback tokens survive loop config changes (disable/enable/reconfigure) +- **Separate storage**: Callback config stored in `callback.json`, not `loop.json` - **Rate-limited**: Per-token rate limiting (1 req/10s, burst of 3) protects against abuse - **Public endpoint**: Works identically on localhost and external listeners ## Quick Start -Once you've enabled a callback URL for a periodic conversation (via the properties panel in the UI), you can trigger it with a simple `curl`: +Once you've enabled a callback URL for a loop conversation (via the properties panel in the UI), you can trigger it with a simple `curl`: ```bash -# Trigger a periodic conversation callback +# Trigger a loop conversation callback curl -X POST https://your-mitto-server.com/mitto/api/callback/cb_YOUR_TOKEN_HERE ``` @@ -53,7 +53,7 @@ POST {apiPrefix}/api/callback/{callback-token} ### `POST {apiPrefix}/api/callback/{token}` -Triggers the periodic prompt for the session associated with this token. +Triggers the loop prompt for the session associated with this token. **Request body (optional):** @@ -80,7 +80,7 @@ Triggers the periodic prompt for the session associated with this token. | 404 | `{"error": "not_found"}` | Token doesn't match any session | | 405 | `{"error": "method_not_allowed"}` | Non-POST method | | 409 | `{"error": "session_busy"}` | Session is currently prompting | -| 410 | `{"error": "periodic_disabled"}` | Periodic is disabled or not configured | +| 410 | `{"error": "loop_disabled"}` | Loop is disabled or not configured | | 429 | `{"error": "rate_limited"}` | Too many requests for this token | | 500 | `{"error": "internal"}` | Delivery failure (session unavailable, etc.) | @@ -176,19 +176,19 @@ curl -X DELETE https://mitto.example.com/mitto/api/sessions/20260409-131740-6840 ```mermaid flowchart TB - START[Session Created] --> PERIODIC[Enable Periodic] - PERIODIC --> ENABLE[Enable Callback<br/>POST /api/sessions/id/callback] + START[Session Created] --> LOOP[Enable Loop] + LOOP --> ENABLE[Enable Callback<br/>POST /api/sessions/id/callback] ENABLE --> ACTIVE[callback.json created<br/>Token registered in index] ACTIVE --> SHARED[URL shared with<br/>CI/webhook systems] SHARED --> TRIGGER[External POST<br/>to callback URL] - TRIGGER --> CHECK{Periodic<br/>enabled?} + TRIGGER --> CHECK{Loop<br/>enabled?} CHECK -->|Yes| RUN[200: Trigger prompt] - CHECK -->|No| DISABLED[410: periodic_disabled] + CHECK -->|No| DISABLED[410: loop_disabled] - SHARED --> DISABLE[User disables periodic] + SHARED --> DISABLE[User disables loop] DISABLE --> PRESERVED[callback.json UNTOUCHED<br/>URL returns 410] - PRESERVED --> REENABLE[User re-enables periodic] + PRESERVED --> REENABLE[User re-enables loop] REENABLE --> WORKS[URL returns 200 again] SHARED --> ROTATE[User rotates token] @@ -206,22 +206,22 @@ flowchart TB **Key points:** -1. **Independent lifecycle**: Callback token survives periodic being disabled/reconfigured -2. **Preserved on disable**: Disabling periodic doesn't delete `callback.json` (URL returns 410 instead of 404) -3. **Re-enable works**: Re-enabling periodic makes the same callback URL work again (200) +1. **Independent lifecycle**: Callback token survives loop being disabled/reconfigured +2. **Preserved on disable**: Disabling loop doesn't delete `callback.json` (URL returns 410 instead of 404) +3. **Re-enable works**: Re-enabling loop makes the same callback URL work again (200) 4. **Rotation invalidates old token**: Rotating generates a new token and the old one returns 404 immediately 5. **Revoke is permanent**: Deleting the callback removes `callback.json` and the URL returns 404 6. **Session deletion**: Deleting the session removes the callback and cleans up the index ## Data Model -Callback config is stored **separately** from periodic config to ensure independent lifecycles: +Callback config is stored **separately** from loop config to ensure independent lifecycles: ``` sessions/{session-id}/ ├── metadata.json ├── events.jsonl - ├── periodic.json ← periodic config (may not exist) + ├── loop.json ← loop config (may not exist) └── callback.json ← callback token (independent) ``` @@ -236,9 +236,9 @@ sessions/{session-id}/ This separation ensures: -- Callback URL survives periodic being disabled/deleted/reconfigured -- Token rotation doesn't affect periodic settings -- Callback can be revoked without touching periodic config +- Callback URL survives loop being disabled/deleted/reconfigured +- Token rotation doesn't affect loop settings +- Callback can be revoked without touching loop config ## Security Model @@ -279,12 +279,12 @@ This separation ensures: **Callback can ONLY:** -- Call `TriggerNow()` on the periodic runner +- Call `TriggerNow()` on the loop runner - Log metadata for auditing **Callback CANNOT:** -- Change periodic configuration +- Change loop configuration - Read session data - Modify session metadata - Execute arbitrary code @@ -365,16 +365,16 @@ func (cr *CallbackRateLimiter) Allow(token string) bool ## Frontend -The `ConversationPropertiesPanel` component shows callback controls in the **Periodic Prompts** section. +The `ConversationPropertiesPanel` component shows callback controls in the **Loop Prompts** section. ### UI States -| Periodic State | Callback State | UI Display | +| Loop State | Callback State | UI Display | | -------------- | -------------- | ---------------------------------------------------------------------- | -| Disabled | None | "Enable Periodic Prompts first" message | +| Disabled | None | "Enable Loop Prompts first" message | | Enabled | None | "Enable Callback" button | | Enabled | Active | URL display + Copy/Rotate/Revoke buttons | -| Disabled | Active | Subdued display: "Callback preserved but inactive (periodic disabled)" | +| Disabled | Active | Subdued display: "Callback preserved but inactive (loop disabled)" | ### Workflow @@ -420,7 +420,7 @@ sequenceDiagram ### GitHub Webhook -Configure a GitHub webhook to POST to the callback URL on push events. The periodic prompt might be "Check the latest commits and summarize changes." +Configure a GitHub webhook to POST to the callback URL on push events. The loop prompt might be "Check the latest commits and summarize changes." **GitHub Webhook Settings:** @@ -429,7 +429,7 @@ Configure a GitHub webhook to POST to the callback URL on push events. The perio - **Events**: Select "Just the push event" or custom events - **Active**: ✓ -**Example periodic prompt:** +**Example loop prompt:** ``` Check the latest commits in the repository and provide a summary of changes. @@ -447,7 +447,7 @@ Trigger daily reports at 9am via system cron: -d '{"metadata": {"source": "cron", "job": "daily-report"}}' ``` -**Example periodic prompt:** +**Example loop prompt:** ``` Generate a daily status report for the team: @@ -478,7 +478,7 @@ Add a step in your CI pipeline to trigger a Mitto conversation after deployment: }' ``` -**Example periodic prompt:** +**Example loop prompt:** ``` A deployment to production just completed. Please: @@ -508,7 +508,7 @@ curl -X POST https://mitto.example.com/mitto/api/callback/cb_a1b2c3d4... \ }' ``` -**Example periodic prompt:** +**Example loop prompt:** ``` An alert was triggered for high error rate. Please investigate: @@ -523,7 +523,7 @@ An alert was triggered for high error rate. Please investigate: ### Manual Testing ```bash -# 1. Enable periodic for a session (via UI or API) +# 1. Enable loop for a session (via UI or API) # 2. Enable callback and get the URL curl -X POST http://localhost:5757/api/sessions/20260409-131740-68402925/callback \ -H "Cookie: session=..." @@ -551,10 +551,10 @@ curl -X POST http://localhost:5757/api/callback/invalid_token curl -X POST http://localhost:5757/api/callback/cb_0000000000000000... # Expected: 404 {"error": "not_found"} -# Periodic disabled -# (disable periodic via UI, then trigger callback) +# Loop disabled +# (disable loop via UI, then trigger callback) curl -X POST http://localhost:5757/api/callback/cb_a1b2c3d4... -# Expected: 410 {"error": "periodic_disabled"} +# Expected: 410 {"error": "loop_disabled"} # Rate limiting (send 4+ requests rapidly) for i in {1..5}; do @@ -583,13 +583,13 @@ curl -X POST http://localhost:5757/api/callback/cb_a1b2c3d4... ### Callback returns 410 intermittently -**Cause:** Periodic is being disabled/enabled by another client or configuration change. +**Cause:** Loop is being disabled/enabled by another client or configuration change. **Solution:** -1. Verify periodic is enabled via `GET /api/sessions/{id}/periodic` +1. Verify loop is enabled via `GET /api/sessions/{id}/loop` 2. Check if auto-disable conditions are met (e.g., error threshold) -3. Review session events for periodic state changes +3. Review session events for loop state changes ### Rate limiting too aggressive @@ -609,7 +609,7 @@ curl -X POST http://localhost:5757/api/callback/cb_a1b2c3d4... 1. Check session status via `GET /api/sessions/{id}` 2. Review server logs for `TriggerNow` errors -3. Verify session has a valid periodic configuration +3. Verify session has a valid loop configuration 4. Check `events.jsonl` for error events ## Security Considerations @@ -653,7 +653,7 @@ curl -X POST http://localhost:5757/api/callback/cb_a1b2c3d4... **Mitigation:** 1. Callback can **ONLY** trigger `TriggerNow()` — no other mutations -2. Prompt content is controlled by session owner (via periodic config) +2. Prompt content is controlled by session owner (via loop config) 3. No data exposure — callback returns minimal response 4. Session authentication required for all management endpoints diff --git a/docs/devel/mcp-tool-discovery.md b/docs/devel/mcp-tool-discovery.md new file mode 100644 index 00000000..c1330f7a --- /dev/null +++ b/docs/devel/mcp-tool-discovery.md @@ -0,0 +1,196 @@ +# MCP Tool Discovery (ADR) + +Spike outcome for **mitto-1ae.2**. Decides how to make `Tools.HasPattern(...)` +(used in `enabledWhen` for external-tool gates like `jira_*`/`github_*`/`slack_*`) +trustworthy. The sibling bug mitto-1ae.1 already removed the Mitto-native +`mitto_conversation_*` gates in favor of deterministic `Permissions.*` — this +doc is only about the remaining **external MCP tool** discovery. + +## Context / current mechanism + +Mitto has no way to ask an ACP agent "what tools do you have" directly, so it +asks the agent's own LLM to introspect itself: + +- `WorkspaceAuxiliaryManager.FetchMCPTools` (`internal/auxiliary/workspace_manager.go:272`) + sends `FetchMCPToolsPromptTemplate` (`internal/auxiliary/prompts/fetch_mcp_tools.txt`) + — "List ALL MCP tools currently available to you... respond ONLY with JSON" — + to a dedicated auxiliary ACP session, then `parseMCPToolsList` + (`internal/auxiliary/utils.go:174`) tolerantly parses the free-form reply + (handles markdown fences, a bare array, surrounding prose). +- Results are cached **in-memory, per workspace** in `mcpToolsCache` + (`workspace_manager.go:52`); **only non-empty results are cached** + (`workspace_manager.go:339`), so an empty/failed fetch never overwrites a + good cache — but a **short-but-non-empty hallucinated list also gets cached**. +- `ToolsContext.Available` (`internal/config/cel_context.go:236`) is set `true` + **only** when `GetCachedMCPTools` finds an entry (`internal/web/session_api.go:350,490`). + While `Available=false`, `hasPattern`/`hasAllPatterns`/`hasAnyPattern` + (`internal/config/templatefuncs.go:23`) **fail open** (return `true`) so + prompts aren't hidden during warm-up — this is also why `mitto-1ae.1` had to + move deterministic capabilities off `Tools.HasPattern`. Processors instead + force `Tools.Available=true` unconditionally (`internal/processors/hook.go:272`), + so they never get the warm-up grace period. +- Triggers: `SessionManager.ensureMCPToolsFetch` fires an async fetch on first + message (`internal/conversation/session_manager.go:2756`); `session_ws.go`'s + WS-connect path (`internal/web/session_ws.go:1244`) is the fallback trigger, + and `checkRequiredToolPatterns` (`session_ws.go:1664`) re-broadcasts + `prompts_changed` at 30s/60s/120s **just in case tools appear late** — a + symptom of not trusting the result, not a real refresh mechanism. +- Separately, `CheckRequiredToolPatterns` (`workspace_manager.go:392`) sends a + **second**, per-pattern LLM query to the same auxiliary session — also + non-deterministic. + +## Findings + +### Q1 — Can we get a real tool list deterministically? + +**ACP protocol itself: no.** Checked the vendored SDK +(`github.com/coder/acp-go-sdk@v0.12.0`, `types_gen.go`): `McpServers` appears +only in `InitializeRequest`/`NewSessionRequest`/`ResumeSessionRequest` — it is +**client→agent** (Mitto tells the agent which MCP servers to wire up for the +session; see `internal/acp/connection.go:271` `NewSession`, which currently +passes `McpServers: []acp.McpServer{}`). `AgentCapabilities.McpCapabilities` +only advertises transport support booleans (`Http`, `Sse`), not tool names. +`ToolCall` types describe tool **use** during a turn, not a queryable catalog. +There is no `tools/list`-equivalent RPC exposed to the client in this SDK. + +**Real MCP `tools/list` against agent-configured servers: yes, and mostly +already wired for server _discovery_.** `agents.Manager.ListMCPServers` +(`internal/agents/manager.go:299`, `agents.CommandMCPList`) already runs each +agent's `mcp-list.sh` and returns `[]MCPServer{Name, Command, Args, URL, Env}` +**deterministically** (parses the agent's own settings file, e.g. +`~/.claude/settings.json`, `~/.augment/settings.json` merged with +project/local scopes for auggie — verified for augment, claude-code, amp, +cline, kilo, codex, gemini, mistral-vibe; `junie`'s script is a stub always +returning `{"servers": []}`). This gives server **identity**, not tool names. +To get tool names, Mitto would need to actually connect to each server and +call `tools/list` — and it already depends on a client-capable MCP SDK: +`github.com/modelcontextprotocol/go-sdk@v1.4.0` provides `mcp.NewClient`, +`mcp.CommandTransport` (stdio, `mcp/cmd.go`), `mcp.StreamableClientTransport` +/ `mcp.SSEClientTransport` (http/sse, `mcp/streamable.go`, `mcp/sse.go`), and +`ClientSession.ListTools` (`mcp/client.go:982`). Today this SDK is only used +server-side (`internal/mcpserver`); nothing currently uses its client side. + +**Feasibility table:** + +| Agent (ACP type) | Server discovery (`ListMCPServers`) | stdio `tools/list` | http/sse `tools/list` | +| ---------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| augment (auggie), claude-code, amp, cline, kilo, codex, gemini, mistral-vibe | Deterministic (local settings JSON) | High feasibility via `mcp.CommandTransport`; effort=moderate (process lifecycle + bounded timeout); risk=slow/side-effecting cold starts for heavy `npx`-based servers | Medium feasibility via `StreamableClientTransport`/`SSEClientTransport`; risk=`agents.MCPServer` has no `Headers` field, so servers needing bearer/auth headers can't be reached yet | +| junie | `mcp-list.sh` is an unimplemented stub | N/A until server discovery is implemented | N/A | +| cursor, opencode, github-copilot, goose, qwen-code | Not inspected in this spike | Unknown — needs per-agent script audit | Unknown | + +### Q2 — Hardening the LLM fallback (for agents/servers a direct connection can't reach) + +- Keep the JSON-object schema (`{"tools":[...]}` / `{"error":"..."}"`) but + reject anything that doesn't parse as that exact shape — no bare-array + fallback, no "grab the biggest `{...}` substring" leniency for new fetches. +- Add a **plausibility check**: if `ListMCPServers` reports N configured + servers but the LLM reports 0 tools, treat the result as suspect and retry + once with a stricter reminder before accepting an empty answer. +- One automatic retry on a parse failure or an empty response; only give up + (and keep the last-known-good cache) after that retry also fails. + +### Q3 — Cache & fail-open policy (the flicker fix) + +1. **Last-known-good.** Never overwrite a cached non-empty tool list with an + empty or shorter one without corroboration: require **two consecutive** + independent negative fetches (different trigger points or time-separated) + before downgrading any previously-observed tool/pattern to absent. +2. **Fail-open only during genuine cold start.** `Tools.Available=false` + (pattern-match fails open) is legitimate only until the **first** fetch for + a workspace completes, ever. After that, `Available` stays `true` for the + process lifetime and pattern truth is name-based only (fail-closed) — this + is already the code's intent (`session_api.go:350`) but should become an + explicit, tested invariant rather than a side effect of "only cache + non-empty results". +3. **Persistence.** Persist the **real-MCP-derived** list to disk per + workspace (it reflects static config, so it's stable) with a TTL-based + refresh (e.g. 15 min) plus an explicit "refresh" action and the existing + `ClearMCPToolsCache` invalidation hook. Do **not** persist the LLM-fallback + result across restarts — it is inherently less trustworthy; keep it + in-memory only, as today. +4. **Retire blind timed retries.** The 30s/60s/120s re-broadcast loop in + `checkRequiredToolPatterns` (`session_ws.go:1684`) exists only because the + LLM path is slow and unreliable. A bounded (5-10s timeout) direct MCP + connection resolves synchronously, so this polling can be removed once + real discovery lands for a given agent/transport. + +### Q4 — Dynamic / late-starting servers (the tool list changes over time) + +MCP servers can come online _after_ the ACP agent starts (e.g. a slow `npx` +cold start that is only ready ~30s in), and a server may add/remove tools +mid-session. The Q3 policy above is written for _flicker_ (stop tools spuriously +disappearing/reappearing) and, taken alone, would **regress** this legitimate +case: once the first fetch completes, `Available` latches `true` and matching +becomes fail-closed (Q3.2); last-known-good guards only against _downgrades_, +not _appearances_ (Q3.1); and retiring the 30s/60s/120s re-broadcast (Q3.4) +plus a 15-min TTL (Q3.3) removes the only fast late-detection path — a tool that +appears at t=40s could stay hidden for up to 15 min. + +Refinements so late/dynamic tools surface without reintroducing flicker: + +1. **Per-server availability state, not one global `Available` bit.** + Distinguish "server _configured_ (from `ListMCPServers`) but not yet + _reachable_" from "server reachable, tool genuinely absent." Keep fail-open + + short backoff for the former (that server's namespace only); treat only the + latter as a real negative for the two-consecutive-negatives rule. A single + global latch cannot express this. +2. **Event-driven refresh via MCP `notifications/tools/list_changed`.** MCP + servers advertise a `listChanged` capability and push a notification when + their tool set changes; a held `ClientSession` can react immediately. Verify + the vendored `modelcontextprotocol/go-sdk` client exposes a handler + (`mcp/client.go`) — if so, this is the proper replacement for blind polling. +3. **Bounded backoff for configured-but-unreachable servers.** When a direct + `tools/list` times out (the 5-10s bound from Q1) against a still-starting + server, schedule short exponential-backoff retries (up to a cap) rather than + waiting for the flat TTL — and do **not** cache that timeout as a negative. +4. **Do not remove the fast re-broadcast (Q3.4) until an event-driven or + backoff-based equivalent exists**, otherwise late-starting servers regress. + +Note: server _identity_ is unaffected by startup timing — `ListMCPServers` reads +static config, so the set of servers to probe is known immediately; only tool +_names_ depend on a live connection. + +## Recommendation / Decision + +**Prefer real MCP `tools/list` via the servers already discovered by +`ListMCPServers`, for every transport the vendored `modelcontextprotocol/go-sdk` +client supports (stdio, http, sse). Fall back to the hardened LLM enumeration +only when a server can't be reached this way** (unsupported agent, missing +`mcp-list.sh` support, auth-required HTTP endpoint, or a live connection +error). Cache = last-known-good with the two-consecutive-negatives downgrade +rule; fail-open is a cold-start-only exception, never a steady-state behavior. +This removes LLM non-determinism entirely for the common case (locally +configured stdio/plain-URL MCP servers for auggie/claude-code) and confines +the flaky path to the genuinely hard cases. + +**Caveat (Q4):** the cold-start-only fail-open above must be scoped +_per server_, not as one global latch, and paired with event-driven refresh +(`notifications/tools/list_changed`) or bounded backoff so late-starting servers +still surface. The fast re-broadcast must stay until that equivalent exists. + +## Proposed follow-up implementation issues (not implemented here) + +- Direct MCP client tool discovery over stdio (`mcp.CommandTransport` + + `ListTools`), wired to `agents.Manager.ListMCPServers`. +- Direct MCP client tool discovery over http/sse (`StreamableClientTransport` + / `SSEClientTransport`). +- Add a `Headers` field to `agents.MCPServer` (+ each `mcp-list.sh`) to support + authenticated HTTP/SSE MCP servers. +- Implement a real `mcp-list.sh` for `junie` (currently a stub). +- Harden `FetchMCPTools`'s LLM fallback: strict schema validation, + retry-on-incomplete, plausibility check against `ListMCPServers` count. +- Implement the last-known-good / two-consecutive-negatives cache policy in + `WorkspaceAuxiliaryManager.mcpToolsCache`. +- Persist the real-MCP-derived tools cache to disk per workspace with TTL + refresh + manual refresh action. +- Remove the blind 30s/60s/120s re-broadcast retries in `session_ws.go` **only + after** an event-driven or backoff-based late-detection replacement exists + (see Q4) — removing it earlier regresses late-starting servers. +- Scope fail-open state **per configured server** (not one global `Available` + bit) so "configured-but-unreachable" keeps that namespace fail-open with + bounded backoff, while "reachable, tool absent" is a genuine negative (Q4). +- Implement event-driven refresh via MCP `notifications/tools/list_changed`: + verify the vendored `modelcontextprotocol/go-sdk` client exposes a handler and + react to it on held `ClientSession`s to surface late/changed tools (Q4). +- Audit `mcp-list.sh` support for `cursor`, `opencode`, `github-copilot`, + `goose`, `qwen-code` (not inspected in this spike). diff --git a/docs/devel/mcp.md b/docs/devel/mcp.md index 298d3782..a105e80d 100644 --- a/docs/devel/mcp.md +++ b/docs/devel/mcp.md @@ -475,7 +475,7 @@ sequenceDiagram #### `mitto_conversation_update` -Update properties of a conversation. Supports partial updates — only specified fields are changed, others are left untouched. Any registered session can update any conversation (no parent-child restriction). Pass `"self"` (or your own conversation ID) as `conversation_id` to update your own conversation (e.g. a periodic conversation disabling its own periodicity). +Update properties of a conversation. Supports partial updates — only specified fields are changed, others are left untouched. Any registered session can update any conversation (no parent-child restriction). Pass `"self"` (or your own conversation ID) as `conversation_id` to update your own conversation (e.g. a loop conversation disabling its own looping). | Parameter | Type | Required | Description | | ----------------- | ------------------------------- | -------- | -------------------------------------------------------------- | @@ -1078,6 +1078,15 @@ sequenceDiagram - **Idempotent reports**: A child can report multiple times during a single wait cycle; each report overwrites the previous one. - **Reports between waits are stored**: If a child calls `_report` when no wait is active, the report is accepted and stored. It will be available when the parent next calls `_wait` with the same `task_id`. +### Conversation Statistics + +The conversation properties panel Statistics section (see `web/static/components/ConversationPropertiesPanel.js`) surfaces MCP-usage, orchestration, and activity counters computed by `internal/web/session_ws.go` at WebSocket connect time (not on every `prompt_complete`, to bound cost). Two sourcing strategies are used, with different restart behavior: + +- **Event-derived (survive restart)**: `mcp_calls_total`, `mcp_ui_calls`, `mcp_children_wait_calls`, `turns`, `acp_tool_calls`, `permissions_allowed`/`permissions_denied`, `errors`, and `images_uploaded` are computed by a single pass over `store.ReadEvents()` (`computeEventStats` in `session_ws.go`). `mitto_*` MCP tool calls are recorded as `tool_call` events with the tool name in the `title` field, so they can be recovered from `events.jsonl` after a restart. `tool_call` events are deduplicated by `tool_call_id` so status re-emissions aren't double-counted. `children_spawned` is derived from `store.CountChildSessions()` (metadata-based). +- **In-memory (reset on restart)**: `child_wait_count`/`child_wait_total_ms` (accumulated via `BackgroundSession.RecordChildWait`, called from `handleChildrenTasksWait` on the PARENT session for calls that actually block) and `usage_cumulative` (accumulated via `BackgroundSession.GetCumulativeUsage`, updated alongside `lastUsage` in `promptDispatcher.accumulateTokenUsage`) are not persisted anywhere and reset to zero when the Mitto server restarts, since token usage is not recorded in `events.jsonl`. + +Permission outcomes are recorded as `"auto_approved"`, `"user_selected"`, or `"timed_out"` (see `PermissionData.Outcome`) — none of these directly encodes allow/deny. Auto-approved permissions always count as allowed; user-made choices are classified by inspecting the selected option ID for `"allow"` vs. `"deny"`/`"reject"` substrings (agent-defined, not a protocol guarantee). Timed-out or unrecognized choices count as neither. + --- ## Session Registration diff --git a/docs/devel/message-queue.md b/docs/devel/message-queue.md index 057270a7..e1d3602b 100644 --- a/docs/devel/message-queue.md +++ b/docs/devel/message-queue.md @@ -118,9 +118,9 @@ When `Pop()` is called, it selects the next ready message: 2. If no immediate messages, the **earliest due scheduled message** (by ScheduledTime) 3. Returns `ErrQueueEmpty` if no messages are ready (even if future-scheduled messages exist) -### Periodic Check +### Loop Check -The `PeriodicRunner` checks all active sessions for due scheduled messages on each poll cycle (default: 1 minute). When a scheduled message becomes due, it triggers `TryProcessQueuedMessage()` on the session. +The `LoopRunner` checks all active sessions for due scheduled messages on each poll cycle (default: 1 minute). When a scheduled message becomes due, it triggers `TryProcessQueuedMessage()` on the session. ### API @@ -132,20 +132,20 @@ The `PeriodicRunner` checks all active sessions for due scheduled messages on ea Scheduled messages display a ⏰ badge with a relative time string (e.g., "in 5 min", "in 2h") in the queue dropdown. The display updates every 30 seconds. -## Periodic Prompts: On-Completion Delivery +## Loop Prompts: On-Completion Delivery -Periodic prompts normally fire on a fixed schedule (checked by the `PeriodicRunner` poll loop). A periodic prompt may instead set `trigger: onCompletion`, which fires the next run **after the agent stops responding**, rather than on a clock. +Loop prompts normally fire on a fixed schedule (checked by the `LoopRunner` poll loop). A loop prompt may instead set `trigger: onCompletion`, which fires the next run **after the agent stops responding**, rather than on a clock. ### Delivery model -When a turn completes and a session goes fully idle, `BackgroundSession` invokes the `onTurnIdle` hook, which routes to `PeriodicRunner.OnConversationIdle(sessionID)`. For an enabled `onCompletion` config this arms a one-shot timer for `delay` seconds (clamped up to the global floor `min_periodic_completion_delay_seconds`, default 5). When the timer fires, `fireOnCompletion` re-validates the config, checks the max-duration cap, and delivers via `TriggerNow`. The delivered run's own completion produces another idle transition, which arms the next run — a self-sustaining loop. +When a turn completes and a session goes fully idle, `BackgroundSession` invokes the `onTurnIdle` hook, which routes to `LoopRunner.OnConversationIdle(sessionID)`. For an enabled `onCompletion` config this arms a one-shot timer for `delay` seconds (clamped up to the global floor `min_loop_completion_delay_seconds`, default 5). When the timer fires, `fireOnCompletion` re-validates the config, checks the max-duration cap, and delivers via `TriggerNow`. The delivered run's own completion produces another idle transition, which arms the next run — a self-sustaining loop. ```mermaid sequenceDiagram participant Agent participant BS as BackgroundSession - participant PR as PeriodicRunner - participant Store as PeriodicStore + participant PR as LoopRunner + participant Store as LoopStore Agent->>BS: turn completes (stop_reason=end_turn) BS->>PR: onTurnIdle → OnConversationIdle(sessionID) @@ -156,7 +156,7 @@ sequenceDiagram PR->>Store: ReachedMaxDuration(now)? alt maxDuration reached PR->>Store: Update(enabled=false) - PR-->>BS: onPeriodicAutoStopped → broadcast periodic_updated + PR-->>BS: onLoopAutoStopped → broadcast loop_updated else within cap PR->>BS: TriggerNow(resetTimer=true) → deliver run BS->>Agent: prompt @@ -169,7 +169,7 @@ sequenceDiagram ### Loop safety -- **Delay floor** — `delay` is clamped up to `min_periodic_completion_delay_seconds` (default 5) so a misconfigured `delay: 0` cannot spin a hot loop. +- **Delay floor** — `delay` is clamped up to `min_loop_completion_delay_seconds` (default 5) so a misconfigured `delay: 0` cannot spin a hot loop. - **Single pending timer** — arming replaces (stops) any existing timer for the session, so at most one firing is queued. - **Max iterations** — the standard per-run counter still applies; reaching the effective cap disables the prompt. - **Max duration** — `maxDuration` is a wall-clock cap from the first run; `fireOnCompletion` checks it before delivering and auto-stops (disables + broadcasts) once exceeded. @@ -177,15 +177,15 @@ sequenceDiagram ### Interplay with the runner and suspension -The schedule-based poll loop and the on-completion timers are independent paths on the same `PeriodicRunner`. On-completion timers are armed by idle events, not the poll loop, so they are unaffected by the poll interval. A suspended periodic session (Tier-1 GC after `periodic_suspend_timeout`) has no live `BackgroundSession` to emit idle events; the on-completion loop resumes once the session is resumed. See [acp.md](acp.md) for suspension details. +The schedule-based poll loop and the on-completion timers are independent paths on the same `LoopRunner`. On-completion timers are armed by idle events, not the poll loop, so they are unaffected by the poll interval. A suspended loop session (Tier-1 GC after `loop_suspend_timeout`) has no live `BackgroundSession` to emit idle events; the on-completion loop resumes once the session is resumed. See [acp.md](acp.md) for suspension details. -## Periodic Prompts: On-Tasks Delivery +## Loop Prompts: On-Tasks Delivery -A periodic prompt may set `trigger: onTasks`, which fires whenever the **beads issues in the conversation's working directory change** on disk, optionally gated by a **CEL condition** so it only fires for meaningful changes (e.g. "the open bug count increased", "an issue labelled `PR opened` was created or updated"). Like `onCompletion`, this is event-driven, not clock-driven — `Frequency` is not required and is ignored. +A loop prompt may set `trigger: onTasks`, which fires whenever the **beads issues in the conversation's working directory change** on disk, optionally gated by a **CEL condition** so it only fires for meaningful changes (e.g. "the open bug count increased", "an issue labelled `PR opened` was created or updated"). Like `onCompletion`, this is event-driven, not clock-driven — `Frequency` is not required and is ignored. ### Trigger semantics -A workspace-wide `BeadsWatcher` (fsnotify on `.beads/`, debounced) calls `PeriodicRunner.OnBeadsChanged(event)` whenever a watched working directory changes. For every **enabled** `onTasks` conversation whose working directory is in `event.WorkingDirs`, the runner: +A workspace-wide `BeadsWatcher` (fsnotify on `.beads/`, debounced) calls `LoopRunner.OnBeadsChanged(event)` whenever a watched working directory changes. For every **enabled** `onTasks` conversation whose working directory is in `event.WorkingDirs`, the runner: 1. Fetches the latest beads snapshot once per working directory (`bd list --json --all -n 0`), shared across all conversations watching that directory. 2. Diffs it against that **conversation's own persisted baseline** (see below) using `config.DiffTasks`. @@ -225,7 +225,7 @@ Changes.Added.exists(i, i.type == "bug" && i.priority <= 1) ### The diff baseline (`internal/web/tasks_baseline.go`) -Each `onTasks` conversation keeps its **own** baseline file (`tasks_baseline.json`, alongside `periodic.json`) holding the raw `bd list` JSON at the time it was last considered "current" for that conversation. The baseline is **per-conversation, not per-working-directory** — several `onTasks` conversations watching the same directory each diff against their own baseline, which is what makes Layer 2 loop prevention (below) possible without any actor/attribution support from `bd`. +Each `onTasks` conversation keeps its **own** baseline file (`tasks_baseline.json`, alongside `loop.json`) holding the raw `bd list` JSON at the time it was last considered "current" for that conversation. The baseline is **per-conversation, not per-working-directory** — several `onTasks` conversations watching the same directory each diff against their own baseline, which is what makes Layer 2 loop prevention (below) possible without any actor/attribution support from `bd`. ### Loop prevention (4 layers) @@ -234,7 +234,7 @@ An `onTasks` conversation (or a child it delegates to) will usually _edit_ beads ```mermaid sequenceDiagram participant Watcher as BeadsWatcher - participant PR as PeriodicRunner + participant PR as LoopRunner participant Baseline as TasksBaselineStore participant CEL as TasksConditionEvaluator participant Agent @@ -271,14 +271,14 @@ sequenceDiagram Note over Baseline: absorbs the run's own edits — they never<br/>reappear as a delta against the NEXT event ``` -- **Layer 0 — hard backstops.** A per-conversation `CooldownSeconds` (clamped up to the global floor `SetMinPeriodicTasksCooldownSeconds`, default 30s) rate-limits fires regardless of the condition. `MaxIterations` and `MaxDurationSeconds` are the same caps used by every trigger; `MaxDurationSeconds` is checked (and auto-stops, mirroring `onCompletion`) before the cooldown check. +- **Layer 0 — hard backstops.** A per-conversation `CooldownSeconds` (clamped up to the global floor `SetMinLoopTasksCooldownSeconds`, default 30s) rate-limits fires regardless of the condition. `MaxIterations` and `MaxDurationSeconds` are the same caps used by every trigger; `MaxDurationSeconds` is checked (and auto-stops, mirroring `onCompletion`) before the cooldown check. - **Layer 1 — busy guard (temporal).** While the conversation's turn is active — **or any delegated child conversation is still running or blocked on `mitto_children_tasks_wait`** (`isTasksSubtreeBusy`) — incoming events are deferred (`armTasksRebase`), not evaluated. This is the guard against the run's OWN in-flight edits. - **Layer 2 — quiescence rebase (the real fix).** Once the conversation's entire delegated-child subtree goes idle, a short quiescence timer (`SetTasksQuiescenceWindow`, default 30s) fires and **rebases the baseline to the current beads snapshot**, absorbing the run's own edits into the new "current" state before the next real event is evaluated. Trade-off: an external change that lands _during_ the busy window is also absorbed and won't trigger a follow-up fire — the fired conversation can re-check state at its own startup if that matters. -- **Layer 3 — no-progress circuit breaker.** `recordTasksFireOutcome` tracks, per conversation, the set of issue IDs touched (`Changes.Touched`) by consecutive fires. When `tasksNoProgressLimit` (3) consecutive fires touch **no issue beyond** what the previous fire already touched, the trigger auto-pauses (`periodicStore.MarkStopped(session.StoppedReasonNoProgress)`) — this catches a condition that is steady-state-true (e.g. a threshold that baseline-rebase alone cannot silence) before it can hot-loop. +- **Layer 3 — no-progress circuit breaker.** `recordTasksFireOutcome` tracks, per conversation, the set of issue IDs touched (`Changes.Touched`) by consecutive fires. When `tasksNoProgressLimit` (3) consecutive fires touch **no issue beyond** what the previous fire already touched, the trigger auto-pauses (`loopStore.MarkStopped(session.StoppedReasonNoProgress)`) — this catches a condition that is steady-state-true (e.g. a threshold that baseline-rebase alone cannot silence) before it can hot-loop. **Out of scope:** actor-based delta filtering (skipping only _other actors'_ edits) was investigated and explicitly deferred — `internal/beads/cli.go` does not stamp a per-change actor, and `bd list --json` exposes only `created_by`/`owner`, not a last-touched actor. The baseline-rebase approach (Layer 2) makes this unnecessary for correctness today. -### Configuration fields (`session.PeriodicPrompt`) +### Configuration fields (`session.LoopPrompt`) | Field | JSON | Meaning | | ----------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------- | @@ -290,7 +290,7 @@ sequenceDiagram ### Testing -`internal/config/tasks_condition_test.go` unit-tests snapshot parsing, diffing, and CEL evaluation (including the fail-closed cases). `internal/web/periodic_runner_test.go` unit-tests the guard/decision logic (`evaluateTasksChange`) and each loop-prevention layer in isolation. `tests/integration/inprocess/periodic_ontasks_e2e_test.go` drives the full stack end-to-end against the mock ACP server — CEL-gated firing, the busy-guard + quiescence-rebase interaction, the cooldown floor, the no-progress circuit breaker, and `MaxIterations`/`MaxDurationSeconds` auto-stop — by calling `PeriodicRunner.OnBeadsChanged` directly with a fake `beads.Client` standing in for `bd list` (the `BeadsWatcher` itself is out of scope for that test and is unit-tested separately). +`internal/config/tasks_condition_test.go` unit-tests snapshot parsing, diffing, and CEL evaluation (including the fail-closed cases). `internal/web/loop_runner_test.go` unit-tests the guard/decision logic (`evaluateTasksChange`) and each loop-prevention layer in isolation. `tests/integration/inprocess/loop_ontasks_e2e_test.go` drives the full stack end-to-end against the mock ACP server — CEL-gated firing, the busy-guard + quiescence-rebase interaction, the cooldown floor, the no-progress circuit breaker, and `MaxIterations`/`MaxDurationSeconds` auto-stop — by calling `LoopRunner.OnBeadsChanged` directly with a fake `beads.Client` standing in for `bd list` (the `BeadsWatcher` itself is out of scope for that test and is unit-tested separately). ## Title Generation @@ -377,12 +377,12 @@ All menu-driven prompt sends (prompts menu, Cmd+/ slash picker, beads-issue menu | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | `buildSeedQueueBody(prompt, {arguments})` | Builds `{prompt_name, arguments}` POST body (never includes `message`) | | `seedConversationWithPrompt(sessionId, prompt, {arguments})` | POST `{prompt_name}` to an existing session's queue | -| `startConversationWithPrompt({workingDir, acpServer, name, beadsIssue, prompt, arguments, periodic})` | Create a new conversation (one-time or periodic — see below) | -| `configurePeriodicSchedule(sessionId, prompt, periodic, {fetchImpl})` | PUT periodic config onto an already-created session | +| `startConversationWithPrompt({workingDir, acpServer, name, beadsIssue, prompt, arguments, loop})` | Create a new conversation (one-time or loop — see below) | +| `configureLoopSchedule(sessionId, prompt, loop, {fetchImpl})` | PUT loop config onto an already-created session | -#### One-time path (no `periodic`) +#### One-time path (no `loop`) -When `periodic` is absent, `startConversationWithPrompt` posts `initial_prompt_name` + `arguments` to `POST /api/sessions` — the backend seeds the queue atomically: +When `loop` is absent, `startConversationWithPrompt` posts `initial_prompt_name` + `arguments` to `POST /api/sessions` — the backend seeds the queue atomically: ```javascript const { seedConversationWithPrompt, startConversationWithPrompt } = @@ -404,12 +404,12 @@ await startConversationWithPrompt({ }); ``` -#### Periodic path (`periodic` present) +#### Loop path (`loop` present) -When `periodic: { value, unit, at? }` is provided, `startConversationWithPrompt`: +When `loop: { value, unit, at? }` is provided, `startConversationWithPrompt`: 1. Creates the session via `POST /api/sessions` **without** `initial_prompt_name` (no one-time queue seed). -2. Calls `configurePeriodicSchedule` which PUTs `/api/sessions/{id}/periodic` with: +2. Calls `configureLoopSchedule` which PUTs `/api/sessions/{id}/loop` with: ```json { "prompt_name": "...", @@ -421,24 +421,24 @@ When `periodic: { value, unit, at? }` is provided, `startConversationWithPrompt` 3. Returns `{ sessionId }` on success, or `{ error }` if the PUT fails (session already created — error is surfaced to the caller). ```javascript -// Create a new PERIODIC conversation driven by a named prompt +// Create a new LOOP conversation driven by a named prompt await startConversationWithPrompt({ workingDir, acpServer, prompt: { name: "Daily Standup" }, - periodic: { value: 1, unit: "days", at: "09:00" }, // at is UTC HH:MM + loop: { value: 1, unit: "days", at: "09:00" }, // at is UTC HH:MM }); ``` -The `at` value in the `periodic` object must already be in **UTC** when passed to `startConversationWithPrompt`. The `PeriodicScheduleDialog` component handles the local→UTC conversion before calling the helper. +The `at` value in the `loop` object must already be in **UTC** when passed to `startConversationWithPrompt`. The `LoopScheduleDialog` component handles the local→UTC conversion before calling the helper. #### Menu-branching rules -Menus branch on `prompt.periodic` (non-null = periodic prompt): +Menus branch on `prompt.loop` (non-null = loop prompt): -- **`handleSendPromptToConversation`** (per-conversation context menu): if `prompt.periodic` is set and the session is **not a child** (`parent_session_id` is empty), opens `PeriodicScheduleDialog` then creates a NEW periodic conversation — it does not seed the existing one. Child conversations are silently skipped (the backend also 400s on periodic-for-child). -- **`handleRunBeadsPrompt`** / **`handleRunBeadsListPrompt`** (beads menus): same branching via the `onOpenPeriodicDialog` callback passed from `app.js` into `useBeadsIntegration`. -- Non-periodic prompts are completely unaffected. +- **`handleSendPromptToConversation`** (per-conversation context menu): if `prompt.loop` is set and the session is **not a child** (`parent_session_id` is empty), opens `LoopScheduleDialog` then creates a NEW loop conversation — it does not seed the existing one. Child conversations are silently skipped (the backend also 400s on loop-for-child). +- **`handleRunBeadsPrompt`** / **`handleRunBeadsListPrompt`** (beads menus): same branching via the `onOpenLoopDialog` callback passed from `app.js` into `useBeadsIntegration`. +- Non-loop prompts are completely unaffected. ## REST API @@ -680,7 +680,7 @@ The queue system supports automatic dequeuing for idle agent sessions: | Method | Location | Purpose | | ---------------------------- | ------------------- | --------------------------------------------------------------- | | `processNextQueuedMessage()` | `BackgroundSession` | Called after prompt completion, applies delay synchronously | -| `TryProcessQueuedMessage()` | `BackgroundSession` | Used for startup/periodic checking, respects delay elapsed time | +| `TryProcessQueuedMessage()` | `BackgroundSession` | Used for startup/loop checking, respects delay elapsed time | | `ProcessPendingQueues()` | `SessionManager` | Called on server startup, resumes sessions with queued items | ## Frontend Integration diff --git a/docs/devel/processors.md b/docs/devel/processors.md index f3832a5f..7117fe1e 100644 --- a/docs/devel/processors.md +++ b/docs/devel/processors.md @@ -432,8 +432,8 @@ This is consistent with the existing `@namespace:value` convention used by proce | `@mitto:acp_server` | ACP server name (e.g., `"claude-code"`) | | `@mitto:workspace_uuid` | Workspace identifier | | `@mitto:available_acp_servers` | ACP servers with workspaces for the session's folder — see below | -| `@mitto:periodic` | `"true"` if this prompt was triggered by the periodic runner, `"false"` otherwise | -| `@mitto:periodic_forced` | `"true"` if this is a manually-triggered periodic run (via "run now"), `"false"` otherwise | +| `@mitto:loop` | `"true"` if this prompt was triggered by the loop runner, `"false"` otherwise | +| `@mitto:loop_forced` | `"true"` if this is a manually-triggered loop run (via "run now"), `"false"` otherwise | ### `@mitto:available_acp_servers` detail @@ -572,7 +572,7 @@ Getters: `ProcessorCount()`, `TotalActivations()`, `LastActivationAt()`. Stats are sent to the frontend via: - `connected` WebSocket message (initial values) - `prompt_complete` message (after each prompt) -- `keepalive_ack` message (periodic refresh) +- `keepalive_ack` message (loop refresh) Fields in WebSocket payloads: `processor_count`, `processor_activations`, `processor_last_activation`. diff --git a/docs/devel/prompt-templates.md b/docs/devel/prompt-templates.md index 11dc5557..13600bf5 100644 --- a/docs/devel/prompt-templates.md +++ b/docs/devel/prompt-templates.md @@ -110,7 +110,7 @@ CEL expression always read the same field from the same struct. | `{{ .Session.ID }}` | `Session.ID` | `Session.ID` | | `{{ .Session.Name }}` | `Session.Name` | `Session.Name` | | `{{ .Session.IsChild }}` | `Session.IsChild` | `Session.IsChild` | -| `{{ .Session.IsPeriodic }}` | `Session.IsPeriodic` | `Session.IsPeriodic` | +| `{{ .Session.IsLoop }}` | `Session.IsLoop` | `Session.IsLoop` | | `{{ .Session.HasMessages }}` | `Session.HasMessages` | `Session.HasMessages` | | `{{ .Session.BeadsIssue }}` | `Session.BeadsIssue` | `Session.BeadsIssue` | | `{{ .Session.UserDataJSON }}` | — | `Session.UserDataJSON` — JSON of session user-data attributes | @@ -130,12 +130,12 @@ CEL expression always read the same field from the same struct. | `{{ .Children.MCP }}` | — | `Children.MCP` — `[]config.ChildInfo` for MCP-origin children only | | `{{ .ACP.Available }}` | — | `ACP.Available` — `[]config.ACPServerInfo` for workspace ACP servers | | `{{ .Args.NAME }}` | `Args["NAME"]` (new) | `Args["NAME"]` (new) | -| `{{ .Iteration.Number }}` | — | `Iteration.Number` — 0-based index of the current periodic run; 0 for non-periodic | -| `{{ .Iteration.Max }}` | — | `Iteration.Max` — configured max runs (0 = unlimited); 0 for non-periodic | -| `{{ .Iteration.IsPeriodic }}` | — | `Iteration.IsPeriodic` — `true` when triggered by the periodic runner | +| `{{ .Iteration.Number }}` | — | `Iteration.Number` — 0-based index of the current loop run; 0 for non-loop | +| `{{ .Iteration.Max }}` | — | `Iteration.Max` — configured max runs (0 = unlimited); 0 for non-loop | +| `{{ .Iteration.IsLoop }}` | — | `Iteration.IsLoop` — `true` when triggered by the loop runner | | `{{ .Iteration.IsFirst }}` | — | `Iteration.IsFirst` — `true` when `Number == 0` | | `{{ .Iteration.IsLast }}` | — | `Iteration.IsLast` — `true` when `Max > 0 && Number == Max-1` | -| `{{ .Iteration.IsUninterrupted }}` | — | `Iteration.IsUninterrupted` — `true` only on a scheduled, non-forced periodic run directly following another such run (no user interjection / forced run / FreshContext; same process lifetime) | +| `{{ .Iteration.IsUninterrupted }}` | — | `Iteration.IsUninterrupted` — `true` only on a scheduled, non-forced loop run directly following another such run (no user interjection / forced run / FreshContext; same process lifetime) | `Args` is populated from `meta.Arguments` at send time. At menu time (`enabledWhen` evaluation), `Args` is `nil`. Template rendering runs at **send time only**, so `Args` is @@ -247,8 +247,8 @@ no template syntax. This check is identical to the `@mitto:` fast-path in `Subst | `@mitto:workspace_uuid` | `{{ .Workspace.UUID }}` | | | `@mitto:beads_issue` | `{{ .Session.BeadsIssue }}` | | | `@mitto:mcp_children_count` | `{{ .Children.MCPCount }}` | int, not string | -| `@mitto:periodic` | `{{ .Session.IsPeriodic }}` | bool, not `"true"`/`"false"` string | -| `@mitto:periodic_forced` | `{{ .Session.IsPeriodicForced }}` | bool, not `"true"`/`"false"` string. Field added to `SessionContext` (mitto-m7sb.3); fully wired into the CEL env (`Session.IsPeriodicForced`). | +| `@mitto:loop` | `{{ .Session.IsLoop }}` | bool, not `"true"`/`"false"` string | +| `@mitto:loop_forced` | `{{ .Session.IsLoopForced }}` | bool, not `"true"`/`"false"` string. Field added to `SessionContext` (mitto-m7sb.3); fully wired into the CEL env (`Session.IsLoopForced`). | | `@mitto:available_acp_servers` | `{{ .ACP.AvailableText }}` | `config.FormatACPServers(ctx.ACP.Available)`; format: `"name [tags] (current), name2"` | | `@mitto:children` | `{{ .Children.AllText }}` | `config.FormatChildren(ctx.Children.All)`; format: `"id (name) [acp], id2"` | | `@mitto:mcp_children` | `{{ .Children.MCPText }}` | `config.FormatChildren(ctx.Children.MCP)`; MCP-origin only | @@ -326,7 +326,7 @@ are caught at load time by `ParsePromptFile`. ### 10.8 Title-generation path must NOT render templates -`BackgroundSession.TriggerTitleGenerationFromPeriodic` (in `bgsession_title.go`) resolves +`BackgroundSession.TriggerTitleGenerationFromLoop` (in `bgsession_title.go`) resolves a prompt name and feeds the result to an auxiliary AI session for title generation. It does NOT call `PromptWithMeta`, so it is **outside the template-rendering chokepoint**. The raw prompt text (with un-rendered `{{ ... }}` tokens) is sent to the auxiliary title generator. This is @@ -340,12 +340,12 @@ At menu time, `ToolsContext.Available == false` causes `Tools.HasPattern` to ret (template `cond` evaluation), the real tool list is always available (warm cache). No asymmetry issue for the `cond` function. -### 10.10 Periodic runner IS covered +### 10.10 Loop runner IS covered -`internal/web/periodic_runner.go` dispatches prompts via `bs.PromptWithMeta(promptText, meta)` -with `meta.SenderID = "periodic-runner"` (line ~1149). Because it goes through `PromptWithMeta`, +`internal/web/loop_runner.go` dispatches prompts via `bs.PromptWithMeta(promptText, meta)` +with `meta.SenderID = "loop-runner"` (line ~1149). Because it goes through `PromptWithMeta`, it passes through `resolveAndSubstitute` and therefore through template rendering. No special -periodic-runner handling is needed. +loop-runner handling is needed. --- @@ -365,7 +365,7 @@ periodic-runner handling is needed. | Bead | Scope | Key files | |---|---|---| | **mitto-m7sb.2** | Core renderer: `renderTemplateBody`, insert in `resolveAndSubstitute`, `missingkey=zero`, fast-path, `text/template.FuncMap` skeleton | `internal/conversation/prompt_dispatcher.go`, new `internal/config/prompt_template.go` | -| **mitto-m7sb.3** | Context builder: populate `PromptEnabledContext` at send time; add `Args map[string]string` field; add `IsPeriodicForced` to `SessionContext` | `internal/config/cel_context.go`, `internal/conversation/prompt_dispatcher.go` | +| **mitto-m7sb.3** | Context builder: populate `PromptEnabledContext` at send time; add `Args map[string]string` field; add `IsLoopForced` to `SessionContext` | `internal/config/cel_context.go`, `internal/conversation/prompt_dispatcher.go` | | **mitto-m7sb.4** | Load-time validation: `ParsePromptFile` + MCP `mitto_prompt_update` parse-and-validate; `cond` literal pre-compile | `internal/config/prompts.go`, `internal/web/handlers/` (prompt update handler) | | **mitto-m7sb.5** | CEL env extension: add `args` map variable to `NewCELEvaluator` and `buildActivation` | `internal/config/cel_evaluator.go` | | **mitto-m7sb.6** | FuncMap full impl: `arg`, `default`, `fileExists`, `dirExists`, `commandExists`, `cond`/`when`; extract shared pure-Go helper package | `internal/config/cel_evaluator.go` (extract), new `internal/config/templatefuncs.go` | @@ -374,17 +374,17 @@ periodic-runner handling is needed. --- -## 13. Label-as-state-machine pattern for periodic beads prompts +## 13. Label-as-state-machine pattern for loop beads prompts This section documents a higher-level **design pattern** built on top of the template context described in §4 and §10.1: using `bd` labels as a durable, ordered state -machine that a periodic conversation advances one stage per run. It is the pattern +machine that a loop conversation advances one stage per run. It is the pattern behind the shipped `Iterate fixing bug`, `Iterate fixing bugs`, and `Iterate implementing features` builtin prompts (§13.9). ### 13.1 Concept -A **periodic conversation** advances a single beads issue through an ordered, +A **loop conversation** advances a single beads issue through an ordered, finite set of states encoded as `bd` **labels** (e.g. `researched` → `reproduced` → `fixed`). Each scheduled run performs the same four-step cycle: @@ -394,7 +394,7 @@ finite set of states encoded as `bd` **labels** (e.g. `researched` → `reproduc per run). 4. **Advance** the label (add the label for the stage just completed), then either stop the turn (the next scheduled run picks up the next stage) or, - at the **terminal** label, **self-terminate** the periodic schedule (§13.5). + at the **terminal** label, **self-terminate** the loop schedule (§13.5). Because the state lives in the tracker (not in conversation memory), the loop survives conversation restarts, crashes, and even a full context reset — @@ -433,10 +433,10 @@ reports where in the schedule the run sits: | Field | Meaning | |---|---| -| `{{ .Session.BeadsIssue }}` | The conversation's **linked** beads issue (set via `beads_issue` at creation, or `mitto_conversation_update`). Preferred — durable across every periodic re-fire regardless of arguments. | +| `{{ .Session.BeadsIssue }}` | The conversation's **linked** beads issue (set via `beads_issue` at creation, or `mitto_conversation_update`). Preferred — durable across every loop re-fire regardless of arguments. | | `{{ .Args.IssueID }}` | An explicit argument (e.g. auto-filled by the `beadsIssues` menu on the first send). Used when there is no linked issue yet, or as a one-shot override. | | `{{ .Iteration.IsFirst }}` | `true` on the very first run (`Iteration.Number == 0`) — no prior `bd comment` history to review yet. | -| `{{ .Iteration.IsUninterrupted }}` | `true` only on a scheduled, non-forced periodic run directly following another such run — i.e. genuine machine-driven continuation, not a user-resumed or force-triggered run. | +| `{{ .Iteration.IsUninterrupted }}` | `true` only on a scheduled, non-forced loop run directly following another such run — i.e. genuine machine-driven continuation, not a user-resumed or force-triggered run. | | `{{ .Iteration.IsLast }}` | `true` on the final scheduled run before `maxIterations` is hit — a hook to wrap up gracefully instead of starting a stage that won't finish. | The standard **target ladder** (also used by the context-adaptive prompts in @@ -449,15 +449,15 @@ prefers the durable linked issue, falling back to the argument: {{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} ``` -### 13.4 Auto-periodic frontmatter block +### 13.4 Auto-loop frontmatter block -A label-as-state-machine prompt declares a `periodic:` block so each run +A label-as-state-machine prompt declares a `loop:` block so each run re-fires automatically once the agent stops responding — see -[docs/config/prompts.md § Periodic Prompts](../config/prompts.md#periodic-prompts) +[docs/config/prompts.md § Loop Prompts](../config/prompts.md#loop-prompts) for the full field reference: ```yaml -periodic: +loop: mode: always trigger: onCompletion # fire the next run after the agent stops, not on a fixed clock delay: 30 # seconds to wait after the agent finishes @@ -468,27 +468,27 @@ periodic: **This block behaves differently depending on how the conversation was started — this distinction matters for orchestrator authors:** -| How the prompt is dispatched | Does `periodic:` auto-apply? | +| How the prompt is dispatched | Does `loop:` auto-apply? | |---|---| -| Selected directly in the UI (ChatInput dropup, Beads context menu, periodic selector) | **Yes.** The frontend reads the prompt's `periodic:` block and configures the conversation accordingly (see [Behavior](../config/prompts.md#behavior)). | -| Spawned programmatically via `mitto_conversation_new(prompt_name: "...")` (e.g. from an orchestrator prompt) | **No.** The prompt's own `periodic:` frontmatter is **not** read or applied. The caller must pass explicit `periodic_prompt`, `periodic_trigger`, `periodic_completion_delay_seconds`, `periodic_max_iterations`, and `periodic_max_duration_seconds` arguments to `mitto_conversation_new` to reproduce the same schedule. | +| Selected directly in the UI (ChatInput dropup, Beads context menu, loop selector) | **Yes.** The frontend reads the prompt's `loop:` block and configures the conversation accordingly (see [Behavior](../config/prompts.md#behavior)). | +| Spawned programmatically via `mitto_conversation_new(prompt_name: "...")` (e.g. from an orchestrator prompt) | **No.** The prompt's own `loop:` frontmatter is **not** read or applied. The caller must pass explicit `loop_prompt`, `loop_trigger`, `loop_completion_delay_seconds`, `loop_max_iterations`, and `loop_max_duration_seconds` arguments to `mitto_conversation_new` to reproduce the same schedule. | The shipped list-level orchestrators (`Iterate fixing bugs`, `Iterate implementing features`) work around this by fetching the per-issue driver's body once via `mitto_prompt_get`, then passing that body as **both** -`initial_prompt` and `periodic_prompt`, with the numeric periodic fields -copied from the driver's own `periodic:` block (see §13.9). +`initial_prompt` and `loop_prompt`, with the numeric loop fields +copied from the driver's own `loop:` block (see §13.9). ### 13.5 Self-termination At the terminal label — the stage after which there is no further work — -the prompt turns off its own periodic schedule instead of continuing to fire: +the prompt turns off its own loop schedule instead of continuing to fire: ``` -mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) +mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` -This flips the conversation back into a regular (non-periodic) one; it is not +This flips the conversation back into a regular (non-loop) one; it is not deleted or archived, and can be re-enabled later (e.g. after a human clears a `needs-human` label and wants to resume — §13.7). @@ -539,7 +539,7 @@ drops out of scheduling and leaves a clear trail for a human to pick up: 3. **Stop the loop** so it does not keep re-firing on the same blocker: ``` - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` **To resume:** clear the flag and the defer date — @@ -549,7 +549,7 @@ bd update <id> --remove-label needs-human --defer "" ``` — which returns the issue to `bd ready`, and re-running the prompt (or -re-enabling its periodic schedule) resumes at the **un-advanced** stage: no +re-enabling its loop schedule) resumes at the **un-advanced** stage: no progress is lost, because the state is durable in the labels, not in conversation memory. @@ -562,14 +562,14 @@ your workflow: ```yaml name: "Iterate my workflow" menus: beadsIssues, conversation -icon: periodic +icon: loop parameters: - name: IssueID type: beadsId required: false description: The beads issue ID to act on enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && Item.Status != "closed"' -periodic: +loop: mode: always trigger: onCompletion delay: 30 @@ -603,7 +603,7 @@ prompt: | - `done` present → **terminal state reached.** Self-terminate: - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) mitto_ui_notify(self_id: "{{ .Session.ID }}", title: "Iterate my workflow — done", message: "<summary>", style: "success") {{- end }} @@ -613,7 +613,7 @@ prompt: | bd update {{ if $target }}{{ $target }}{{ else }}<target-id>{{ end }} --add-label needs-human --defer +1d bd comment {{ if $target }}{{ $target }}{{ else }}<target-id>{{ end }} "Blocked at <stage>. What I tried: <summary>. What I need: <the ONE concrete question>. How to resume: bd update <id> --remove-label needs-human --defer '' , then re-run." - mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", periodic_enabled: false) + mitto_conversation_update(self_id: "{{ .Session.ID }}", conversation_id: "self", loop_enabled: false) ``` ### 13.9 Worked example: the shipped bug-fix state machine @@ -648,8 +648,8 @@ child, wait for it, then move to the next: `planned` → `implemented` → `tested` → `verified` feature state machine, one feature at a time. -Both orchestrators are themselves **non-periodic, one-shot** runs (they loop +Both orchestrators are themselves **non-loop, one-shot** runs (they loop internally via `mitto_children_tasks_wait`); the *children* they spawn are the -periodic ones, and both fetch the child driver's body via `mitto_prompt_get` -and pass it as both `initial_prompt` and `periodic_prompt` — a direct +loop ones, and both fetch the child driver's body via `mitto_prompt_get` +and pass it as both `initial_prompt` and `loop_prompt` — a direct consequence of the `mitto_conversation_new` behavior documented in §13.4. diff --git a/docs/devel/prompts.md b/docs/devel/prompts.md index 7ee4a588..752cea38 100644 --- a/docs/devel/prompts.md +++ b/docs/devel/prompts.md @@ -4,7 +4,7 @@ This document covers how prompts are surfaced across the different UI menus (ChatInput drop-up, per-conversation context menu, Beads list menus) and how selecting one either **sends into an existing conversation** or **creates a new conversation**. For the user-facing front-matter reference (all fields, `menus`, -`enabledWhen`, `requires`, `periodic`, parameters), see +`enabledWhen`, `requires`, `loop`, parameters), see [docs/config/prompts.md](../config/prompts.md). For the underlying queue mechanics, see [Message Queue](message-queue.md). @@ -46,15 +46,15 @@ Defined on both `PromptFile` and `WebPrompt` in `internal/config/prompts.go` / | `menus` value | UI surface | Start behavior | | ----------------- | ------------------------------------------------------------ | ----------------------------------------------- | | `prompts` | ChatInput drop-up (default) | sends into the **active** conversation | -| `promptsPeriodic` | periodic prompt selector | configures a periodic schedule | +| `promptsLoop` | loop prompt selector | configures a loop schedule | | `conversation` | per-conversation context menu (sidebar row + chat header ⋯) | **sends into the clicked existing conversation** | | `beadsIssues` | per-issue right-click **New ›** submenu in the Beads list | **creates a new conversation** (with `ISSUE_ID`) | | `beadsList` | list-level prompts button in the Beads list footer | **creates a new conversation** (no per-issue arg)| **Exclusion syntax (`!menu`):** A `!`-prefixed token explicitly opts the prompt *out* of a menu, taking precedence over any union or implicit inclusion rule. -For example, `menus: prompts, !promptsPeriodic` shows the prompt in the ChatInput -dropup but hides it from the periodic prompt selector (which otherwise includes all +For example, `menus: prompts, !promptsLoop` shows the prompt in the ChatInput +dropup but hides it from the loop prompt selector (which otherwise includes all `prompts`-tagged prompts via a union rule). Exclusion tokens are parsed and applied on the frontend (`promptMenuExcludes` / `promptMenuIncludes` in `web/static/utils/prompts.js`); the backend ignores them during validation. @@ -136,7 +136,7 @@ Flow: per-issue **New ›** click → `handleRunBeadsPrompt(prompt, issue)` (or `handleRunBeadsListPrompt`) in `web/static/hooks/useBeadsIntegration.js` → `startConversationWithPrompt({ ... })`. -`startConversationWithPrompt` (non-periodic) calls `newSession` with +`startConversationWithPrompt` (non-loop) calls `newSession` with `initialPromptName` + `arguments`: ``` @@ -206,7 +206,7 @@ can serve **both** the per-issue `beadsIssues` menu and the generic {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }} {{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}} ``` - Priority: `.Session.BeadsIssue` first (durable across periodic re-runs), + Priority: `.Session.BeadsIssue` first (durable across loop re-runs), then `.Args.IssueID` (auto-filled by the Beads per-issue menu), then empty (mode 3 — no linked issue). @@ -219,14 +219,14 @@ can serve **both** the per-issue `beadsIssues` menu and the generic > The body MUST resolve the target from `$target` (or `.Session.BeadsIssue` / > `.Args.IssueID` directly), never from `.Item.*`. -This same menu-time/send-time split underpins periodic, multi-run prompts that +This same menu-time/send-time split underpins loop, multi-run prompts that advance a beads issue through a sequence of `bd` labels one stage per run — -see [Label-as-state-machine pattern for periodic beads prompts](prompt-templates.md#13-label-as-state-machine-pattern-for-periodic-beads-prompts). +see [Label-as-state-machine pattern for loop beads prompts](prompt-templates.md#13-label-as-state-machine-pattern-for-loop-beads-prompts). For the full YAML header recipe, ladder, and gating examples see [Context-adaptive prompts (three modes)](../config/prompts.md#context-adaptive-prompts-three-modes) in the user-facing config reference. The six builtin exemplars are -`beads-issue-investigate`, `beads-issue-discuss`, `beads-issue-status`, +`beads-issue-investigate`, `beads-issue-assess`, `beads-issue-status`, `beads-issue-resolved`, `beads-issue-work`, and `beads-followup-work`; their render correctness is guarded by the `*ThreeModeTargetResolution` tests in `internal/config/prompt_template_test.go`. @@ -294,31 +294,31 @@ from the "missing" list; it never reads or displays cached values. - [docs/config/prompts.md](../config/prompts.md) — `cache` block schema, field reference, validation rules. -## 5. The periodic overlay +## 5. The loop overlay -Any prompt in any of these menus may additionally declare `periodic:`. When +Any prompt in any of these menus may additionally declare `loop:`. When present, the start handlers branch instead of doing a one-shot seed: -- **Conversation menu** — `decidePeriodicAction` chooses: - - `new-periodic` — no session yet → open the schedule dialog → create a NEW - periodic conversation. - - `make-periodic` — a regular conversation → configure it as periodic + fire +- **Conversation menu** — `decideLoopAction` chooses: + - `new-loop` — no session yet → open the schedule dialog → create a NEW + loop conversation. + - `make-loop` — a regular conversation → configure it as loop + fire the first run. - - `one-shot` — already periodic, or a child conversation → enqueue once + - `one-shot` — already loop, or a child conversation → enqueue once without changing config (the backend also returns HTTP 400 for - periodic-on-child). -- **Beads menus** — `onOpenPeriodicDialog` → `startConversationWithPrompt({ - periodic })`, which creates the session **without** a queue seed and instead - `PUT`s `/api/sessions/{id}/periodic` with the `prompt_name` + frequency. + loop-on-child). +- **Beads menus** — `onOpenLoopDialog` → `startConversationWithPrompt({ + loop })`, which creates the session **without** a queue seed and instead + `PUT`s `/api/sessions/{id}/loop` with the `prompt_name` + frequency. -Periodic conversations can only be **top-level** (not children). The `at` field +Loop conversations can only be **top-level** (not children). The `at` field (HH:MM UTC) is only sent for `unit: days`. ## 6. Key files | Layer | File | Responsibility | | -------- | ------------------------------------------------- | --------------------------------------------------------------------- | -| Model | `internal/config/prompts.go`, `config.go` | `PromptFile`/`WebPrompt`, `Menus`, `EnabledWhen`, `Periodic`, params | +| Model | `internal/config/prompts.go`, `config.go` | `PromptFile`/`WebPrompt`, `Menus`, `EnabledWhen`, `Loop`, params | | Backend | `internal/web/session_api.go` | `handleWorkspacePromptsGET`, `seedQueueWithNamedPrompt`, contexts | | Backend | `internal/web/queue_api.go` | `handleAddToQueue` (stores `prompt_name`/`arguments`) | | Backend | `internal/web/background_session.go` | dispatch-time `promptResolver` + `SubstituteArguments` | @@ -332,14 +332,14 @@ Periodic conversations can only be **top-level** (not children). The `at` field | Frontend | `web/static/hooks/useBeadsIntegration.js` | `fetchBeads*PromptsForWorkspace`, `handleRunBeads*Prompt` | | Frontend | `web/static/hooks/useConversationSeeding.js` | `seedConversationWithPrompt`, `startConversationWithPrompt` | | Frontend | `web/static/hooks/useConversationMenu.js` | per-conversation context menu assembly | -| Frontend | `web/static/app.js` | `handleSendPromptToConversation` (periodic branching) | +| Frontend | `web/static/app.js` | `handleSendPromptToConversation` (loop branching) | | Builtin | `config/prompts/builtin/beads-issue-*.prompt.yaml` | Five context-adaptive exemplar prompts (three-mode pattern) | | Test | `internal/config/prompt_template_test.go` | `*ThreeModeTargetResolution` render tests + `TestBuiltinPrompts_NoDeprecatedMittoVars` guard | ## See Also - [docs/config/prompts.md](../config/prompts.md) — user-facing front-matter - reference (`menus`, `enabledWhen`, `requires`, `periodic`, parameters) + reference (`menus`, `enabledWhen`, `requires`, `loop`, parameters) - [Message Queue](message-queue.md) — queue storage, named-prompt dispatch, REST API - [Message Processing Pipeline](processors.md) — `@mitto:` variable substitution in processors diff --git a/docs/devel/rest-api-conventions.md b/docs/devel/rest-api-conventions.md index ab8c6a50..5a5a5be1 100644 --- a/docs/devel/rest-api-conventions.md +++ b/docs/devel/rest-api-conventions.md @@ -47,7 +47,7 @@ Some operations are genuinely non-CRUD and do not map cleanly to a resource: | Path pattern | Reason acceptable | | ------------------------------------------- | ---------------------------------------------- | -| `POST .../periodic/run-now` | One-shot trigger, not a resource mutation | +| `POST .../loop/run-now` | One-shot trigger, not a resource mutation | | `POST .../queue/{id}/move` | Reorder within queue, no natural PATCH target | | `POST .../sessions/{id}/prune` | Destructive bulk operation on opaque internals | | `POST /api/agents/scan` | Long-running discovery action | @@ -161,8 +161,8 @@ Legend: **migrate** = path/method change needed · **keep** = stays as-is · **e | `/api/sessions/{id}/queue/{msgId}` | GET, DELETE | `/api/sessions/{id}/queue/{msgId}` | GET, DELETE | keep | Correct already | | `/api/sessions/{id}/queue/{msgId}/move` | POST | `/api/sessions/{id}/queue/{msgId}/move` | POST | keep | Non-CRUD action; acceptable | | `/api/sessions/{id}/user-data` | GET, PUT | `/api/sessions/{id}/user-data` | GET, PUT | keep | Correct already | -| `/api/sessions/{id}/periodic` | GET, PUT, PATCH, DELETE | `/api/sessions/{id}/periodic` | GET, PUT, PATCH, DELETE | keep | Correct already | -| `/api/sessions/{id}/periodic/run-now` | POST | `/api/sessions/{id}/periodic/run-now` | POST | keep | Non-CRUD action; acceptable | +| `/api/sessions/{id}/loop` | GET, PUT, PATCH, DELETE | `/api/sessions/{id}/loop` | GET, PUT, PATCH, DELETE | keep | Correct already | +| `/api/sessions/{id}/loop/run-now` | POST | `/api/sessions/{id}/loop/run-now` | POST | keep | Non-CRUD action; acceptable | | `/api/sessions/{id}/callback` | GET, POST, DELETE | `/api/sessions/{id}/callback` | GET, POST, DELETE | keep | Correct already | | `/api/sessions/{id}/settings` | GET, PATCH | `/api/sessions/{id}/settings` | GET, PATCH | keep | Correct already | | `/api/sessions/{id}/prune` | POST | `/api/sessions/{id}/prune` | POST | keep | Non-CRUD bulk action; acceptable | diff --git a/docs/devel/session-management.md b/docs/devel/session-management.md index ff779a45..a1528571 100644 --- a/docs/devel/session-management.md +++ b/docs/devel/session-management.md @@ -41,7 +41,7 @@ Events are persisted **immediately** when received from ACP, preserving the sequ - **Consistent seq numbers**: Streaming and persisted events have identical `seq` values - **Crash resilience**: No data loss window (no buffering) -- **Simpler architecture**: No periodic persistence timers or buffer management +- **Simpler architecture**: No loop persistence timers or buffer management ### Event Flow @@ -159,7 +159,7 @@ recorder.RecordUserPrompt(message, session.WithMeta("source", "queue")) // Merge a map. recorder.RecordUserPromptComplete(msg, imgs, files, pid, pname, argC, - session.WithMetaMap(map[string]any{"run_id": runID, "periodic": true})) + session.WithMetaMap(map[string]any{"run_id": runID, "loop": true})) ``` `WithMeta` and `WithMetaMap` accumulate: multiple calls to either option on the same event merge their entries. Existing callers with no options compile and behave unchanged. diff --git a/docs/devel/web-interface.md b/docs/devel/web-interface.md index 8c989d42..b921b45c 100644 --- a/docs/devel/web-interface.md +++ b/docs/devel/web-interface.md @@ -87,8 +87,8 @@ These endpoints do **not** require a session cookie. | `/api/sessions/{id}/files/from-path` | POST | Attach file by local path (native macOS app — external-stable) | | `/api/sessions/{id}/queue` | GET, POST | List pending prompts in queue (GET); enqueue a prompt (POST) | | `/api/sessions/{id}/queue/{msgId}` | GET, DELETE | Get or cancel a specific queued prompt | -| `/api/sessions/{id}/periodic` | GET, PUT, DELETE | Get or set periodic execution configuration | -| `/api/sessions/{id}/periodic/{subPath}` | varies | Periodic sub-resource actions (e.g. trigger-now) | +| `/api/sessions/{id}/loop` | GET, PUT, DELETE | Get or set loop execution configuration | +| `/api/sessions/{id}/loop/{subPath}` | varies | Loop sub-resource actions (e.g. trigger-now) | --- @@ -196,7 +196,7 @@ The `/api/sessions` endpoint returns an array of session objects with the follow | `status` | string | Session status (active, idle, error) | | `archived` | boolean | Whether session is archived | | `parent_session_id` | string | Parent session ID (if created via `mitto_conversation_new` MCP tool) | -| `periodic_enabled` | boolean | Whether periodic execution is configured | +| `loop_enabled` | boolean | Whether loop execution is configured | #### Parent-Child Relationships @@ -357,14 +357,14 @@ App The sidebar renders all conversations as a single hierarchical daisyUI `menu` tree (`SessionList` → `SessionItem`), replacing the former three tabs -(Conversations / Periodic / Archived) and the group-by toggle. +(Conversations / Loop / Archived) and the group-by toggle. - **Folders** group conversations by working directory (resolved to the root parent for nested children). Child conversations nest under their parent. - Each folder has an **Archived** subgroup (collapsed by default) and a static **Tasks** node (opens the beads view for that folder). - A static **Dashboard** node clears the active conversation. -- **Category filter** (sidebar header dropdown): show/hide Regular, Periodic, +- **Category filter** (sidebar header dropdown): show/hide Regular, Loop, Archived, and Tasks. Persisted per-device in `sessionStorage`. - Each row exposes an always-visible **three-dot (ellipsis) menu** that opens the shared `ContextMenu` (rename, pin, archive, delete, prompt groups…). @@ -376,8 +376,8 @@ tree (`SessionList` → `SessionItem`), replacing the former three tabs visual order, skipping static nodes and respecting the category filter; the target's folder/archived/parent auto-expands and scrolls into view. -> Conversations are categorized by `getFilterTabForSession` (regular / periodic / -> archived). Periodic prompts are configured per-conversation (ChatInput / +> Conversations are categorized by `getFilterTabForSession` (regular / loop / +> archived). Loop prompts are configured per-conversation (ChatInput / > SessionPanel), not via a creation tab. Startup restores the single last-active > conversation regardless of category (falling back to the most recent overall). diff --git a/docs/devel/websockets/README.md b/docs/devel/websockets/README.md index cd53ac45..91ff4c8d 100644 --- a/docs/devel/websockets/README.md +++ b/docs/devel/websockets/README.md @@ -147,8 +147,8 @@ graph TB | `session_archive_pending` | Session archiving initiated | | `session_streaming` | Session streaming state changed | | `session_settings_updated` | Session advanced settings changed | -| `periodic_updated` | Periodic prompt config changed | -| `periodic_started` | Periodic prompt was delivered | +| `loop_updated` | Loop prompt config changed | +| `loop_started` | Loop prompt was delivered | | `acp_started` | ACP process started for session | | `acp_start_failed` | ACP process failed to start | | `hook_failed` | Lifecycle hook failed | diff --git a/docs/devel/websockets/protocol-spec.md b/docs/devel/websockets/protocol-spec.md index 5ca87572..36f1f60f 100644 --- a/docs/devel/websockets/protocol-spec.md +++ b/docs/devel/websockets/protocol-spec.md @@ -845,28 +845,28 @@ stateDiagram-v2 } ``` -### Periodic Prompt Events +### Loop Prompt Events -#### `periodic_updated` +#### `loop_updated` ```json { - "type": "periodic_updated", + "type": "loop_updated", "data": { "session_id": "...", "session_name": "Daily Report", - "periodic_enabled": true, - "periodic_frequency": "daily", + "loop_enabled": true, + "loop_frequency": "daily", "next_scheduled_at": "2026-02-01T09:00:00Z" } } ``` -#### `periodic_started` +#### `loop_started` ```json { - "type": "periodic_started", + "type": "loop_started", "data": { "session_id": "...", "session_name": "Daily Report" } } ``` diff --git a/docs/devel/websockets/sequence-numbers.md b/docs/devel/websockets/sequence-numbers.md index b2ee35cc..4357302b 100644 --- a/docs/devel/websockets/sequence-numbers.md +++ b/docs/devel/websockets/sequence-numbers.md @@ -18,7 +18,7 @@ Events are persisted **immediately** when received from ACP, not buffered for la - **Consistent seq numbers**: Streaming and persisted events have identical `seq` values - **Crash resilience**: No data loss window (events are on disk immediately) -- **Simpler architecture**: No periodic persistence timers or buffer management +- **Simpler architecture**: No loop persistence timers or buffer management - **Correct reconnection**: Clients can sync from persisted events with matching seq values ## Sequence Number Assignment diff --git a/docs/devel/websockets/synchronization.md b/docs/devel/websockets/synchronization.md index d864f71a..d5e84396 100644 --- a/docs/devel/websockets/synchronization.md +++ b/docs/devel/websockets/synchronization.md @@ -265,7 +265,7 @@ On mobile devices, WebSocket connections can become "zombies" - they appear open ### The Solution: Client-Side Keepalive with Sequence Sync -The frontend sends periodic `keepalive` messages that serve two purposes: +The frontend sends loop `keepalive` messages that serve two purposes: 1. **Detect zombie connections** - Force reconnect if keepalives aren't acknowledged 2. **Detect out-of-sync state** - Compare sequence numbers to catch missed messages @@ -386,7 +386,7 @@ Long-running measurement of the recovery rate shows a stable baseline of **~133/ Three legitimate drivers explain the volume: -1. **Long-lived sessions** naturally accumulate more reconnect cycles. A long-lived "Logs Analyzer" parent session, for example, contributed 54 of the recoveries in a single measurement window. Periodic conversations (see [Periodic Conversations](../../../CLAUDE.md)) are long-lived by design and behave the same way. +1. **Long-lived sessions** naturally accumulate more reconnect cycles. A long-lived "Logs Analyzer" parent session, for example, contributed 54 of the recoveries in a single measurement window. Loop conversations (see [Loop Conversations](../../../CLAUDE.md)) are long-lived by design and behave the same way. 2. **macOS app hide/resume cycles** suspend the WKWebView per-session WebSocket while the app is hidden, so each `App became active` event finds dead sockets that must be re-established. See sibling issue `mitto-1o2` for the WKWebView timer-suspension details. 3. **Idle per-session sockets may be released server-side or by the lazy-connect background sweep** (see `BACKGROUND_DISCONNECT_GRACE_MS` in `useWebSocket.js`). When the user switches back, the session-activation health check above re-establishes the connection on demand. diff --git a/internal/acpproc/acp_process_gc.go b/internal/acpproc/acp_process_gc.go index c6682314..08ad28b7 100644 --- a/internal/acpproc/acp_process_gc.go +++ b/internal/acpproc/acp_process_gc.go @@ -30,18 +30,18 @@ type GCConfig struct { // than user-created sessions and should be reclaimed faster to reduce memory pressure. // Default: 5m (same as IdleTimeout). Set shorter than IdleTimeout for faster child GC. ChildIdleTimeout time.Duration - // PeriodicSuspendThreshold is the minimum time until the next periodic prompt - // before a periodic session is eligible for suspension. Periodic sessions whose + // LoopSuspendThreshold is the minimum time until the next loop prompt + // before a loop session is eligible for suspension. Loop sessions whose // next run is farther away than this threshold will have their ACP connection // closed even if they have active WebSocket observers. The session is NOT archived — // it remains visible in the sidebar and resumes transparently via ensure_resumed - // when the user focuses it, or via the PeriodicRunner when the prompt is due. - // This saves significant memory by stopping MCP server processes for idle periodic - // conversations. Set to 0 to disable periodic suspension (default: 30m). - PeriodicSuspendThreshold time.Duration - // PeriodicSuspendGracePeriod is a generous post-activity buffer that protects a - // periodic session from being suspended too soon after it finishes a turn. A - // periodic session is NOT suspended while its most recent activity — the agent + // when the user focuses it, or via the LoopRunner when the prompt is due. + // This saves significant memory by stopping MCP server processes for idle loop + // conversations. Set to 0 to disable loop suspension (default: 30m). + LoopSuspendThreshold time.Duration + // LoopSuspendGracePeriod is a generous post-activity buffer that protects a + // loop session from being suspended too soon after it finishes a turn. A + // loop session is NOT suspended while its most recent activity — the agent // completing a response (LastResponseCompleteAt) or a prompt/observer change // (LastActivityAt) — is within this window. This prevents aggressively reclaiming // a conversation that just ended a turn and may be about to continue (a queued @@ -49,11 +49,11 @@ type GCConfig struct { // from LastActivityAt-only checks because LastActivityAt is set at prompt START and // is therefore stale after a long-running task. Set to a negative value to disable // the grace window (default: 10m). - PeriodicSuspendGracePeriod time.Duration + LoopSuspendGracePeriod time.Duration // MemoryRecycleThreshold is the RSS threshold in bytes (summed over the agent // process tree) above which an IDLE shared ACP process is recycled (stopped) to // reclaim memory. Recycling only happens when the process has no prompting - // session, no in-flight RPCs, empty queues, and no periodic prompt due soon — + // session, no in-flight RPCs, empty queues, and no loop prompt due soon — // affected conversations resume transparently on next focus. 0 means disabled // (opt-in; no default is applied). MemoryRecycleThreshold uint64 @@ -69,15 +69,15 @@ type SessionCloseFunc func(sessionID string) // defaultGCConfig returns a GCConfig with sensible defaults. func defaultGCConfig() GCConfig { return GCConfig{ - Interval: 30 * time.Second, - GracePeriod: 60 * time.Second, - ObserverGracePeriod: 60 * time.Second, - IdleTimeout: 5 * time.Minute, - ChildIdleTimeout: 5 * time.Minute, - MaxClosuresPerCycle: 3, - AuxIdleTimeout: 10 * time.Minute, - PeriodicSuspendThreshold: 30 * time.Minute, - PeriodicSuspendGracePeriod: 10 * time.Minute, + Interval: 30 * time.Second, + GracePeriod: 60 * time.Second, + ObserverGracePeriod: 60 * time.Second, + IdleTimeout: 5 * time.Minute, + ChildIdleTimeout: 5 * time.Minute, + MaxClosuresPerCycle: 3, + AuxIdleTimeout: 10 * time.Minute, + LoopSuspendThreshold: 30 * time.Minute, + LoopSuspendGracePeriod: 10 * time.Minute, } } @@ -110,19 +110,19 @@ func (m *ACPProcessManager) StartGC(config GCConfig, query SessionQueryFunc, clo if config.ChildIdleTimeout <= 0 { config.ChildIdleTimeout = defaultGCConfig().ChildIdleTimeout } - // PeriodicSuspendThreshold: 0 means "not set" → use default. + // LoopSuspendThreshold: 0 means "not set" → use default. // Negative means "explicitly disabled" → set to 0 so RunGCOnce skips the heuristic. - if config.PeriodicSuspendThreshold == 0 { - config.PeriodicSuspendThreshold = defaultGCConfig().PeriodicSuspendThreshold - } else if config.PeriodicSuspendThreshold < 0 { - config.PeriodicSuspendThreshold = 0 + if config.LoopSuspendThreshold == 0 { + config.LoopSuspendThreshold = defaultGCConfig().LoopSuspendThreshold + } else if config.LoopSuspendThreshold < 0 { + config.LoopSuspendThreshold = 0 } - // PeriodicSuspendGracePeriod: 0 means "not set" → use generous default. + // LoopSuspendGracePeriod: 0 means "not set" → use generous default. // Negative means "explicitly disabled" → set to 0 so RunGCOnce skips the grace check. - if config.PeriodicSuspendGracePeriod == 0 { - config.PeriodicSuspendGracePeriod = defaultGCConfig().PeriodicSuspendGracePeriod - } else if config.PeriodicSuspendGracePeriod < 0 { - config.PeriodicSuspendGracePeriod = 0 + if config.LoopSuspendGracePeriod == 0 { + config.LoopSuspendGracePeriod = defaultGCConfig().LoopSuspendGracePeriod + } else if config.LoopSuspendGracePeriod < 0 { + config.LoopSuspendGracePeriod = 0 } // Note: MaxClosuresPerCycle == 0 means unlimited — no default applied. @@ -146,15 +146,15 @@ func (m *ACPProcessManager) StartGC(config GCConfig, query SessionQueryFunc, clo } } -// UpdatePeriodicSuspendThreshold updates the periodic suspend threshold on the +// UpdateLoopSuspendThreshold updates the loop suspend threshold on the // running GC. This is safe to call while the GC is running. A threshold of 0 -// disables the periodic suspend heuristic. -func (m *ACPProcessManager) UpdatePeriodicSuspendThreshold(d time.Duration) { +// disables the loop suspend heuristic. +func (m *ACPProcessManager) UpdateLoopSuspendThreshold(d time.Duration) { m.gcMu.Lock() defer m.gcMu.Unlock() - m.gcConfig.PeriodicSuspendThreshold = d + m.gcConfig.LoopSuspendThreshold = d if m.logger != nil { - m.logger.Info("GC: updated periodic suspend threshold", "threshold", d) + m.logger.Info("GC: updated loop suspend threshold", "threshold", d) } } @@ -214,15 +214,15 @@ func (m *ACPProcessManager) gcLoop() { // RunGCOnce executes a single GC iteration. It is exported for testing. // // Tier 1 closes idle sessions — those with no WebSocket observers, no active -// prompt, an empty queue, and no periodic prompt due within 2× the GC interval. +// prompt, an empty queue, and no loop prompt due within 2× the GC interval. // -// Periodic suspend heuristic: periodic sessions whose next prompt is farther -// away than PeriodicSuspendThreshold (default 30m) are eligible for suspension +// Loop suspend heuristic: loop sessions whose next prompt is farther +// away than LoopSuspendThreshold (default 30m) are eligible for suspension // even when they have active WebSocket observers. The session is NOT archived — // it stays visible and resumes transparently via ensure_resumed (user focus) -// or PeriodicRunner (when the prompt is due). This saves memory by stopping -// MCP server processes for idle periodic conversations. A generous -// PeriodicSuspendGracePeriod (default 10m) protects sessions that recently +// or LoopRunner (when the prompt is due). This saves memory by stopping +// MCP server processes for idle loop conversations. A generous +// LoopSuspendGracePeriod (default 10m) protects sessions that recently // finished a turn from being suspended too aggressively. // // Tier 2 stops shared ACP processes that have had no active sessions for longer @@ -231,7 +231,7 @@ func (m *ACPProcessManager) gcLoop() { // Tier 4 recycles memory-bloated idle processes: when a shared process's RSS // (summed over its process tree) exceeds MemoryRecycleThreshold and the process // is fully idle (no in-flight RPCs, no prompting session, empty queues, no -// periodic prompt due soon), its sessions are GC-suspended and closed and the +// loop prompt due soon), its sessions are GC-suspended and closed and the // process is stopped to reclaim memory. Disabled when MemoryRecycleThreshold is 0. // // Tier 3 cleans up auxiliary sessions that have been idle longer than AuxIdleTimeout. @@ -273,48 +273,48 @@ gcTier1: continue } - // Determine if this is a periodic session eligible for suspension. - // A periodic session qualifies when: - // 1. It has a NextPeriodicAt set (i.e., has an enabled periodic prompt) - // 2. The next prompt is farther away than PeriodicSuspendThreshold + // Determine if this is a loop session eligible for suspension. + // A loop session qualifies when: + // 1. It has a NextLoopAt set (i.e., has an enabled loop prompt) + // 2. The next prompt is farther away than LoopSuspendThreshold // 3. The session is not actively prompting (checked above) // 4. The queue is empty (checked below) // When eligible, we bypass the observer, connected-client, and idle-timeout // checks — the session is suspended even if the user has it open in the // sidebar. The user will see it transition to "not running" and it resumes // instantly via ensure_resumed when they focus it. - periodicSuspendEligible := false - if m.gcConfig.PeriodicSuspendThreshold > 0 && s.NextPeriodicAt != nil { - suspendThreshold := now.Add(m.gcConfig.PeriodicSuspendThreshold) - if s.NextPeriodicAt.After(suspendThreshold) { - periodicSuspendEligible = true + loopSuspendEligible := false + if m.gcConfig.LoopSuspendThreshold > 0 && s.NextLoopAt != nil { + suspendThreshold := now.Add(m.gcConfig.LoopSuspendThreshold) + if s.NextLoopAt.After(suspendThreshold) { + loopSuspendEligible = true } } - // Generous post-activity grace: never suspend a periodic session that + // Generous post-activity grace: never suspend a loop session that // recently finished a turn. The agent may be about to continue (a queued // follow-up, a nudge, or the user inspecting results). We use the most // recent of LastResponseCompleteAt (turn END) and LastActivityAt (prompt // START / observer change); the former is the reliable signal here because // LastActivityAt is stale by the end of a long-running task. - if periodicSuspendEligible && m.gcConfig.PeriodicSuspendGracePeriod > 0 { + if loopSuspendEligible && m.gcConfig.LoopSuspendGracePeriod > 0 { recentActivity := s.LastActivityAt if s.LastResponseCompleteAt.After(recentActivity) { recentActivity = s.LastResponseCompleteAt } - if !recentActivity.IsZero() && now.Sub(recentActivity) < m.gcConfig.PeriodicSuspendGracePeriod { + if !recentActivity.IsZero() && now.Sub(recentActivity) < m.gcConfig.LoopSuspendGracePeriod { if m.logger != nil { - m.logger.Debug("GC: skipping periodic suspend (recently active, within grace)", + m.logger.Debug("GC: skipping loop suspend (recently active, within grace)", "session_id", s.SessionID, "workspace_uuid", workspaceUUID, "active_ago", now.Sub(recentActivity), - "grace", m.gcConfig.PeriodicSuspendGracePeriod) + "grace", m.gcConfig.LoopSuspendGracePeriod) } continue } } - if !periodicSuspendEligible { + if !loopSuspendEligible { // Standard idle-session checks (apply only to non-suspend-eligible sessions). if s.HasObservers { if m.logger != nil { @@ -364,8 +364,8 @@ gcTier1: } } - // Queue and periodic-due-soon checks apply to both standard and - // periodic-suspend-eligible sessions. + // Queue and loop-due-soon checks apply to both standard and + // loop-suspend-eligible sessions. if s.QueueLength > 0 { if m.logger != nil { m.logger.Debug("GC: skipping session (non-empty queue)", @@ -375,26 +375,26 @@ gcTier1: } continue } - if s.NextPeriodicAt != nil { + if s.NextLoopAt != nil { threshold := now.Add(2 * m.gcConfig.Interval) - if s.NextPeriodicAt.Before(threshold) { + if s.NextLoopAt.Before(threshold) { if m.logger != nil { - m.logger.Debug("GC: skipping session (periodic prompt due soon)", + m.logger.Debug("GC: skipping session (loop prompt due soon)", "session_id", s.SessionID, "workspace_uuid", workspaceUUID, - "next_periodic_at", s.NextPeriodicAt) + "next_loop_at", s.NextLoopAt) } continue } } - if periodicSuspendEligible { + if loopSuspendEligible { if m.logger != nil { - m.logger.Info("GC: suspending periodic session (next run far away)", + m.logger.Info("GC: suspending loop session (next run far away)", "session_id", s.SessionID, "workspace_uuid", workspaceUUID, - "next_periodic_at", s.NextPeriodicAt, - "threshold", m.gcConfig.PeriodicSuspendThreshold) + "next_loop_at", s.NextLoopAt, + "threshold", m.gcConfig.LoopSuspendThreshold) } // Mark session as GC-suspended BEFORE closing, so the WebSocket // auto-resume handler sees the flag and skips resume. This prevents @@ -559,13 +559,13 @@ gcTier1: busy = true break } - if s.NextPeriodicAt != nil && s.NextPeriodicAt.Before(now.Add(2*m.gcConfig.Interval)) { + if s.NextLoopAt != nil && s.NextLoopAt.Before(now.Add(2*m.gcConfig.Interval)) { if m.logger != nil { m.logger.Debug("GC: skipping memory recycle (busy)", "workspace_uuid", workspaceUUID, - "reason", "periodic prompt due soon", + "reason", "loop prompt due soon", "session_id", s.SessionID, - "next_periodic_at", s.NextPeriodicAt) + "next_loop_at", s.NextLoopAt) } busy = true break @@ -605,7 +605,7 @@ gcTier1: } // Mark each session GC-suspended BEFORE closing so the WebSocket // auto-resume handler skips resume and avoids a thrash loop — same - // ordering as Tier 1's periodic-suspend path. + // ordering as Tier 1's loop-suspend path. recycledCount := len(sessions) for _, s := range sessions { m.MarkGCSuspended(s.SessionID) diff --git a/internal/acpproc/acp_process_gc_test.go b/internal/acpproc/acp_process_gc_test.go index d4e27364..3a50b631 100644 --- a/internal/acpproc/acp_process_gc_test.go +++ b/internal/acpproc/acp_process_gc_test.go @@ -29,12 +29,12 @@ func newTestGCManager( lastSessionSeen: make(map[string]time.Time), auxSessions: make(map[auxSessionKey]*auxiliarySessionState), gcConfig: GCConfig{ - Interval: 30 * time.Second, - GracePeriod: 60 * time.Second, - ObserverGracePeriod: 60 * time.Second, - IdleTimeout: 5 * time.Minute, - AuxIdleTimeout: 10 * time.Minute, - PeriodicSuspendThreshold: 30 * time.Minute, + Interval: 30 * time.Second, + GracePeriod: 60 * time.Second, + ObserverGracePeriod: 60 * time.Second, + IdleTimeout: 5 * time.Minute, + AuxIdleTimeout: 10 * time.Minute, + LoopSuspendThreshold: 30 * time.Minute, }, sessionQuery: query, sessionClose: closeSession, @@ -52,7 +52,7 @@ func newTestSharedProcess() *SharedACPProcess { } // TestGCTier1_ClosesIdleSessions verifies that sessions with no active state -// (not prompting, no observers, empty queue, no periodic) are closed by Tier 1. +// (not prompting, no observers, empty queue, no loop) are closed by Tier 1. func TestGCTier1_ClosesIdleSessions(t *testing.T) { sessions := map[string][]conversation.SessionInfo{ "ws-1": { @@ -87,7 +87,7 @@ func TestGCTier1_ClosesIdleSessions(t *testing.T) { // TestGCTier1_SkipsActiveSessions verifies that sessions with any active state // are never closed by Tier 1. func TestGCTier1_SkipsActiveSessions(t *testing.T) { - // NextPeriodicAt within 2×interval (60s) — should be skipped. + // NextLoopAt within 2×interval (60s) — should be skipped. soon := time.Now().Add(10 * time.Second) sessions := map[string][]conversation.SessionInfo{ @@ -95,7 +95,7 @@ func TestGCTier1_SkipsActiveSessions(t *testing.T) { {SessionID: "prompting", WorkspaceUUID: "ws-1", IsPrompting: true}, {SessionID: "observers", WorkspaceUUID: "ws-1", HasObservers: true}, {SessionID: "queue", WorkspaceUUID: "ws-1", QueueLength: 5}, - {SessionID: "periodic", WorkspaceUUID: "ws-1", NextPeriodicAt: &soon}, + {SessionID: "loop", WorkspaceUUID: "ws-1", NextLoopAt: &soon}, {SessionID: "connected-clients", WorkspaceUUID: "ws-1", HasConnectedClients: true}, }, } @@ -121,15 +121,15 @@ func TestGCTier1_SkipsActiveSessions(t *testing.T) { } } -// TestGCTier1_ClosesSessionWithDistantPeriodic verifies that a session whose -// next periodic prompt is far in the future (beyond 2×interval) is still +// TestGCTier1_ClosesSessionWithDistantLoop verifies that a session whose +// next loop prompt is far in the future (beyond 2×interval) is still // considered idle and is closed by Tier 1. -func TestGCTier1_ClosesSessionWithDistantPeriodic(t *testing.T) { +func TestGCTier1_ClosesSessionWithDistantLoop(t *testing.T) { far := time.Now().Add(2 * time.Hour) // well beyond 2×30s = 60s threshold sessions := map[string][]conversation.SessionInfo{ "ws-1": { - {SessionID: "distant-periodic", WorkspaceUUID: "ws-1", NextPeriodicAt: &far}, + {SessionID: "distant-loop", WorkspaceUUID: "ws-1", NextLoopAt: &far}, }, } @@ -149,8 +149,8 @@ func TestGCTier1_ClosesSessionWithDistantPeriodic(t *testing.T) { mu.Lock() defer mu.Unlock() - if !closed["distant-periodic"] { - t.Error("session with distant periodic should be closed by Tier 1") + if !closed["distant-loop"] { + t.Error("session with distant loop should be closed by Tier 1") } } @@ -775,23 +775,23 @@ func TestGCTier1_ObserverGracePeriodIgnoredWhenHasObservers(t *testing.T) { } // ============================================================================= -// Periodic Suspend Heuristic Tests +// Loop Suspend Heuristic Tests // ============================================================================= -// TestGCTier1_PeriodicSuspend_ClosesWithObservers verifies that a periodic session -// with active observers is suspended (closed) when its next periodic prompt is -// farther away than the PeriodicSuspendThreshold. -func TestGCTier1_PeriodicSuspend_ClosesWithObservers(t *testing.T) { - // Next periodic is 2 hours away — well beyond the 30m threshold. +// TestGCTier1_LoopSuspend_ClosesWithObservers verifies that a loop session +// with active observers is suspended (closed) when its next loop prompt is +// farther away than the LoopSuspendThreshold. +func TestGCTier1_LoopSuspend_ClosesWithObservers(t *testing.T) { + // Next loop is 2 hours away — well beyond the 30m threshold. far := time.Now().Add(2 * time.Hour) sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "periodic-far", + SessionID: "loop-far", WorkspaceUUID: "ws-1", HasObservers: true, - NextPeriodicAt: &far, + NextLoopAt: &far, ResumedAt: time.Now().Add(-10 * time.Minute), LastActivityAt: time.Now().Add(-1 * time.Minute), // Recent activity — normally would prevent GC }, @@ -814,18 +814,18 @@ func TestGCTier1_PeriodicSuspend_ClosesWithObservers(t *testing.T) { mu.Lock() defer mu.Unlock() - if !closed["periodic-far"] { - t.Error("periodic session with distant next-run should be suspended even with observers") + if !closed["loop-far"] { + t.Error("loop session with distant next-run should be suspended even with observers") } // Verify the GC-suspended flag is set to prevent auto-resume thrashing - if !m.IsGCSuspended("periodic-far") { - t.Error("periodic session should be marked as GC-suspended after suspension") + if !m.IsGCSuspended("loop-far") { + t.Error("loop session should be marked as GC-suspended after suspension") } } -// TestGCTier1_PeriodicSuspend_GCSuspendedFlagCleared verifies that ClearGCSuspended +// TestGCTier1_LoopSuspend_GCSuspendedFlagCleared verifies that ClearGCSuspended // removes the flag and IsGCSuspended returns false for non-suspended sessions. -func TestGCTier1_PeriodicSuspend_GCSuspendedFlagCleared(t *testing.T) { +func TestGCTier1_LoopSuspend_GCSuspendedFlagCleared(t *testing.T) { m := newTestGCManager( func() map[string][]conversation.SessionInfo { return nil }, func(id string) {}, @@ -849,9 +849,9 @@ func TestGCTier1_PeriodicSuspend_GCSuspendedFlagCleared(t *testing.T) { } } -// TestGCTier1_PeriodicSuspend_IdleClosureNotMarkedSuspended verifies that regular -// idle session closures do NOT set the GC-suspended flag (only periodic suspensions do). -func TestGCTier1_PeriodicSuspend_IdleClosureNotMarkedSuspended(t *testing.T) { +// TestGCTier1_LoopSuspend_IdleClosureNotMarkedSuspended verifies that regular +// idle session closures do NOT set the GC-suspended flag (only loop suspensions do). +func TestGCTier1_LoopSuspend_IdleClosureNotMarkedSuspended(t *testing.T) { sessions := map[string][]conversation.SessionInfo{ "ws-1": { { @@ -876,21 +876,21 @@ func TestGCTier1_PeriodicSuspend_IdleClosureNotMarkedSuspended(t *testing.T) { } } -// TestGCTier1_PeriodicSuspend_KeepsClosePeriodicWithObservers verifies that a -// periodic session with observers is NOT suspended when its next periodic prompt -// is within the PeriodicSuspendThreshold. -func TestGCTier1_PeriodicSuspend_KeepsClosePeriodicWithObservers(t *testing.T) { - // Next periodic is 10 minutes away — within the 30m threshold. +// TestGCTier1_LoopSuspend_KeepsCloseLoopWithObservers verifies that a +// loop session with observers is NOT suspended when its next loop prompt +// is within the LoopSuspendThreshold. +func TestGCTier1_LoopSuspend_KeepsCloseLoopWithObservers(t *testing.T) { + // Next loop is 10 minutes away — within the 30m threshold. close_ := time.Now().Add(10 * time.Minute) sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "periodic-close", - WorkspaceUUID: "ws-1", - HasObservers: true, - NextPeriodicAt: &close_, - ResumedAt: time.Now().Add(-10 * time.Minute), + SessionID: "loop-close", + WorkspaceUUID: "ws-1", + HasObservers: true, + NextLoopAt: &close_, + ResumedAt: time.Now().Add(-10 * time.Minute), }, }, } @@ -911,19 +911,19 @@ func TestGCTier1_PeriodicSuspend_KeepsClosePeriodicWithObservers(t *testing.T) { mu.Lock() defer mu.Unlock() - if closed["periodic-close"] { - t.Error("periodic session with nearby next-run and observers should NOT be suspended") + if closed["loop-close"] { + t.Error("loop session with nearby next-run and observers should NOT be suspended") } } -// TestGCTier1_PeriodicSuspend_KeepsNonPeriodicWithObservers verifies that a -// non-periodic session with observers is never closed (the periodic suspend -// heuristic does not apply to non-periodic sessions). -func TestGCTier1_PeriodicSuspend_KeepsNonPeriodicWithObservers(t *testing.T) { +// TestGCTier1_LoopSuspend_KeepsNonLoopWithObservers verifies that a +// non-loop session with observers is never closed (the loop suspend +// heuristic does not apply to non-loop sessions). +func TestGCTier1_LoopSuspend_KeepsNonLoopWithObservers(t *testing.T) { sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "non-periodic", + SessionID: "non-loop", WorkspaceUUID: "ws-1", HasObservers: true, ResumedAt: time.Now().Add(-10 * time.Minute), @@ -947,25 +947,25 @@ func TestGCTier1_PeriodicSuspend_KeepsNonPeriodicWithObservers(t *testing.T) { mu.Lock() defer mu.Unlock() - if closed["non-periodic"] { - t.Error("non-periodic session with observers should NOT be closed") + if closed["non-loop"] { + t.Error("non-loop session with observers should NOT be closed") } } -// TestGCTier1_PeriodicSuspend_SkipsPrompting verifies that a periodic session +// TestGCTier1_LoopSuspend_SkipsPrompting verifies that a loop session // eligible for suspension is NOT closed when it is actively prompting. -func TestGCTier1_PeriodicSuspend_SkipsPrompting(t *testing.T) { +func TestGCTier1_LoopSuspend_SkipsPrompting(t *testing.T) { far := time.Now().Add(2 * time.Hour) sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "periodic-prompting", - WorkspaceUUID: "ws-1", - HasObservers: true, - IsPrompting: true, - NextPeriodicAt: &far, - ResumedAt: time.Now().Add(-10 * time.Minute), + SessionID: "loop-prompting", + WorkspaceUUID: "ws-1", + HasObservers: true, + IsPrompting: true, + NextLoopAt: &far, + ResumedAt: time.Now().Add(-10 * time.Minute), }, }, } @@ -986,25 +986,25 @@ func TestGCTier1_PeriodicSuspend_SkipsPrompting(t *testing.T) { mu.Lock() defer mu.Unlock() - if closed["periodic-prompting"] { - t.Error("periodic session that is prompting should NOT be suspended") + if closed["loop-prompting"] { + t.Error("loop session that is prompting should NOT be suspended") } } -// TestGCTier1_PeriodicSuspend_SkipsNonEmptyQueue verifies that a periodic session +// TestGCTier1_LoopSuspend_SkipsNonEmptyQueue verifies that a loop session // eligible for suspension is NOT closed when it has queued messages. -func TestGCTier1_PeriodicSuspend_SkipsNonEmptyQueue(t *testing.T) { +func TestGCTier1_LoopSuspend_SkipsNonEmptyQueue(t *testing.T) { far := time.Now().Add(2 * time.Hour) sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "periodic-queue", - WorkspaceUUID: "ws-1", - HasObservers: true, - QueueLength: 3, - NextPeriodicAt: &far, - ResumedAt: time.Now().Add(-10 * time.Minute), + SessionID: "loop-queue", + WorkspaceUUID: "ws-1", + HasObservers: true, + QueueLength: 3, + NextLoopAt: &far, + ResumedAt: time.Now().Add(-10 * time.Minute), }, }, } @@ -1025,25 +1025,25 @@ func TestGCTier1_PeriodicSuspend_SkipsNonEmptyQueue(t *testing.T) { mu.Lock() defer mu.Unlock() - if closed["periodic-queue"] { - t.Error("periodic session with non-empty queue should NOT be suspended") + if closed["loop-queue"] { + t.Error("loop session with non-empty queue should NOT be suspended") } } -// TestGCTier1_PeriodicSuspend_SkipsRecentlyResumed verifies that a periodic session +// TestGCTier1_LoopSuspend_SkipsRecentlyResumed verifies that a loop session // eligible for suspension is NOT closed when it was recently resumed (within one // GC interval). This prevents a resume → immediate close → resume loop. -func TestGCTier1_PeriodicSuspend_SkipsRecentlyResumed(t *testing.T) { +func TestGCTier1_LoopSuspend_SkipsRecentlyResumed(t *testing.T) { far := time.Now().Add(2 * time.Hour) sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "periodic-just-resumed", - WorkspaceUUID: "ws-1", - HasObservers: true, - NextPeriodicAt: &far, - ResumedAt: time.Now().Add(-5 * time.Second), // Resumed 5s ago, within 30s interval + SessionID: "loop-just-resumed", + WorkspaceUUID: "ws-1", + HasObservers: true, + NextLoopAt: &far, + ResumedAt: time.Now().Add(-5 * time.Second), // Resumed 5s ago, within 30s interval }, }, } @@ -1064,27 +1064,27 @@ func TestGCTier1_PeriodicSuspend_SkipsRecentlyResumed(t *testing.T) { mu.Lock() defer mu.Unlock() - if closed["periodic-just-resumed"] { - t.Error("recently resumed periodic session should NOT be suspended (anti-thrash)") + if closed["loop-just-resumed"] { + t.Error("recently resumed loop session should NOT be suspended (anti-thrash)") } } -// TestGCTier1_PeriodicSuspend_SkipsWithinGrace verifies that a periodic session +// TestGCTier1_LoopSuspend_SkipsWithinGrace verifies that a loop session // that recently finished a turn is NOT suspended while it is within the generous -// PeriodicSuspendGracePeriod. This protects a conversation that just ended a turn +// LoopSuspendGracePeriod. This protects a conversation that just ended a turn // (and may be about to continue) from being reclaimed too aggressively. The grace // is keyed on LastResponseCompleteAt (turn END), not LastActivityAt (prompt START). -func TestGCTier1_PeriodicSuspend_SkipsWithinGrace(t *testing.T) { +func TestGCTier1_LoopSuspend_SkipsWithinGrace(t *testing.T) { far := time.Now().Add(2 * time.Hour) sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "periodic-grace", - WorkspaceUUID: "ws-1", - HasObservers: true, - NextPeriodicAt: &far, - ResumedAt: time.Now().Add(-2 * time.Hour), // long ago — not "recently resumed" + SessionID: "loop-grace", + WorkspaceUUID: "ws-1", + HasObservers: true, + NextLoopAt: &far, + ResumedAt: time.Now().Add(-2 * time.Hour), // long ago — not "recently resumed" // Prompt started long ago (stale), but the turn ended just 2m ago. LastActivityAt: time.Now().Add(-90 * time.Minute), LastResponseCompleteAt: time.Now().Add(-2 * time.Minute), @@ -1103,33 +1103,33 @@ func TestGCTier1_PeriodicSuspend_SkipsWithinGrace(t *testing.T) { closed[id] = true }, ) - m.gcConfig.PeriodicSuspendGracePeriod = 10 * time.Minute + m.gcConfig.LoopSuspendGracePeriod = 10 * time.Minute m.RunGCOnce() mu.Lock() defer mu.Unlock() - if closed["periodic-grace"] { - t.Error("periodic session that finished a turn within the grace window should NOT be suspended") + if closed["loop-grace"] { + t.Error("loop session that finished a turn within the grace window should NOT be suspended") } - if m.IsGCSuspended("periodic-grace") { - t.Error("periodic session within grace window should NOT be marked GC-suspended") + if m.IsGCSuspended("loop-grace") { + t.Error("loop session within grace window should NOT be marked GC-suspended") } } -// TestGCTier1_PeriodicSuspend_SuspendsAfterGrace verifies that once a periodic -// session has been idle longer than PeriodicSuspendGracePeriod (no recent turn +// TestGCTier1_LoopSuspend_SuspendsAfterGrace verifies that once a loop +// session has been idle longer than LoopSuspendGracePeriod (no recent turn // completion or activity), it is suspended as normal. -func TestGCTier1_PeriodicSuspend_SuspendsAfterGrace(t *testing.T) { +func TestGCTier1_LoopSuspend_SuspendsAfterGrace(t *testing.T) { far := time.Now().Add(2 * time.Hour) sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "periodic-past-grace", + SessionID: "loop-past-grace", WorkspaceUUID: "ws-1", HasObservers: true, - NextPeriodicAt: &far, + NextLoopAt: &far, ResumedAt: time.Now().Add(-2 * time.Hour), LastActivityAt: time.Now().Add(-90 * time.Minute), LastResponseCompleteAt: time.Now().Add(-30 * time.Minute), // well beyond grace @@ -1148,34 +1148,34 @@ func TestGCTier1_PeriodicSuspend_SuspendsAfterGrace(t *testing.T) { closed[id] = true }, ) - m.gcConfig.PeriodicSuspendGracePeriod = 10 * time.Minute + m.gcConfig.LoopSuspendGracePeriod = 10 * time.Minute m.RunGCOnce() mu.Lock() defer mu.Unlock() - if !closed["periodic-past-grace"] { - t.Error("periodic session idle beyond the grace window should be suspended") + if !closed["loop-past-grace"] { + t.Error("loop session idle beyond the grace window should be suspended") } - if !m.IsGCSuspended("periodic-past-grace") { - t.Error("periodic session suspended after grace should be marked GC-suspended") + if !m.IsGCSuspended("loop-past-grace") { + t.Error("loop session suspended after grace should be marked GC-suspended") } } -// TestGCTier1_PeriodicSuspend_WithConnectedClients verifies that a periodic session +// TestGCTier1_LoopSuspend_WithConnectedClients verifies that a loop session // eligible for suspension is closed even when it has connected WebSocket clients // (pre-connected background sessions that haven't sent load_events yet). -func TestGCTier1_PeriodicSuspend_WithConnectedClients(t *testing.T) { +func TestGCTier1_LoopSuspend_WithConnectedClients(t *testing.T) { far := time.Now().Add(2 * time.Hour) sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "periodic-clients", + SessionID: "loop-clients", WorkspaceUUID: "ws-1", HasObservers: false, HasConnectedClients: true, - NextPeriodicAt: &far, + NextLoopAt: &far, ResumedAt: time.Now().Add(-10 * time.Minute), LastActivityAt: time.Now().Add(-1 * time.Minute), }, @@ -1198,24 +1198,24 @@ func TestGCTier1_PeriodicSuspend_WithConnectedClients(t *testing.T) { mu.Lock() defer mu.Unlock() - if !closed["periodic-clients"] { - t.Error("periodic session with distant next-run should be suspended even with connected clients") + if !closed["loop-clients"] { + t.Error("loop session with distant next-run should be suspended even with connected clients") } } -// TestGCTier1_PeriodicSuspend_DisabledWhenThresholdZero verifies that setting -// PeriodicSuspendThreshold to 0 disables the periodic suspend heuristic. -func TestGCTier1_PeriodicSuspend_DisabledWhenThresholdZero(t *testing.T) { +// TestGCTier1_LoopSuspend_DisabledWhenThresholdZero verifies that setting +// LoopSuspendThreshold to 0 disables the loop suspend heuristic. +func TestGCTier1_LoopSuspend_DisabledWhenThresholdZero(t *testing.T) { far := time.Now().Add(2 * time.Hour) sessions := map[string][]conversation.SessionInfo{ "ws-1": { { - SessionID: "periodic-no-suspend", - WorkspaceUUID: "ws-1", - HasObservers: true, - NextPeriodicAt: &far, - ResumedAt: time.Now().Add(-10 * time.Minute), + SessionID: "loop-no-suspend", + WorkspaceUUID: "ws-1", + HasObservers: true, + NextLoopAt: &far, + ResumedAt: time.Now().Add(-10 * time.Minute), }, }, } @@ -1231,16 +1231,16 @@ func TestGCTier1_PeriodicSuspend_DisabledWhenThresholdZero(t *testing.T) { closed[id] = true }, ) - // Disable periodic suspend by setting threshold to 0 (disabled). + // Disable loop suspend by setting threshold to 0 (disabled). // StartGC converts negative values to 0; RunGCOnce skips the heuristic when <= 0. - m.gcConfig.PeriodicSuspendThreshold = 0 + m.gcConfig.LoopSuspendThreshold = 0 m.RunGCOnce() mu.Lock() defer mu.Unlock() - if closed["periodic-no-suspend"] { - t.Error("periodic session should NOT be suspended when PeriodicSuspendThreshold is disabled") + if closed["loop-no-suspend"] { + t.Error("loop session should NOT be suspended when LoopSuspendThreshold is disabled") } } diff --git a/internal/acpproc/acp_process_manager.go b/internal/acpproc/acp_process_manager.go index 8ff960e1..bf2e7917 100644 --- a/internal/acpproc/acp_process_manager.go +++ b/internal/acpproc/acp_process_manager.go @@ -40,6 +40,12 @@ type ACPProcessManager struct { // AuxiliaryModelSelection is used as-is. ModelProfileResolver func(name string) *config.ModelProfile + // ModelProfilesByTagResolver returns all Model profiles (Config.Models) carrying + // a given capability tag, in definition order. Used to resolve AuxiliaryModelTag + // for new auxiliary sessions (mitto-9vz). May be nil, in which case + // AuxiliaryModelTag is ignored. + ModelProfilesByTagResolver func(tag string) []config.ModelProfile + // Auxiliary session tracking auxMu sync.Mutex auxSessions map[auxSessionKey]*auxiliarySessionState @@ -85,13 +91,13 @@ type ACPProcessManager struct { onMemoryRecycled func(workspaceUUID string, rssBytes, threshold uint64, sessionCount int) // gcSuspendedSessions tracks session IDs that were intentionally suspended - // by the GC's periodic-suspend heuristic. When a periodic session's next run + // by the GC's loop-suspend heuristic. When a loop session's next run // is far away, the GC closes it and adds it here. The WebSocket auto-resume // handler checks this set and skips resume for flagged sessions, preventing // a suspend/resume thrashing loop (GC closes → WS reconnects → auto-resume // → GC closes again). The flag is cleared by: // - ensure_resumed (explicit user focus) - // - PeriodicRunner (when the prompt is due) + // - LoopRunner (when the prompt is due) // - ResumeSession (any explicit resume call) gcSuspendedSessions map[string]bool // protected by gcMu @@ -105,7 +111,7 @@ type ACPProcessManager struct { } // MarkGCSuspended records that a session was intentionally suspended by the GC's -// periodic-suspend heuristic. The WebSocket auto-resume handler checks this flag +// loop-suspend heuristic. The WebSocket auto-resume handler checks this flag // and skips resume to prevent suspend/resume thrashing. func (m *ACPProcessManager) MarkGCSuspended(sessionID string) { m.gcMu.Lock() @@ -118,7 +124,7 @@ func (m *ACPProcessManager) MarkGCSuspended(sessionID string) { // ClearGCSuspended removes the GC-suspended flag for a session, allowing // WebSocket auto-resume to proceed normally. Called by ensure_resumed (explicit -// user focus), PeriodicRunner (when the prompt is due), and ResumeSession. +// user focus), LoopRunner (when the prompt is due), and ResumeSession. func (m *ACPProcessManager) ClearGCSuspended(sessionID string) { m.gcMu.Lock() defer m.gcMu.Unlock() @@ -807,10 +813,13 @@ func (m *ACPProcessManager) getOrCreateAuxiliarySession(ctx context.Context, wor } // Apply auxiliary model selection if configured for this workspace. - // If AuxiliaryModelProfile is set (mitto-hke), it takes precedence and its resolved - // Criteria is used in place of the legacy AuxiliaryModelSelection matchMode/pattern. - // Falls back to AuxiliaryModelSelection when the profile field is empty or unresolved. - // On no match or nil selection, leave the ACP server's default model unchanged. + // Precedence: AuxiliaryModelProfile > AuxiliaryModelTag > AuxiliaryModelSelection + // (mitto-hke, mitto-9vz). If AuxiliaryModelProfile is set, it takes precedence and + // its resolved Criteria is used in place of the legacy AuxiliaryModelSelection + // matchMode/pattern. Else, if AuxiliaryModelTag is set, the first Model profile + // carrying that tag whose Criteria matches an available model is used. Falls back + // to AuxiliaryModelSelection when neither resolves. On no match or nil selection, + // leave the ACP server's default model unchanged. if m.WorkspaceConfigProvider != nil { if ws := m.WorkspaceConfigProvider(workspaceUUID); ws != nil { auxConstraint := ws.AuxiliaryModelSelection @@ -818,6 +827,17 @@ func (m *ACPProcessManager) getOrCreateAuxiliarySession(ctx context.Context, wor if profile := m.ModelProfileResolver(ws.AuxiliaryModelProfile); profile != nil && profile.Criteria != nil { auxConstraint = profile.Criteria } + } else if ws.AuxiliaryModelTag != "" && m.ModelProfilesByTagResolver != nil { + profiles := m.ModelProfilesByTagResolver(ws.AuxiliaryModelTag) + for i := range profiles { + if profiles[i].Criteria == nil { + continue + } + if conversation.ResolveProfileModel(&profiles[i], sessionHandle.Models) != "" { + auxConstraint = profiles[i].Criteria + break + } + } } if auxConstraint != nil && auxConstraint.Pattern != "" { matched, shouldSet := conversation.ResolveAuxModelSwitch(auxConstraint, sessionHandle.Models) diff --git a/internal/agents/manager_test.go b/internal/agents/manager_test.go index 8ed493ad..042524f8 100644 --- a/internal/agents/manager_test.go +++ b/internal/agents/manager_test.go @@ -557,6 +557,34 @@ func TestMCPServer_EnvUnmarshal(t *testing.T) { } } +// TestMCPServer_HeadersUnmarshal verifies that the MCPServer.Headers field is +// populated when an mcp-list.sh script emits a "headers" object for a remote +// server, so auth headers can be surfaced/round-tripped like Env. +func TestMCPServer_HeadersUnmarshal(t *testing.T) { + raw := `{"servers":[{"name":"authed","url":"https://example.com/mcp","headers":{"Authorization":"Bearer tkn","X-Api":"v1"}},{"name":"plain","url":"http://127.0.0.1:5757/mcp"}]}` + + var out MCPListOutput + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("failed to unmarshal MCPListOutput: %v", err) + } + if len(out.Servers) != 2 { + t.Fatalf("servers = %d, want 2", len(out.Servers)) + } + + authed := out.Servers[0] + if got := authed.Headers["Authorization"]; got != "Bearer tkn" { + t.Errorf("Headers[Authorization] = %q, want %q", got, "Bearer tkn") + } + if got := authed.Headers["X-Api"]; got != "v1" { + t.Errorf("Headers[X-Api] = %q, want %q", got, "v1") + } + + plain := out.Servers[1] + if plain.Headers != nil { + t.Errorf("expected nil Headers for server without headers, got %+v", plain.Headers) + } +} + // TestMCPServer_EnvOmitEmpty verifies that an MCPServer with no env vars marshals // without an "env" key (json:",omitempty"), keeping the listing output clean. func TestMCPServer_EnvOmitEmpty(t *testing.T) { diff --git a/internal/agents/types.go b/internal/agents/types.go index e8289bf9..2f769114 100644 --- a/internal/agents/types.go +++ b/internal/agents/types.go @@ -198,6 +198,7 @@ type MCPServer struct { Args []string `json:"args,omitempty"` URL string `json:"url,omitempty"` Env map[string]string `json:"env,omitempty"` + Headers map[string]string `json:"headers,omitempty"` } // MCPListOutput is the expected JSON output from mcp-list.sh. diff --git a/internal/appdir/appdir.go b/internal/appdir/appdir.go index faa23a57..2da69a10 100644 --- a/internal/appdir/appdir.go +++ b/internal/appdir/appdir.go @@ -65,6 +65,10 @@ const ( // DefenseBlocklistFileName is the name of the scanner defense blocklist file. DefenseBlocklistFileName = "scanner_blocklist.json" + + // MCPToolsCacheDirName is the name of the subdirectory holding per-workspace + // persisted real-MCP tools snapshots (one JSON file per workspace UUID). + MCPToolsCacheDirName = "mcp-tools-cache" ) var ( @@ -399,6 +403,18 @@ func DefenseBlocklistPath() (string, error) { return filepath.Join(dir, DefenseBlocklistFileName), nil } +// MCPToolsCacheDir returns the directory holding per-workspace persisted +// real-MCP tools snapshots ($MITTO_DIR/mcp-tools-cache). The directory is not +// created here; callers persist via fileutil.WriteJSONAtomic, which creates it +// on first write. +func MCPToolsCacheDir() (string, error) { + dir, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(dir, MCPToolsCacheDirName), nil +} + // ResetCache clears the cached directory path. // This is primarily useful for testing. func ResetCache() { diff --git a/internal/auxiliary/mcp_watchers_test.go b/internal/auxiliary/mcp_watchers_test.go new file mode 100644 index 00000000..420402d7 --- /dev/null +++ b/internal/auxiliary/mcp_watchers_test.go @@ -0,0 +1,248 @@ +package auxiliary + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/inercia/mitto/internal/agents" + "github.com/inercia/mitto/internal/mcpdiscovery" +) + +func noopWatchToolHandler(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil +} + +// newWatchableMCPServer starts an in-memory MCP server exposing toolNames and +// returns the server (so tests can AddTool/RemoveTools on it), a +// mcpdiscovery.TransportFactory that connects to it, and a stop func. +func newWatchableMCPServer(t *testing.T, toolNames ...string) (*mcp.Server, mcpdiscovery.TransportFactory, func()) { + t.Helper() + serverCtx, stop := context.WithCancel(context.Background()) + + srv := mcp.NewServer(&mcp.Implementation{Name: "mock", Version: "0"}, nil) + for _, name := range toolNames { + mcp.AddTool(srv, &mcp.Tool{Name: name, Description: "mock tool"}, noopWatchToolHandler) + } + + clientT, serverT := mcp.NewInMemoryTransports() + go srv.Run(serverCtx, serverT) + + factory := func(_ context.Context, _ agents.MCPServer) (mcp.Transport, func(), error) { + return clientT, stop, nil + } + return srv, factory, stop +} + +func TestEnsureMCPWatchers_InitialAndChangeRebroadcast(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + srv, factory, stop := newWatchableMCPServer(t, "jira_search") + defer stop() + + mgr.MCPWatchTransportFactory = factory + mgr.MCPServerLister = func(ctx context.Context, workspaceUUID string) ([]agents.MCPServer, error) { + return []agents.MCPServer{{Name: "jira", Command: "unused"}}, nil + } + + updates := make(chan []MCPToolInfo, 8) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mgr.EnsureMCPWatchers(ctx, "ws", func(tools []MCPToolInfo) { + updates <- tools + }) + + select { + case tools := <-updates: + assertToolNames(t, tools, "jira_search") + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for initial onUpdate") + } + + cached, ok := mgr.GetCachedMCPTools("ws") + if !ok { + t.Fatalf("expected cache entry after initial watcher startup") + } + assertToolNames(t, cached, "jira_search") + + mcp.AddTool(srv, &mcp.Tool{Name: "jira_create_issue", Description: "mock tool"}, noopWatchToolHandler) + + select { + case tools := <-updates: + assertToolNames(t, tools, "jira_create_issue", "jira_search") + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for onUpdate after AddTool") + } + + cached, ok = mgr.GetCachedMCPTools("ws") + if !ok { + t.Fatalf("expected cache entry after change notification") + } + assertToolNames(t, cached, "jira_create_issue", "jira_search") + + mgr.StopMCPWatchers("ws") +} + +func TestEnsureMCPWatchers_Dedup(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + _, factory, stop := newWatchableMCPServer(t, "jira_search") + defer stop() + + mgr.MCPWatchTransportFactory = factory + + var listerCalls int32 + mgr.MCPServerLister = func(ctx context.Context, workspaceUUID string) ([]agents.MCPServer, error) { + atomic.AddInt32(&listerCalls, 1) + return []agents.MCPServer{{Name: "jira", Command: "unused"}}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}, 4) + onUpdate := func(tools []MCPToolInfo) { done <- struct{}{} } + + mgr.EnsureMCPWatchers(ctx, "ws", onUpdate) + mgr.EnsureMCPWatchers(ctx, "ws", onUpdate) // must be a no-op: pool already active/starting + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the initial onUpdate") + } + + if got := atomic.LoadInt32(&listerCalls); got != 1 { + t.Errorf("MCPServerLister called %d times, want 1 (dedup)", got) + } + + mgr.StopMCPWatchers("ws") +} + +func TestStopAndCloseAllMCPWatchers(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + _, factory, stop := newWatchableMCPServer(t, "jira_search") + defer stop() + + mgr.MCPWatchTransportFactory = factory + mgr.MCPServerLister = func(ctx context.Context, workspaceUUID string) ([]agents.MCPServer, error) { + return []agents.MCPServer{{Name: "jira", Command: "unused"}}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var mu sync.Mutex + got := false + done := make(chan struct{}, 4) + mgr.EnsureMCPWatchers(ctx, "ws", func(tools []MCPToolInfo) { + mu.Lock() + got = true + mu.Unlock() + done <- struct{}{} + }) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for onUpdate") + } + mu.Lock() + if !got { + t.Fatalf("onUpdate never called") + } + mu.Unlock() + + mgr.StopMCPWatchers("ws") + mgr.mcpWatchersMu.Lock() + _, exists := mgr.mcpWatchers["ws"] + mgr.mcpWatchersMu.Unlock() + if exists { + t.Errorf("expected workspace key removed after StopMCPWatchers") + } + + // Idempotent: calling again must not panic. + mgr.StopMCPWatchers("ws") + + // CloseAllMCPWatchers must also be safe when empty. + mgr.CloseAllMCPWatchers() + mgr.CloseAllMCPWatchers() +} + +func TestEnsureMCPWatchers_NilLister_NoOp(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) // MCPServerLister left nil + + called := false + mgr.EnsureMCPWatchers(context.Background(), "ws", func(tools []MCPToolInfo) { + called = true + }) + + time.Sleep(10 * time.Millisecond) + if called { + t.Errorf("onUpdate called, want no-op for nil MCPServerLister") + } + mgr.mcpWatchersMu.Lock() + _, exists := mgr.mcpWatchers["ws"] + mgr.mcpWatchersMu.Unlock() + if exists { + t.Errorf("expected no workspace entry created for nil MCPServerLister") + } +} + +func TestEnsureMCPWatchers_ListerErrorReleasesReservation(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + _, factory, stop := newWatchableMCPServer(t, "jira_search") + defer stop() + + mgr.MCPWatchTransportFactory = factory + mgr.MCPServerLister = func(ctx context.Context, workspaceUUID string) ([]agents.MCPServer, error) { + return nil, errors.New("lister boom") + } + + mgr.EnsureMCPWatchers(context.Background(), "ws", nil) + + // Poll until the failed attempt has released its reservation. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mgr.mcpWatchersMu.Lock() + _, exists := mgr.mcpWatchers["ws"] + mgr.mcpWatchersMu.Unlock() + if !exists { + break + } + time.Sleep(time.Millisecond) + } + mgr.mcpWatchersMu.Lock() + _, stillReserved := mgr.mcpWatchers["ws"] + mgr.mcpWatchersMu.Unlock() + if stillReserved { + t.Fatalf("expected reservation released after lister error, workspace still present in mcpWatchers") + } + + var workingListerCalls int32 + mgr.MCPServerLister = func(ctx context.Context, workspaceUUID string) ([]agents.MCPServer, error) { + atomic.AddInt32(&workingListerCalls, 1) + return []agents.MCPServer{{Name: "jira", Command: "unused"}}, nil + } + + done := make(chan struct{}, 4) + mgr.EnsureMCPWatchers(context.Background(), "ws", func(tools []MCPToolInfo) { + done <- struct{}{} + }) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for onUpdate after retrying with a working lister") + } + + if got := atomic.LoadInt32(&workingListerCalls); got != 1 { + t.Errorf("working MCPServerLister called %d times, want 1 (retry must not be blocked by the earlier failure)", got) + } + + mgr.StopMCPWatchers("ws") +} diff --git a/internal/auxiliary/prompts.go b/internal/auxiliary/prompts.go index 62d982cd..702974ba 100644 --- a/internal/auxiliary/prompts.go +++ b/internal/auxiliary/prompts.go @@ -39,6 +39,11 @@ var AnalyzeFollowUpQuestionsPromptTemplate string //go:embed prompts/fetch_mcp_tools.txt var FetchMCPToolsPromptTemplate string +// mcpToolsRetryReminder is appended to FetchMCPToolsPromptTemplate on retry +// attempts (mitto-sys.7) after the agent's previous reply failed strict +// parsing, so the retry explicitly restates the required response shape. +const mcpToolsRetryReminder = `IMPORTANT: Your previous reply was not accepted. Reply with ONLY a single JSON object of the exact form {"tools":[{"name":"...","description":"..."}]} (or {"error":"..."} if you truly have no MCP tools). No prose, no markdown fences, no extra text.` + // CheckToolPatternsPromptTemplate asks the agent to check if specific tool patterns // have matching MCP tools available. Use with fmt.Sprintf, passing the comma-separated patterns. // This is sent to the same PurposeMCPTools auxiliary session, so the agent already has diff --git a/internal/auxiliary/utils.go b/internal/auxiliary/utils.go index 8fb59c5a..bf8c3e93 100644 --- a/internal/auxiliary/utils.go +++ b/internal/auxiliary/utils.go @@ -168,47 +168,32 @@ type mcpToolsResponse struct { Error string `json:"error"` } -// parseMCPToolsList parses the JSON response from the MCP tools fetch. -// Accepts either a JSON object {"tools":[...], "error":"..."} or a bare JSON array [...]. -// Returns the tools list and any error message reported by the agent. +// parseMCPToolsList parses the JSON response from the MCP tools fetch. It is +// STRICT (mitto-sys.7, ADR Q2): the whole (trimmed, unfenced) response must be +// a single JSON object with a "tools" and/or "error" key — no substring +// extraction, no bare-array fallback. {"tools":[]} is a valid, legitimate +// "zero tools" answer, distinct from a parse failure. Returns the tools list +// and any error message reported by the agent. func parseMCPToolsList(response string) ([]MCPToolInfo, string, error) { response = strings.TrimSpace(response) response = stripMarkdownFences(response) + response = strings.TrimSpace(response) - // Try parsing as a JSON object {"tools": [...], "error": "..."} - var obj mcpToolsResponse - if err := json.Unmarshal([]byte(response), &obj); err == nil && (len(obj.Tools) > 0 || obj.Error != "") { - return obj.Tools, obj.Error, nil + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(response), &raw); err != nil { + return nil, "", fmt.Errorf("invalid JSON response: %s", truncateForLog(response, 100)) } - - // Try to find a JSON object in the response (may have surrounding text) - start := strings.Index(response, "{") - end := strings.LastIndex(response, "}") - if start >= 0 && end > start { - jsonStr := response[start : end+1] - if err := json.Unmarshal([]byte(jsonStr), &obj); err == nil && (len(obj.Tools) > 0 || obj.Error != "") { - return obj.Tools, obj.Error, nil + if _, hasTools := raw["tools"]; !hasTools { + if _, hasError := raw["error"]; !hasError { + return nil, "", fmt.Errorf("invalid JSON response: %s", truncateForLog(response, 100)) } } - // Fallback: try parsing as a bare JSON array (backward compatibility) - var tools []MCPToolInfo - if err := json.Unmarshal([]byte(response), &tools); err == nil { - return tools, "", nil - } - - // Try to find a JSON array in the response - arrStart := strings.Index(response, "[") - arrEnd := strings.LastIndex(response, "]") - if arrStart >= 0 && arrEnd > arrStart { - jsonStr := response[arrStart : arrEnd+1] - if err := json.Unmarshal([]byte(jsonStr), &tools); err == nil { - return tools, "", nil - } + var obj mcpToolsResponse + if err := json.Unmarshal([]byte(response), &obj); err != nil { + return nil, "", fmt.Errorf("invalid JSON response: %s", truncateForLog(response, 100)) } - - // Parsing failed - return nil, "", fmt.Errorf("invalid JSON response: %s", truncateForLog(response, 100)) + return obj.Tools, obj.Error, nil } // parseMCPAvailabilityResult parses the JSON response from the MCP availability check. diff --git a/internal/auxiliary/utils_test.go b/internal/auxiliary/utils_test.go index df22af8f..e10c32f0 100644 --- a/internal/auxiliary/utils_test.go +++ b/internal/auxiliary/utils_test.go @@ -87,14 +87,14 @@ func TestParseMCPToolsList(t *testing.T) { wantTools: 1, }, { - name: "bare JSON array fallback", - input: `[{"name": "tool1", "description": "desc1"}]`, - wantTools: 1, + name: "bare JSON array fallback", + input: `[{"name": "tool1", "description": "desc1"}]`, + wantErr: true, }, { - name: "fenced bare JSON array", - input: "```\n[{\"name\": \"tool1\", \"description\": \"desc1\"}]\n```", - wantTools: 1, + name: "fenced bare JSON array", + input: "```\n[{\"name\": \"tool1\", \"description\": \"desc1\"}]\n```", + wantErr: true, }, { name: "agent error response", @@ -106,6 +106,21 @@ func TestParseMCPToolsList(t *testing.T) { input: `not json at all`, wantErr: true, }, + { + name: "valid empty tools object", + input: `{"tools": []}`, + wantTools: 0, + }, + { + name: "empty object with neither key fails", + input: `{}`, + wantErr: true, + }, + { + name: "prose with embedded object fails", + input: `prefix {"tools":[{"name":"tool1"}]} suffix`, + wantErr: true, + }, } for _, tt := range tests { diff --git a/internal/auxiliary/workspace_manager.go b/internal/auxiliary/workspace_manager.go index 6780c95f..99550fea 100644 --- a/internal/auxiliary/workspace_manager.go +++ b/internal/auxiliary/workspace_manager.go @@ -4,11 +4,22 @@ import ( "context" "fmt" "log/slog" + "os" + "path/filepath" "sort" "strings" "sync" + "time" + + "github.com/inercia/mitto/internal/agents" + "github.com/inercia/mitto/internal/fileutil" + "github.com/inercia/mitto/internal/mcpdiscovery" ) +// defaultMCPToolsTTL bounds how long a persisted real-MCP tools snapshot is +// reused after a restart before a fresh probe is required (mitto-sys.8). +const defaultMCPToolsTTL = 15 * time.Minute + // Purpose constants for auxiliary sessions const ( PurposeTitleGen = "title-gen" @@ -51,15 +62,73 @@ type WorkspaceAuxiliaryManager struct { // Cache for MCP tools list per workspace mcpToolsCache map[string][]MCPToolInfo mcpToolsCacheMu sync.RWMutex + + // mcpToolsNegatives tracks, per workspace and tool name, the number of + // consecutive successful-but-negative fetches (the tool was previously + // known but absent from the latest fetch). Guarded by mcpToolsCacheMu + // (same lock as mcpToolsCache, since applyMCPToolsCachePolicy reads and + // writes both together). See docs/devel/mcp-tool-discovery.md (Q3.1): + // a tool is only downgraded to absent after 2 consecutive negatives. + mcpToolsNegatives map[string]map[string]int + + // StdioToolsDiscoverer, when set, deterministically discovers a workspace's + // MCP tools via direct tools/list (per configured stdio server). Injected + // by the web layer. When nil (tests/CLI), FetchMCPTools uses the LLM path + // only (legacy). + StdioToolsDiscoverer func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) + + // mcpBackoffActive dedups the per-workspace backoff re-probe goroutine + // (at most one per workspace). Guarded by mcpBackoffMu. + mcpBackoffActive map[string]bool + mcpBackoffMu sync.Mutex + // mcpBackoffPolicy is the schedule used by EnsureMCPBackoffRetry; defaults + // to mcpdiscovery.DefaultBackoffPolicy(), overridable in tests for speed. + mcpBackoffPolicy mcpdiscovery.BackoffPolicy + + // MCPServerLister, when set, enumerates a workspace's configured MCP + // servers (Command/Args/URL/Env) so EnsureMCPWatchers can open one + // persistent notifications/tools/list_changed watcher per server. + // Injected by the web layer (mitto-sys.4 increment 3/N). When nil + // (tests/CLI), EnsureMCPWatchers is a no-op. + MCPServerLister func(ctx context.Context, workspaceUUID string) ([]agents.MCPServer, error) + + // MCPWatchTransportFactory builds the transport used by EnsureMCPWatchers' + // mcpdiscovery.WatchServer calls; nil defaults to + // mcpdiscovery.DefaultTransportFactory. Overridable in tests to inject + // in-memory transports. + MCPWatchTransportFactory mcpdiscovery.TransportFactory + + // mcpWatchers holds, per workspace, the live persistent watchers opened + // by EnsureMCPWatchers (one per configured server). Key presence (not + // list length) marks a pool as active/starting — see EnsureMCPWatchers' + // dedup and StopMCPWatchers' teardown. Guarded by mcpWatchersMu. + mcpWatchers map[string][]*mcpdiscovery.ToolListWatcher + mcpWatchersMu sync.Mutex + + // MCPToolsPersistDir, when non-empty, enables on-disk persistence of the + // real-MCP-derived (deterministic) tools list per workspace, so a restart + // reuses the last snapshot within the TTL instead of re-probing every + // server. The web layer wires it to an appdir-based directory; empty + // (tests/CLI) disables persistence. Only deterministic results are ever + // written — never the LLM fallback (docs/devel/mcp-tool-discovery.md, Q3.3). + MCPToolsPersistDir string + // mcpToolsTTL bounds reuse of a persisted snapshot; defaults to + // defaultMCPToolsTTL. Overridable in tests for speed. + mcpToolsTTL time.Duration } // NewWorkspaceAuxiliaryManager creates a new workspace-scoped auxiliary manager. func NewWorkspaceAuxiliaryManager(provider ProcessProvider, logger *slog.Logger) *WorkspaceAuxiliaryManager { return &WorkspaceAuxiliaryManager{ - provider: provider, - logger: logger, - mcpCheckCache: make(map[string]*MCPAvailabilityResult), - mcpToolsCache: make(map[string][]MCPToolInfo), + provider: provider, + logger: logger, + mcpCheckCache: make(map[string]*MCPAvailabilityResult), + mcpToolsCache: make(map[string][]MCPToolInfo), + mcpToolsNegatives: make(map[string]map[string]int), + mcpBackoffActive: make(map[string]bool), + mcpBackoffPolicy: mcpdiscovery.DefaultBackoffPolicy(), + mcpWatchers: make(map[string][]*mcpdiscovery.ToolListWatcher), + mcpToolsTTL: defaultMCPToolsTTL, } } @@ -285,70 +354,551 @@ func (m *WorkspaceAuxiliaryManager) FetchMCPTools(ctx context.Context, workspace } m.mcpToolsCacheMu.RUnlock() - if m.logger != nil { - m.logger.Debug("mcp tools fetch: starting", - "workspace_uuid", workspaceUUID) - } - - response, err := m.provider.PromptAuxiliary(ctx, workspaceUUID, PurposeMCPTools, FetchMCPToolsPromptTemplate) - if err != nil { + // Real-MCP persistence (mitto-sys.8): within the TTL, reuse the on-disk + // snapshot of the last deterministic tools/list result instead of + // re-probing every server on restart. Only real-MCP results are ever + // persisted, so this never resurrects an LLM-hallucinated list. A missing + // or expired snapshot falls through to a fresh probe below. + if persisted, ok := m.loadPersistedMCPTools(workspaceUUID); ok { + m.mcpToolsCacheMu.Lock() + m.mcpToolsCache[workspaceUUID] = persisted + m.mcpToolsCacheMu.Unlock() if m.logger != nil { - m.logger.Debug("mcp tools fetch: request failed", + m.logger.Debug("mcp tools fetch: using persisted real-MCP snapshot", "workspace_uuid", workspaceUUID, - "error", err.Error()) + "tool_count", len(persisted)) + } + return persisted, nil + } + + // Try deterministic stdio discovery first (mitto-sys.2/mitto-sys.6): a + // real tools/list per configured stdio server, no LLM involved. The LLM + // fallback below only runs when discovery is unavailable, errors, or + // reports at least one unreachable server (that server's tools may still + // only be knowable via the LLM, or it may just be starting up). + var deterministic []MCPToolInfo + detNames := map[string]bool{} + needLLM := true + configuredServerCount := -1 + + if m.StdioToolsDiscoverer != nil { + results, derr := m.StdioToolsDiscoverer(ctx, workspaceUUID) + if derr != nil { + if m.logger != nil { + m.logger.Debug("mcp tools fetch: stdio discovery failed, falling back to LLM", + "workspace_uuid", workspaceUUID, + "error", derr.Error()) + } + } else { + configuredServerCount = len(results) + anyUnreachable := false + for _, r := range results { + if !r.Reachable { + anyUnreachable = true + continue + } + for _, name := range r.Tools { + if detNames[name] { + continue + } + detNames[name] = true + deterministic = append(deterministic, MCPToolInfo{Name: name}) + } + } + needLLM = anyUnreachable || len(results) == 0 + if m.logger != nil { + m.logger.Debug("mcp tools fetch: stdio discovery completed", + "workspace_uuid", workspaceUUID, + "server_count", len(results), + "tool_count", len(deterministic), + "any_unreachable", anyUnreachable, + "need_llm", needLLM) + } } - return nil, fmt.Errorf("failed to fetch MCP tools: %w", err) } + merged := deterministic + + if needLLM { + llmTools, err := m.fetchMCPToolsViaLLM(ctx, workspaceUUID, configuredServerCount) + if err != nil { + if len(deterministic) == 0 { + return nil, err + } + if m.logger != nil { + m.logger.Warn("mcp tools fetch: LLM fallback failed, using deterministic tools only", + "workspace_uuid", workspaceUUID, + "error", err.Error()) + } + } else { + for _, tool := range llmTools { + if detNames[tool.Name] { + continue // deterministic entries win on name collision + } + merged = append(merged, tool) + } + } + } + + // Sort tools alphabetically by name. + sort.Slice(merged, func(i, j int) bool { + return merged[i].Name < merged[j].Name + }) + + // Merge into the cache using the last-known-good policy: a previously + // observed tool is never downgraded to absent on a single negative + // fetch (see applyMCPToolsCachePolicy). + result := m.applyMCPToolsCachePolicy(workspaceUUID, merged) + + // Persist ONLY the real-MCP-derived (deterministic) list to disk — never + // the LLM fallback (mitto-sys.8, ADR Q3.3). A no-op when persistence is + // disabled or discovery produced no tools. A subsequent restart reuses this + // snapshot within the TTL instead of re-probing. + m.savePersistedMCPTools(workspaceUUID, deterministic) + if m.logger != nil { - m.logger.Debug("mcp tools fetch: received response", + m.logger.Info("MCP tools fetch completed", "workspace_uuid", workspaceUUID, - "response_length", len(response)) + "tool_count", len(result)) } - tools, agentError, err := parseMCPToolsList(response) - if err != nil { + return result, nil +} + +// maxMCPToolsAttempts bounds fetchMCPToolsViaLLM's retry loop (mitto-sys.7): +// one initial attempt plus one retry when the response fails strict parsing +// or is implausibly empty. +const maxMCPToolsAttempts = 2 + +// fetchMCPToolsViaLLM queries the agent's own LLM to introspect its MCP +// tools — the pre-mitto-sys.2 fallback path, used by FetchMCPTools when +// deterministic stdio discovery is unavailable, errors, or reports an +// unreachable server. Callers decide whether an error here is fatal +// (no deterministic tools to fall back on) or can be ignored. +// +// It retries once (mitto-sys.7, ADR Q2) when the response fails strict +// parsing, or when it plausibility-fails: zero tools returned while +// configuredServerCount indicates servers were actually configured +// (configuredServerCount <= 0 means "unknown/no discoverer", which skips the +// plausibility check and accepts a zero-tool response outright). An explicit +// agent-reported error is always definitive and never retried. +func (m *WorkspaceAuxiliaryManager) fetchMCPToolsViaLLM(ctx context.Context, workspaceUUID string, configuredServerCount int) ([]MCPToolInfo, error) { + var lastErr error + + for attempt := 1; attempt <= maxMCPToolsAttempts; attempt++ { if m.logger != nil { - m.logger.Warn("mcp tools fetch: failed to parse response", + m.logger.Debug("mcp tools fetch: starting", "workspace_uuid", workspaceUUID, - "error", err.Error(), - "response", truncateForLog(response, 200)) + "attempt", attempt) + } + + prompt := FetchMCPToolsPromptTemplate + if attempt > 1 { + prompt += "\n\n" + mcpToolsRetryReminder + } + + response, err := m.provider.PromptAuxiliary(ctx, workspaceUUID, PurposeMCPTools, prompt) + if err != nil { + if m.logger != nil { + m.logger.Debug("mcp tools fetch: request failed", + "workspace_uuid", workspaceUUID, + "attempt", attempt, + "error", err.Error()) + } + lastErr = fmt.Errorf("failed to fetch MCP tools: %w", err) + if ctx.Err() != nil { + return nil, lastErr + } + continue } - return nil, fmt.Errorf("failed to parse MCP tools response: %w", err) - } - if agentError != "" { if m.logger != nil { - m.logger.Warn("mcp tools fetch: agent reported error", + m.logger.Debug("mcp tools fetch: received response", "workspace_uuid", workspaceUUID, - "agent_error", agentError) + "attempt", attempt, + "response_length", len(response)) + } + + tools, agentError, perr := parseMCPToolsList(response) + if perr != nil { + if m.logger != nil { + m.logger.Warn("mcp tools fetch: failed to parse response", + "workspace_uuid", workspaceUUID, + "attempt", attempt, + "error", perr.Error(), + "response", truncateForLog(response, 200)) + } + lastErr = fmt.Errorf("failed to parse MCP tools response: %w", perr) + continue + } + + if agentError != "" { + if m.logger != nil { + m.logger.Warn("mcp tools fetch: agent reported error", + "workspace_uuid", workspaceUUID, + "attempt", attempt, + "agent_error", agentError) + } + // If the agent reported an error but also returned some tools, use + // them. If no tools were returned, propagate the error. Either way, + // this is definitive — never retried. + if len(tools) == 0 { + return nil, fmt.Errorf("agent error: %s", agentError) + } + return tools, nil } - // If the agent reported an error but also returned some tools, use them. - // If no tools were returned, propagate the error. - if len(tools) == 0 { - return nil, fmt.Errorf("agent error: %s", agentError) + + if len(tools) == 0 && configuredServerCount > 0 && attempt < maxMCPToolsAttempts { + if m.logger != nil { + m.logger.Debug("mcp tools fetch: implausible empty response, retrying", + "workspace_uuid", workspaceUUID, + "attempt", attempt, + "configured_server_count", configuredServerCount) + } + lastErr = fmt.Errorf("implausible empty tools: %d server(s) configured", configuredServerCount) + continue } + + return tools, nil } - // Sort tools alphabetically by name. - sort.Slice(tools, func(i, j int) bool { - return tools[i].Name < tools[j].Name - }) + return nil, lastErr +} - // Only cache non-empty results. - if len(tools) > 0 { - m.mcpToolsCacheMu.Lock() - m.mcpToolsCache[workspaceUUID] = tools - m.mcpToolsCacheMu.Unlock() +// applyMCPToolsCachePolicy merges a fresh, successful MCP tools fetch into +// the per-workspace cache using a last-known-good policy +// (docs/devel/mcp-tool-discovery.md, Q3.1): a tool observed in a previous +// fetch is never downgraded to absent on a single negative fetch — removal +// requires two consecutive independent negative fetches. A tool that +// reappears in fresh resets its negative counter. Callers must only invoke +// this for a SUCCESSFUL fetch; errors/timeouts must never count as a negative. +// +// fresh is the tool list from the latest successful fetch (may be empty). +// The merged result is retained-known-tools (refreshed from fresh when +// present) plus any new fresh tools, sorted alphabetically by Name; it is +// stored into mcpToolsCache[workspaceUUID] and returned. When there is no +// prior cache entry and fresh is empty, no entry is created (returns nil, +// cache left untouched) — an empty first fetch establishes nothing. +func (m *WorkspaceAuxiliaryManager) applyMCPToolsCachePolicy(workspaceUUID string, fresh []MCPToolInfo) []MCPToolInfo { + m.mcpToolsCacheMu.Lock() + defer m.mcpToolsCacheMu.Unlock() + + cached, hadCache := m.mcpToolsCache[workspaceUUID] + if !hadCache && len(fresh) == 0 { + return nil } - if m.logger != nil { - m.logger.Info("MCP tools fetch completed", - "workspace_uuid", workspaceUUID, - "tool_count", len(tools)) + freshByName := make(map[string]MCPToolInfo, len(fresh)) + for _, tool := range fresh { + freshByName[tool.Name] = tool } - return tools, nil + negatives := m.mcpToolsNegatives[workspaceUUID] + if negatives == nil { + negatives = make(map[string]int) + } + + merged := make([]MCPToolInfo, 0, len(cached)+len(fresh)) + kept := make(map[string]bool, len(cached)) + + for _, tool := range cached { + if freshTool, ok := freshByName[tool.Name]; ok { + delete(negatives, tool.Name) + merged = append(merged, freshTool) + kept[tool.Name] = true + continue + } + negatives[tool.Name]++ + if negatives[tool.Name] >= 2 { + delete(negatives, tool.Name) // two consecutive negatives: remove + continue + } + merged = append(merged, tool) // last-known-good: retain as-is + kept[tool.Name] = true + } + + for _, tool := range fresh { + if kept[tool.Name] { + continue + } + merged = append(merged, tool) + delete(negatives, tool.Name) + } + + sort.Slice(merged, func(i, j int) bool { return merged[i].Name < merged[j].Name }) + + if len(negatives) > 0 { + m.mcpToolsNegatives[workspaceUUID] = negatives + } else { + delete(m.mcpToolsNegatives, workspaceUUID) + } + m.mcpToolsCache[workspaceUUID] = merged + + return merged +} + +// mergeMCPToolsAdditive merges freshly-discovered tools into the workspace +// cache WITHOUT the two-negatives downgrade: tools absent from `fresh` are +// retained unconditionally, so a partial backoff re-probe (which only sees +// currently-reachable servers) never evicts tools contributed by other +// servers or the LLM fallback. Refreshes descriptions of tools present in +// fresh. Returns the merged, name-sorted list and whether it changed vs the +// prior cache. Takes mcpToolsCacheMu. +func (m *WorkspaceAuxiliaryManager) mergeMCPToolsAdditive(workspaceUUID string, fresh []MCPToolInfo) ([]MCPToolInfo, bool) { + m.mcpToolsCacheMu.Lock() + defer m.mcpToolsCacheMu.Unlock() + + cached := m.mcpToolsCache[workspaceUUID] + + byName := make(map[string]MCPToolInfo, len(cached)+len(fresh)) + order := make([]string, 0, len(cached)+len(fresh)) + for _, tool := range cached { + byName[tool.Name] = tool + order = append(order, tool.Name) + } + + changed := false + for _, tool := range fresh { + if existing, ok := byName[tool.Name]; ok { + if existing != tool { + byName[tool.Name] = tool + changed = true + } + continue + } + byName[tool.Name] = tool + order = append(order, tool.Name) + changed = true + } + + if !changed { + return cached, false + } + + merged := make([]MCPToolInfo, 0, len(order)) + for _, name := range order { + merged = append(merged, byName[name]) + } + sort.Slice(merged, func(i, j int) bool { return merged[i].Name < merged[j].Name }) + m.mcpToolsCache[workspaceUUID] = merged + + if negatives := m.mcpToolsNegatives[workspaceUUID]; negatives != nil { + for _, tool := range fresh { + delete(negatives, tool.Name) + } + if len(negatives) == 0 { + delete(m.mcpToolsNegatives, workspaceUUID) + } + } + + return merged, true +} + +// EnsureMCPBackoffRetry starts, at most once per workspace, a bounded +// exponential-backoff goroutine that re-probes configured-but-unreachable +// MCP servers via StdioToolsDiscoverer until every server is reachable, ctx +// is cancelled, or the backoff attempts are exhausted (mitto-sys.5). Each +// round additively merges newly-reachable servers' tools into the cache +// (last-known-good; never downgrades) and, when the tool set changed, +// invokes onUpdate with the merged list so the caller can broadcast. No-op +// when StdioToolsDiscoverer is nil or a loop is already active for the ws. +func (m *WorkspaceAuxiliaryManager) EnsureMCPBackoffRetry(ctx context.Context, workspaceUUID string, onUpdate func([]MCPToolInfo)) { + if m.StdioToolsDiscoverer == nil { + return + } + + m.mcpBackoffMu.Lock() + if m.mcpBackoffActive[workspaceUUID] { + m.mcpBackoffMu.Unlock() + return + } + m.mcpBackoffActive[workspaceUUID] = true + m.mcpBackoffMu.Unlock() + + go func() { + defer func() { + m.mcpBackoffMu.Lock() + delete(m.mcpBackoffActive, workspaceUUID) + m.mcpBackoffMu.Unlock() + }() + + if m.logger != nil { + m.logger.Debug("mcp backoff: starting", "workspace_uuid", workspaceUUID) + } + + policy := m.mcpBackoffPolicy + probe := func(pctx context.Context) mcpdiscovery.ServerToolsResult { + results, err := m.StdioToolsDiscoverer(pctx, workspaceUUID) + if err != nil { + return mcpdiscovery.ServerToolsResult{Server: workspaceUUID, Reachable: false, Err: err} + } + + var reachableTools []MCPToolInfo + seen := map[string]bool{} + unreachable := 0 + for _, r := range results { + if !r.Reachable { + unreachable++ + continue + } + for _, name := range r.Tools { + if seen[name] { + continue + } + seen[name] = true + reachableTools = append(reachableTools, MCPToolInfo{Name: name}) + } + } + + merged, changed := m.mergeMCPToolsAdditive(workspaceUUID, reachableTools) + if changed && onUpdate != nil { + onUpdate(merged) + } + + // Stop only when every configured server responded (none unreachable). + return mcpdiscovery.ServerToolsResult{Server: workspaceUUID, Reachable: unreachable == 0} + } + + _, allReachable := mcpdiscovery.RetryUntilReachable(ctx, policy, probe, nil) + + if m.logger != nil { + m.logger.Debug("mcp backoff: stopped", + "workspace_uuid", workspaceUUID, + "all_reachable", allReachable) + } + }() +} + +// EnsureMCPWatchers starts, at most once per workspace, a pool of persistent +// notifications/tools/list_changed watchers — one per server returned by +// MCPServerLister (mitto-sys.4, event-driven tool refresh, complementary to +// EnsureMCPBackoffRetry's polling). Each watcher's initial tools/list result +// and every subsequent change notification are additively merged into the +// workspace's tools cache via mergeMCPToolsAdditive (never removes tools — +// removal-on-list_changed is a documented follow-up, kept out of this +// increment for parity with the backoff path); onUpdate is invoked with the +// merged list whenever a merge actually changes it. No-op when +// MCPServerLister is nil or a pool is already active/starting for the +// workspace. +func (m *WorkspaceAuxiliaryManager) EnsureMCPWatchers(ctx context.Context, workspaceUUID string, onUpdate func([]MCPToolInfo)) { + if m.MCPServerLister == nil { + return + } + + m.mcpWatchersMu.Lock() + if _, exists := m.mcpWatchers[workspaceUUID]; exists { + m.mcpWatchersMu.Unlock() + return + } + // Reserve the slot immediately (present-but-nil) so concurrent Ensure + // calls dedup against this pool while servers are still being listed + // and connected. + m.mcpWatchers[workspaceUUID] = nil + m.mcpWatchersMu.Unlock() + + go func() { + if m.logger != nil { + m.logger.Debug("mcp watchers: starting", "workspace_uuid", workspaceUUID) + } + + servers, err := m.MCPServerLister(ctx, workspaceUUID) + if err != nil { + if m.logger != nil { + m.logger.Debug("mcp watchers: server lister failed", + "workspace_uuid", workspaceUUID, "error", err.Error()) + } + // Release the reservation so a future EnsureMCPWatchers call for + // this workspace can retry, instead of being permanently deduped + // against a pool that never started. Only clear it if it's still + // the empty reservation we made above (a concurrent pool may have + // since been created/populated after StopMCPWatchers, though that + // races with this failed attempt). + m.mcpWatchersMu.Lock() + if w, ok := m.mcpWatchers[workspaceUUID]; ok && len(w) == 0 { + delete(m.mcpWatchers, workspaceUUID) + } + m.mcpWatchersMu.Unlock() + return + } + + started := 0 + for _, srv := range servers { + onChange := func(res mcpdiscovery.ServerToolsResult) { + if !res.Reachable { + return + } + tools := make([]MCPToolInfo, 0, len(res.Tools)) + for _, name := range res.Tools { + tools = append(tools, MCPToolInfo{Name: name}) + } + merged, changed := m.mergeMCPToolsAdditive(workspaceUUID, tools) + if changed && onUpdate != nil { + onUpdate(merged) + } + } + + w, initial, werr := mcpdiscovery.WatchServer(ctx, srv, m.MCPWatchTransportFactory, onChange) + if werr != nil { + // Unreachable at watch-time: EnsureMCPBackoffRetry's polling + // path is responsible for retrying this server. + if m.logger != nil { + m.logger.Debug("mcp watchers: server unreachable, skipping", + "workspace_uuid", workspaceUUID, "server", srv.Name, "error", werr.Error()) + } + continue + } + + onChange(initial) // surface tools already present at startup + + m.mcpWatchersMu.Lock() + if _, exists := m.mcpWatchers[workspaceUUID]; !exists { + // StopMCPWatchers/CloseAllMCPWatchers ran while we were + // connecting: this pool was torn down. Close the just-opened + // watcher and stop starting any more. + m.mcpWatchersMu.Unlock() + w.Close() + return + } + m.mcpWatchers[workspaceUUID] = append(m.mcpWatchers[workspaceUUID], w) + m.mcpWatchersMu.Unlock() + started++ + } + + if m.logger != nil { + m.logger.Debug("mcp watchers: started", + "workspace_uuid", workspaceUUID, + "server_count", len(servers), + "watcher_count", started) + } + }() +} + +// StopMCPWatchers closes and removes all active watchers for a workspace. +// Safe to call when no pool is active (no-op). +func (m *WorkspaceAuxiliaryManager) StopMCPWatchers(workspaceUUID string) { + m.mcpWatchersMu.Lock() + watchers := m.mcpWatchers[workspaceUUID] + delete(m.mcpWatchers, workspaceUUID) + m.mcpWatchersMu.Unlock() + + for _, w := range watchers { + w.Close() + } +} + +// CloseAllMCPWatchers tears down every active watcher across all workspaces. +// Intended for server shutdown. Idempotent/safe when there are none. +func (m *WorkspaceAuxiliaryManager) CloseAllMCPWatchers() { + m.mcpWatchersMu.Lock() + all := m.mcpWatchers + m.mcpWatchers = make(map[string][]*mcpdiscovery.ToolListWatcher) + m.mcpWatchersMu.Unlock() + + for _, watchers := range all { + for _, w := range watchers { + w.Close() + } + } } // ClearMCPToolsCache clears the cached MCP tools list for a workspace. @@ -358,6 +908,10 @@ func (m *WorkspaceAuxiliaryManager) ClearMCPToolsCache(workspaceUUID string) { delete(m.mcpToolsCache, workspaceUUID) m.mcpToolsCacheMu.Unlock() + // Also drop the persisted snapshot so a manual refresh forces a fresh + // probe rather than reusing stale disk state (mitto-sys.8). + m.deletePersistedMCPTools(workspaceUUID) + if m.logger != nil { m.logger.Debug("cleared MCP tools cache", "workspace_uuid", workspaceUUID) @@ -373,6 +927,94 @@ func (m *WorkspaceAuxiliaryManager) GetCachedMCPTools(workspaceUUID string) ([]M return cached, ok } +// persistedMCPTools is the on-disk representation of a workspace's +// real-MCP-derived tools snapshot (mitto-sys.8). Only deterministic +// (direct tools/list) results are persisted; the LLM fallback is never +// written to disk (docs/devel/mcp-tool-discovery.md, Q3.3). +type persistedMCPTools struct { + Tools []MCPToolInfo `json:"tools"` + UpdatedAt time.Time `json:"updated_at"` +} + +// mcpToolsPersistPath returns the on-disk path for a workspace's persisted +// real-MCP tools snapshot, or "" when persistence is disabled or the workspace +// UUID is empty. +func (m *WorkspaceAuxiliaryManager) mcpToolsPersistPath(workspaceUUID string) string { + if m.MCPToolsPersistDir == "" || workspaceUUID == "" { + return "" + } + return filepath.Join(m.MCPToolsPersistDir, workspaceUUID+".json") +} + +// loadPersistedMCPTools returns the persisted real-MCP tools for a workspace +// when a snapshot exists and is still within the TTL. Returns (nil, false) when +// persistence is disabled, the file is missing/unreadable, the snapshot is +// empty, or it has expired (a stale snapshot forces a fresh probe). +func (m *WorkspaceAuxiliaryManager) loadPersistedMCPTools(workspaceUUID string) ([]MCPToolInfo, bool) { + path := m.mcpToolsPersistPath(workspaceUUID) + if path == "" { + return nil, false + } + + var snapshot persistedMCPTools + if err := fileutil.ReadJSON(path, &snapshot); err != nil { + return nil, false + } + if len(snapshot.Tools) == 0 { + return nil, false + } + + ttl := m.mcpToolsTTL + if ttl <= 0 { + ttl = defaultMCPToolsTTL + } + if age := time.Since(snapshot.UpdatedAt); age > ttl { + if m.logger != nil { + m.logger.Debug("mcp tools persist: snapshot expired, forcing re-probe", + "workspace_uuid", workspaceUUID, + "age", age.String()) + } + return nil, false + } + + return snapshot.Tools, true +} + +// savePersistedMCPTools writes a workspace's real-MCP-derived tools snapshot to +// disk atomically. It is a no-op when persistence is disabled or tools is empty. +// Only deterministic results must be passed here — never the LLM fallback list. +func (m *WorkspaceAuxiliaryManager) savePersistedMCPTools(workspaceUUID string, tools []MCPToolInfo) { + path := m.mcpToolsPersistPath(workspaceUUID) + if path == "" || len(tools) == 0 { + return + } + + snapshot := persistedMCPTools{Tools: tools, UpdatedAt: time.Now()} + if err := fileutil.WriteJSONAtomic(path, &snapshot, 0o644); err != nil { + if m.logger != nil { + m.logger.Warn("mcp tools persist: failed to write snapshot", + "workspace_uuid", workspaceUUID, + "error", err.Error()) + } + } +} + +// deletePersistedMCPTools removes a workspace's persisted tools snapshot, if +// any. Used by ClearMCPToolsCache so a manual refresh forces a fresh probe. +func (m *WorkspaceAuxiliaryManager) deletePersistedMCPTools(workspaceUUID string) { + path := m.mcpToolsPersistPath(workspaceUUID) + if path == "" { + return + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + if m.logger != nil { + m.logger.Warn("mcp tools persist: failed to remove snapshot", + "workspace_uuid", workspaceUUID, + "error", err.Error()) + } + } +} + // ClearMCPCheckCache clears the cached MCP availability result for a workspace. // This can be used to force a re-check, for example after installing the MCP server. func (m *WorkspaceAuxiliaryManager) ClearMCPCheckCache(workspaceUUID string) { diff --git a/internal/auxiliary/workspace_manager_test.go b/internal/auxiliary/workspace_manager_test.go index 94730c42..8c786d3c 100644 --- a/internal/auxiliary/workspace_manager_test.go +++ b/internal/auxiliary/workspace_manager_test.go @@ -3,8 +3,16 @@ package auxiliary import ( "context" "errors" + "os" + "path/filepath" "strings" + "sync" + "sync/atomic" "testing" + "time" + + "github.com/inercia/mitto/internal/fileutil" + "github.com/inercia/mitto/internal/mcpdiscovery" ) // mockProcessProvider is a mock implementation of ProcessProvider for testing. @@ -339,3 +347,791 @@ func TestWorkspaceAuxiliaryManager_ClearMCPCheckCache(t *testing.T) { t.Errorf("Expected 2 calls after cache clear, got %d", callCount) } } + +// ============================================================================= +// applyMCPToolsCachePolicy: last-known-good / two-consecutive-negatives +// (mitto-sys.6, docs/devel/mcp-tool-discovery.md Q3.1) +// ============================================================================= + +// mcpToolNames returns the sorted tool names from a []MCPToolInfo, for +// concise assertions. +func mcpToolNames(tools []MCPToolInfo) []string { + names := make([]string, len(tools)) + for i, tool := range tools { + names[i] = tool.Name + } + return names +} + +func assertToolNames(t *testing.T, got []MCPToolInfo, want ...string) { + t.Helper() + gotNames := mcpToolNames(got) + if len(gotNames) != len(want) { + t.Fatalf("tool names = %v, want %v", gotNames, want) + } + for i, name := range want { + if gotNames[i] != name { + t.Fatalf("tool names = %v, want %v", gotNames, want) + } + } +} + +func TestApplyMCPToolsCachePolicy_FirstNonEmptyFetchStores(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + + got := mgr.applyMCPToolsCachePolicy("ws", []MCPToolInfo{ + {Name: "jira_create_issue", Description: "create"}, + {Name: "jira_search", Description: "search"}, + }) + + assertToolNames(t, got, "jira_create_issue", "jira_search") + cached, ok := mgr.GetCachedMCPTools("ws") + if !ok { + t.Fatalf("expected cache entry after first non-empty fetch") + } + assertToolNames(t, cached, "jira_create_issue", "jira_search") +} + +func TestApplyMCPToolsCachePolicy_FirstEmptyFetchEstablishesNothing(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + + got := mgr.applyMCPToolsCachePolicy("ws", nil) + + if got != nil { + t.Fatalf("expected nil result for first empty fetch, got %v", got) + } + if _, ok := mgr.GetCachedMCPTools("ws"); ok { + t.Fatalf("expected no cache entry after first empty fetch") + } +} + +func TestApplyMCPToolsCachePolicy_OneNegativeRetainsTool(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + + mgr.applyMCPToolsCachePolicy("ws", []MCPToolInfo{{Name: "jira_search", Description: "search"}}) + + // One empty fetch: the previously-known tool must be retained. + got := mgr.applyMCPToolsCachePolicy("ws", nil) + + assertToolNames(t, got, "jira_search") +} + +func TestApplyMCPToolsCachePolicy_TwoConsecutiveNegativesRemove(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + + mgr.applyMCPToolsCachePolicy("ws", []MCPToolInfo{{Name: "jira_search", Description: "search"}}) + mgr.applyMCPToolsCachePolicy("ws", nil) // 1st negative: retained + + got := mgr.applyMCPToolsCachePolicy("ws", nil) // 2nd consecutive negative: removed + + if len(got) != 0 { + t.Fatalf("expected tool removed after 2 consecutive negatives, got %v", mcpToolNames(got)) + } + cached, ok := mgr.GetCachedMCPTools("ws") + if !ok || len(cached) != 0 { + t.Fatalf("expected an empty (but present) cache entry, got ok=%v cached=%v", ok, cached) + } +} + +func TestApplyMCPToolsCachePolicy_ReappearanceResetsCounter(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + + mgr.applyMCPToolsCachePolicy("ws", []MCPToolInfo{{Name: "jira_search", Description: "search"}}) + mgr.applyMCPToolsCachePolicy("ws", nil) // 1st negative: retained + + // Tool reappears: counter must reset. + got := mgr.applyMCPToolsCachePolicy("ws", []MCPToolInfo{{Name: "jira_search", Description: "search v2"}}) + assertToolNames(t, got, "jira_search") + + // A single subsequent empty fetch must NOT remove it (counter was reset, + // this is only the first negative again). + got = mgr.applyMCPToolsCachePolicy("ws", nil) + assertToolNames(t, got, "jira_search") +} + +func TestApplyMCPToolsCachePolicy_NewToolAdded(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + + mgr.applyMCPToolsCachePolicy("ws", []MCPToolInfo{{Name: "jira_search", Description: "search"}}) + + got := mgr.applyMCPToolsCachePolicy("ws", []MCPToolInfo{ + {Name: "jira_search", Description: "search"}, + {Name: "jira_create_issue", Description: "create"}, + }) + + assertToolNames(t, got, "jira_create_issue", "jira_search") +} + +func TestApplyMCPToolsCachePolicy_DescriptionRefreshedWhenPresentInFresh(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + + mgr.applyMCPToolsCachePolicy("ws", []MCPToolInfo{{Name: "jira_search", Description: "old description"}}) + + got := mgr.applyMCPToolsCachePolicy("ws", []MCPToolInfo{{Name: "jira_search", Description: "new description"}}) + + if len(got) != 1 || got[0].Description != "new description" { + t.Fatalf("expected description refreshed to %q, got %+v", "new description", got) + } +} + +func TestApplyMCPToolsCachePolicy_WorkspacesAreIsolated(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + + mgr.applyMCPToolsCachePolicy("ws-a", []MCPToolInfo{{Name: "jira_search"}}) + mgr.applyMCPToolsCachePolicy("ws-b", []MCPToolInfo{{Name: "slack_post"}}) + + // A negative fetch for ws-a must not affect ws-b's cache/negatives. + mgr.applyMCPToolsCachePolicy("ws-a", nil) + + cachedB, ok := mgr.GetCachedMCPTools("ws-b") + if !ok { + t.Fatalf("expected ws-b cache entry to remain untouched") + } + assertToolNames(t, cachedB, "slack_post") +} + +// ============================================================================= +// FetchMCPTools + StdioToolsDiscoverer: deterministic discovery with LLM +// fallback (mitto-sys.2 acceptance #1, mitto-sys.6) +// ============================================================================= + +func TestFetchMCPTools_NilDiscoverer_PureLLM(t *testing.T) { + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + return `{"tools":[{"name":"jira_search","description":"search"}]}`, nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) // StdioToolsDiscoverer left nil + + tools, err := mgr.FetchMCPTools(context.Background(), "ws") + if err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + assertToolNames(t, tools, "jira_search") + + cached, ok := mgr.GetCachedMCPTools("ws") + if !ok { + t.Fatalf("expected cache entry") + } + assertToolNames(t, cached, "jira_search") +} + +func TestFetchMCPTools_AllReachable_SkipsLLM(t *testing.T) { + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + t.Fatal("LLM provider must not be called when all servers are reachable") + return "", nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + return []mcpdiscovery.ServerToolsResult{ + {Server: "jira", Reachable: true, Tools: []string{"jira_create_issue", "jira_search"}}, + }, nil + } + + tools, err := mgr.FetchMCPTools(context.Background(), "ws") + if err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + assertToolNames(t, tools, "jira_create_issue", "jira_search") + + cached, ok := mgr.GetCachedMCPTools("ws") + if !ok { + t.Fatalf("expected cache entry") + } + assertToolNames(t, cached, "jira_create_issue", "jira_search") +} + +func TestFetchMCPTools_UnreachableServer_UnionsWithLLM(t *testing.T) { + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + return `{"tools":[{"name":"slack_post","description":"post"}]}`, nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + return []mcpdiscovery.ServerToolsResult{ + {Server: "jira", Reachable: true, Tools: []string{"jira_search"}}, + {Server: "slack", Reachable: false, Err: errors.New("timeout")}, + }, nil + } + + tools, err := mgr.FetchMCPTools(context.Background(), "ws") + if err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + assertToolNames(t, tools, "jira_search", "slack_post") +} + +func TestFetchMCPTools_DiscovererError_FallsBackToLLM(t *testing.T) { + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + return `{"tools":[{"name":"jira_search","description":"search"}]}`, nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + return nil, errors.New("boom") + } + + tools, err := mgr.FetchMCPTools(context.Background(), "ws") + if err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + assertToolNames(t, tools, "jira_search") +} + +func TestFetchMCPTools_AllReachableZeroTools_NoCacheEntry(t *testing.T) { + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + t.Fatal("LLM provider must not be called when all servers are reachable") + return "", nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + return []mcpdiscovery.ServerToolsResult{ + {Server: "empty-server", Reachable: true, Tools: nil}, + }, nil + } + + tools, err := mgr.FetchMCPTools(context.Background(), "ws") + if err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + if len(tools) != 0 { + t.Fatalf("expected empty tools, got %v", tools) + } + if _, ok := mgr.GetCachedMCPTools("ws"); ok { + t.Fatalf("expected no cache entry (policy no-op on first empty fetch)") + } +} + +func TestFetchMCPTools_DedupBetweenDeterministicAndLLM(t *testing.T) { + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + return `{"tools":[{"name":"jira_search","description":"llm description"},{"name":"slack_post","description":"post"}]}`, nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + return []mcpdiscovery.ServerToolsResult{ + {Server: "jira", Reachable: true, Tools: []string{"jira_search"}}, + {Server: "slack", Reachable: false, Err: errors.New("timeout")}, + }, nil + } + + tools, err := mgr.FetchMCPTools(context.Background(), "ws") + if err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + assertToolNames(t, tools, "jira_search", "slack_post") + + // jira_search must appear exactly once, from the deterministic result + // (empty Description), not duplicated or overwritten by the LLM version. + count := 0 + var desc string + for _, tool := range tools { + if tool.Name == "jira_search" { + count++ + desc = tool.Description + } + } + if count != 1 { + t.Fatalf("expected exactly one jira_search entry, got %d", count) + } + if desc != "" { + t.Errorf("expected the deterministic entry (empty Description) to win, got %q", desc) + } +} + +// ============================================================================= +// FetchMCPTools disk persistence: real-MCP only, TTL, manual refresh (mitto-sys.8) +// ============================================================================= + +func readPersistedSnapshot(t *testing.T, dir, workspaceUUID string) persistedMCPTools { + t.Helper() + var snap persistedMCPTools + if err := fileutil.ReadJSON(filepath.Join(dir, workspaceUUID+".json"), &snap); err != nil { + t.Fatalf("read persisted snapshot: %v", err) + } + return snap +} + +func TestFetchMCPTools_PersistsRealMCPToDisk(t *testing.T) { + dir := t.TempDir() + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + t.Fatal("LLM provider must not be called when all servers are reachable") + return "", nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + mgr.MCPToolsPersistDir = dir + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + return []mcpdiscovery.ServerToolsResult{ + {Server: "jira", Reachable: true, Tools: []string{"jira_create_issue", "jira_search"}}, + }, nil + } + + if _, err := mgr.FetchMCPTools(context.Background(), "ws"); err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + + snap := readPersistedSnapshot(t, dir, "ws") + assertToolNames(t, snap.Tools, "jira_create_issue", "jira_search") + if snap.UpdatedAt.IsZero() { + t.Errorf("expected UpdatedAt to be set") + } + if time.Since(snap.UpdatedAt) > time.Minute { + t.Errorf("expected a recent UpdatedAt, got %v", snap.UpdatedAt) + } +} + +func TestFetchMCPTools_DoesNotPersistLLMFallback(t *testing.T) { + dir := t.TempDir() + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + return `{"tools":[{"name":"slack_post","description":"post"}]}`, nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + mgr.MCPToolsPersistDir = dir + // No StdioToolsDiscoverer → pure-LLM path; nothing real-MCP to persist. + + tools, err := mgr.FetchMCPTools(context.Background(), "ws") + if err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + assertToolNames(t, tools, "slack_post") + + if _, err := os.Stat(filepath.Join(dir, "ws.json")); !os.IsNotExist(err) { + t.Fatalf("LLM-fallback result must not be persisted (stat err = %v)", err) + } +} + +func TestFetchMCPTools_LoadsPersistedWithinTTL_SkipsProbe(t *testing.T) { + dir := t.TempDir() + // Pre-seed a fresh snapshot on disk. + snap := persistedMCPTools{ + Tools: []MCPToolInfo{{Name: "jira_search"}, {Name: "slack_post"}}, + UpdatedAt: time.Now(), + } + if err := fileutil.WriteJSONAtomic(filepath.Join(dir, "ws.json"), &snap, 0o644); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + t.Fatal("LLM provider must not be called when a fresh snapshot exists") + return "", nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + mgr.MCPToolsPersistDir = dir + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + t.Fatal("discovery must not run when a fresh snapshot exists") + return nil, nil + } + + tools, err := mgr.FetchMCPTools(context.Background(), "ws") + if err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + assertToolNames(t, tools, "jira_search", "slack_post") +} + +func TestFetchMCPTools_ExpiredSnapshot_ReProbes(t *testing.T) { + dir := t.TempDir() + // Pre-seed a stale snapshot (older than the TTL). + stale := persistedMCPTools{ + Tools: []MCPToolInfo{{Name: "old_tool"}}, + UpdatedAt: time.Now().Add(-30 * time.Minute), + } + if err := fileutil.WriteJSONAtomic(filepath.Join(dir, "ws.json"), &stale, 0o644); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + t.Fatal("LLM provider must not be called when all servers are reachable") + return "", nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + mgr.MCPToolsPersistDir = dir + mgr.mcpToolsTTL = 15 * time.Minute + probed := false + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + probed = true + return []mcpdiscovery.ServerToolsResult{ + {Server: "jira", Reachable: true, Tools: []string{"jira_search"}}, + }, nil + } + + tools, err := mgr.FetchMCPTools(context.Background(), "ws") + if err != nil { + t.Fatalf("FetchMCPTools error = %v", err) + } + if !probed { + t.Fatalf("expected a fresh probe after TTL expiry") + } + assertToolNames(t, tools, "jira_search") + + // The stale snapshot must have been overwritten with the fresh probe. + snap := readPersistedSnapshot(t, dir, "ws") + assertToolNames(t, snap.Tools, "jira_search") +} + +func TestClearMCPToolsCache_RemovesPersistedSnapshot(t *testing.T) { + dir := t.TempDir() + snap := persistedMCPTools{Tools: []MCPToolInfo{{Name: "jira_search"}}, UpdatedAt: time.Now()} + path := filepath.Join(dir, "ws.json") + if err := fileutil.WriteJSONAtomic(path, &snap, 0o644); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + mgr.MCPToolsPersistDir = dir + mgr.ClearMCPToolsCache("ws") + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected persisted snapshot removed, stat err = %v", err) + } +} + +// ============================================================================= +// fetchMCPToolsViaLLM: strict parsing + single retry + plausibility (mitto-sys.7) +// ============================================================================= + +func TestFetchMCPToolsViaLLM_RetryAndPlausibility(t *testing.T) { + t.Run("parse-fail-then-success", func(t *testing.T) { + calls := 0 + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + calls++ + if calls == 1 { + return "garbage", nil + } + return `{"tools":[{"name":"t"}]}`, nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + + tools, err := mgr.fetchMCPToolsViaLLM(context.Background(), "ws", -1) + if err != nil { + t.Fatalf("fetchMCPToolsViaLLM error = %v", err) + } + assertToolNames(t, tools, "t") + if calls != 2 { + t.Errorf("calls = %d, want 2", calls) + } + }) + + t.Run("empty-plausible-then-success", func(t *testing.T) { + calls := 0 + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + calls++ + if calls == 1 { + return `{"tools":[]}`, nil + } + return `{"tools":[{"name":"t"}]}`, nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + + tools, err := mgr.fetchMCPToolsViaLLM(context.Background(), "ws", 2) + if err != nil { + t.Fatalf("fetchMCPToolsViaLLM error = %v", err) + } + assertToolNames(t, tools, "t") + if calls != 2 { + t.Errorf("calls = %d, want 2", calls) + } + }) + + t.Run("empty-accepted-when-count-not-positive", func(t *testing.T) { + calls := 0 + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + calls++ + return `{"tools":[]}`, nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + + tools, err := mgr.fetchMCPToolsViaLLM(context.Background(), "ws", -1) + if err != nil { + t.Fatalf("fetchMCPToolsViaLLM error = %v", err) + } + if len(tools) != 0 { + t.Errorf("tools = %v, want empty", tools) + } + if calls != 1 { + t.Errorf("calls = %d, want 1 (no retry when configuredServerCount <= 0)", calls) + } + }) + + t.Run("explicit-error-no-retry", func(t *testing.T) { + calls := 0 + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + calls++ + return `{"error":"none"}`, nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + + _, err := mgr.fetchMCPToolsViaLLM(context.Background(), "ws", 2) + if err == nil { + t.Fatalf("expected error, got nil") + } + if calls != 1 { + t.Errorf("calls = %d, want 1 (agent error is definitive, never retried)", calls) + } + }) + + t.Run("both-attempts-fail", func(t *testing.T) { + calls := 0 + mock := &mockProcessProvider{ + promptFunc: func(ctx context.Context, workspaceUUID, purpose, message string) (string, error) { + calls++ + return "garbage", nil + }, + } + mgr := NewWorkspaceAuxiliaryManager(mock, nil) + + _, err := mgr.fetchMCPToolsViaLLM(context.Background(), "ws", 2) + if err == nil { + t.Fatalf("expected error, got nil") + } + if calls != 2 { + t.Errorf("calls = %d, want 2", calls) + } + }) +} + +// ============================================================================= +// mergeMCPToolsAdditive + EnsureMCPBackoffRetry: bounded backoff re-probe of +// configured-but-unreachable MCP servers (mitto-sys.5) +// ============================================================================= + +// fastBackoffPolicy is a tiny policy for fast, deterministic tests. +func fastBackoffPolicy(maxAttempts int) mcpdiscovery.BackoffPolicy { + return mcpdiscovery.BackoffPolicy{Base: time.Millisecond, Factor: 2, Max: 5 * time.Millisecond, MaxAttempts: maxAttempts} +} + +func TestMergeMCPToolsAdditive(t *testing.T) { + t.Run("empty cache plus fresh", func(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + merged, changed := mgr.mergeMCPToolsAdditive("ws", []MCPToolInfo{{Name: "jira_search"}}) + if !changed { + t.Fatalf("changed = false, want true") + } + assertToolNames(t, merged, "jira_search") + }) + + t.Run("absent tool retained, no downgrade", func(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + mgr.mcpToolsCache["ws"] = []MCPToolInfo{{Name: "jira_search"}, {Name: "slack_post"}} + + merged, changed := mgr.mergeMCPToolsAdditive("ws", []MCPToolInfo{{Name: "jira_search"}}) + if changed { + t.Fatalf("changed = true, want false (slack_post absent from fresh must not evict it)") + } + assertToolNames(t, merged, "jira_search", "slack_post") + + cached, ok := mgr.GetCachedMCPTools("ws") + if !ok { + t.Fatalf("expected cache entry") + } + assertToolNames(t, cached, "jira_search", "slack_post") + }) + + t.Run("description refreshed", func(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + mgr.mcpToolsCache["ws"] = []MCPToolInfo{{Name: "jira_search", Description: "old"}} + + merged, changed := mgr.mergeMCPToolsAdditive("ws", []MCPToolInfo{{Name: "jira_search", Description: "newdesc"}}) + if !changed { + t.Fatalf("changed = false, want true") + } + if len(merged) != 1 || merged[0].Description != "newdesc" { + t.Errorf("merged = %+v, want [{jira_search newdesc}]", merged) + } + }) + + t.Run("empty fresh is a no-op", func(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + mgr.mcpToolsCache["ws"] = []MCPToolInfo{{Name: "jira_search"}} + + merged, changed := mgr.mergeMCPToolsAdditive("ws", nil) + if changed { + t.Fatalf("changed = true, want false") + } + assertToolNames(t, merged, "jira_search") + }) + + t.Run("reappearing tool resets negatives", func(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + // jira_search was previously seen (negatives=1, e.g. one missed + // applyMCPToolsCachePolicy round) but is no longer in the cache list + // itself (only tracked via the negatives counter at this point). + mgr.mcpToolsNegatives["ws"] = map[string]int{"jira_search": 1} + + mgr.mergeMCPToolsAdditive("ws", []MCPToolInfo{{Name: "jira_search"}}) + + if negs := mgr.mcpToolsNegatives["ws"]; negs != nil { + if _, ok := negs["jira_search"]; ok { + t.Errorf("expected jira_search negatives entry cleared, got %v", negs) + } + } + }) +} + +func TestEnsureMCPBackoffRetry_ReadyAfterN(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + mgr.mcpBackoffPolicy = fastBackoffPolicy(20) + + var calls int32 + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + n := atomic.AddInt32(&calls, 1) + if n <= 3 { + return []mcpdiscovery.ServerToolsResult{{Server: "s1", Reachable: false}}, nil + } + return []mcpdiscovery.ServerToolsResult{{Server: "s1", Reachable: true, Tools: []string{"late_tool"}}}, nil + } + + var mu sync.Mutex + var updates [][]MCPToolInfo + done := make(chan struct{}, 10) + onUpdate := func(tools []MCPToolInfo) { + mu.Lock() + updates = append(updates, tools) + mu.Unlock() + done <- struct{}{} + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mgr.EnsureMCPBackoffRetry(ctx, "ws", onUpdate) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for onUpdate") + } + + // Give the loop a moment to make its final (stopping) probe and exit. + time.Sleep(20 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(updates) != 1 { + t.Fatalf("onUpdate called %d times, want 1: %+v", len(updates), updates) + } + assertToolNames(t, updates[0], "late_tool") + + if got := atomic.LoadInt32(&calls); got != 4 { + t.Errorf("discoverer called %d times, want 4", got) + } +} + +func TestEnsureMCPBackoffRetry_NoNegativeOnTimeout(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + mgr.mcpBackoffPolicy = fastBackoffPolicy(3) + mgr.mcpToolsCache["ws"] = []MCPToolInfo{{Name: "known"}} + + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + return []mcpdiscovery.ServerToolsResult{{Server: "s1", Reachable: false, Err: context.DeadlineExceeded}}, nil + } + + var onUpdateCalls int32 + onUpdate := func(tools []MCPToolInfo) { + atomic.AddInt32(&onUpdateCalls, 1) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mgr.EnsureMCPBackoffRetry(ctx, "ws", onUpdate) + + // Poll until the backoff goroutine has finished (mcpBackoffActive cleared). + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mgr.mcpBackoffMu.Lock() + active := mgr.mcpBackoffActive["ws"] + mgr.mcpBackoffMu.Unlock() + if !active { + break + } + time.Sleep(time.Millisecond) + } + + if atomic.LoadInt32(&onUpdateCalls) != 0 { + t.Errorf("onUpdate called %d times, want 0", onUpdateCalls) + } + cached, ok := mgr.GetCachedMCPTools("ws") + if !ok || len(cached) != 1 || cached[0].Name != "known" { + t.Errorf("cache = %+v (ok=%v), want unchanged [{known}]", cached, ok) + } +} + +func TestEnsureMCPBackoffRetry_Dedup(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) + mgr.mcpBackoffPolicy = fastBackoffPolicy(0) // unbounded, relies on ctx cancel + + var starts int32 + mgr.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + atomic.AddInt32(&starts, 1) + return []mcpdiscovery.ServerToolsResult{{Server: "s1", Reachable: false}}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mgr.EnsureMCPBackoffRetry(ctx, "ws", nil) + mgr.EnsureMCPBackoffRetry(ctx, "ws", nil) // second call must be a no-op (already active) + + time.Sleep(20 * time.Millisecond) + cancel() + + // Wait for the goroutine to exit. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mgr.mcpBackoffMu.Lock() + active := mgr.mcpBackoffActive["ws"] + mgr.mcpBackoffMu.Unlock() + if !active { + break + } + time.Sleep(time.Millisecond) + } + + if got := atomic.LoadInt32(&starts); got < 1 { + t.Errorf("discoverer never called") + } + // A single active loop dedups repeated Ensure calls; the assertion of + // interest is that mcpBackoffActive never allowed two concurrent loops, + // which is implied by there being exactly one goroutine's worth of + // deterministic sequential calls (no data race under -race, and the + // second Ensure call returned immediately without starting a rival + // goroutine that could interleave with the first's cleanup). +} + +func TestEnsureMCPBackoffRetry_NilDiscoverer_NoOp(t *testing.T) { + mgr := NewWorkspaceAuxiliaryManager(&mockProcessProvider{}, nil) // StdioToolsDiscoverer left nil + + called := false + mgr.EnsureMCPBackoffRetry(context.Background(), "ws", func(tools []MCPToolInfo) { + called = true + }) + + time.Sleep(10 * time.Millisecond) + if called { + t.Errorf("onUpdate called, want no-op for nil discoverer") + } +} diff --git a/internal/beads/beads.go b/internal/beads/beads.go index cd445e0d..12011241 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -75,6 +75,13 @@ type DepParams struct { Action string // "add" or "remove" } +// LabelParams carries the fields for Client.Label. +type LabelParams struct { + ID string + Label string + Action string // "add" or "remove" +} + // Client executes bd subcommands for a workspace directory. // Each method accepts a context and an absolute workspace directory as its // first two arguments. @@ -90,6 +97,8 @@ type Client interface { Update(ctx context.Context, dir string, p UpdateParams) error Comment(ctx context.Context, dir, id, text string) error Dep(ctx context.Context, dir string, p DepParams) error + Label(ctx context.Context, dir string, p LabelParams) error + ListAllLabels(ctx context.Context, dir string) ([]byte, error) ConfigShow(ctx context.Context, dir string) (map[string]string, error) ConfigSet(ctx context.Context, dir, key, value string) error ConfigUnset(ctx context.Context, dir, key string) error @@ -151,6 +160,18 @@ func IsValidDepType(t string) bool { return ok } +// IsValidLabel reports whether l is a safe bd label: non-empty after trimming +// and not flag-like (no leading '-'). Guarding against a leading dash prevents +// the label from being parsed as a flag in the bd argument list. bd itself +// enforces any further naming rules and reports a descriptive error otherwise. +func IsValidLabel(l string) bool { + l = strings.TrimSpace(l) + if l == "" || strings.HasPrefix(l, "-") { + return false + } + return true +} + // validDepTypes is the set of dependency edge kinds accepted by "bd dep add -t". var validDepTypes = map[string]bool{ "blocks": true, diff --git a/internal/beads/cli.go b/internal/beads/cli.go index cf0af34f..72b85aa4 100644 --- a/internal/beads/cli.go +++ b/internal/beads/cli.go @@ -269,3 +269,28 @@ func (c *cliClient) Dep(ctx context.Context, dir string, p DepParams) error { _, err := c.runRaw(ctx, defaultTimeout, dir, args...) return err } + +func (c *cliClient) Label(ctx context.Context, dir string, p LabelParams) error { + var args []string + switch p.Action { + case "add": + args = []string{"label", "add", p.ID, p.Label} + case "remove": + args = []string{"label", "remove", p.ID, p.Label} + default: + return &CmdError{Err: errors.New("invalid label action: " + p.Action)} + } + _, err := c.runRaw(ctx, defaultTimeout, dir, args...) + return err +} + +// ListAllLabels returns the JSON array produced by "bd label list-all --json": +// a list of {"label","count"} objects for every unique label in the database. +// An uninitialized folder has no label database, so an empty JSON array is +// returned rather than letting bd fail. +func (c *cliClient) ListAllLabels(ctx context.Context, dir string) ([]byte, error) { + if !isInitialized(dir) { + return []byte("[]"), nil + } + return c.runJSON(ctx, dir, "label", "list-all", "--json") +} diff --git a/internal/client/client.go b/internal/client/client.go index 330fd97c..e292e27c 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -517,22 +517,22 @@ func (c *Client) GetPromptArgCache(sessionID, promptName string) ([]string, erro return result.Cached, nil } -// --- Periodic API --- +// --- Loop API --- -// PeriodicFrequency represents a periodic schedule frequency. -type PeriodicFrequency struct { +// LoopFrequency represents a loop schedule frequency. +type LoopFrequency struct { Value int `json:"value"` Unit string `json:"unit"` At string `json:"at,omitempty"` // HH:MM in UTC, only for unit=days } -// SetPeriodicRequest is the request body for PUT /api/sessions/{id}/periodic. -type SetPeriodicRequest struct { - PromptName string `json:"prompt_name,omitempty"` - Prompt string `json:"prompt,omitempty"` - Frequency PeriodicFrequency `json:"frequency"` - Enabled bool `json:"enabled"` - MaxIterations int `json:"max_iterations,omitempty"` +// SetLoopRequest is the request body for PUT /api/sessions/{id}/loop. +type SetLoopRequest struct { + PromptName string `json:"prompt_name,omitempty"` + Prompt string `json:"prompt,omitempty"` + Frequency LoopFrequency `json:"frequency"` + Enabled bool `json:"enabled"` + MaxIterations int `json:"max_iterations,omitempty"` // On-completion trigger fields (mitto-icf). Trigger string `json:"trigger,omitempty"` // "schedule" | "onCompletion" | "onTasks" DelaySeconds int `json:"delay_seconds,omitempty"` // clamped to server floor @@ -543,14 +543,14 @@ type SetPeriodicRequest struct { CooldownSeconds int `json:"cooldown_seconds,omitempty"` // per-conversation cooldown floor; 0 = use global floor } -// PeriodicConfig represents the periodic configuration for a session. -type PeriodicConfig struct { - Prompt string `json:"prompt,omitempty"` - PromptName string `json:"prompt_name,omitempty"` - Frequency PeriodicFrequency `json:"frequency"` - Enabled bool `json:"enabled"` - MaxIterations int `json:"max_iterations,omitempty"` - NextScheduledAt string `json:"next_scheduled_at,omitempty"` +// LoopConfig represents the loop configuration for a session. +type LoopConfig struct { + Prompt string `json:"prompt,omitempty"` + PromptName string `json:"prompt_name,omitempty"` + Frequency LoopFrequency `json:"frequency"` + Enabled bool `json:"enabled"` + MaxIterations int `json:"max_iterations,omitempty"` + NextScheduledAt string `json:"next_scheduled_at,omitempty"` // On-completion trigger fields (mitto-icf). Trigger string `json:"trigger,omitempty"` DelaySeconds int `json:"delay_seconds,omitempty"` @@ -564,87 +564,87 @@ type PeriodicConfig struct { StoppedReason string `json:"stopped_reason,omitempty"` } -// SetPeriodic configures a periodic schedule on a session via PUT. -func (c *Client) SetPeriodic(sessionID string, req SetPeriodicRequest) (*PeriodicConfig, error) { +// SetLoop configures a loop schedule on a session via PUT. +func (c *Client) SetLoop(sessionID string, req SetLoopRequest) (*LoopConfig, error) { body, err := json.Marshal(req) if err != nil { - return nil, fmt.Errorf("set periodic: marshal: %w", err) + return nil, fmt.Errorf("set loop: marshal: %w", err) } httpReq, err := http.NewRequest(http.MethodPut, - c.apiURL("/api/sessions/"+url.PathEscape(sessionID)+"/periodic"), + c.apiURL("/api/sessions/"+url.PathEscape(sessionID)+"/loop"), bytes.NewReader(body), ) if err != nil { - return nil, fmt.Errorf("set periodic: build request: %w", err) + return nil, fmt.Errorf("set loop: build request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("set periodic: %w", err) + return nil, fmt.Errorf("set loop: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("set periodic: status %d: %s", resp.StatusCode, string(respBody)) + return nil, fmt.Errorf("set loop: status %d: %s", resp.StatusCode, string(respBody)) } - var config PeriodicConfig + var config LoopConfig if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { - return nil, fmt.Errorf("set periodic: decode: %w", err) + return nil, fmt.Errorf("set loop: decode: %w", err) } return &config, nil } -// GetPeriodic returns the periodic configuration for a session. -func (c *Client) GetPeriodic(sessionID string) (*PeriodicConfig, error) { - resp, err := c.httpClient.Get(c.apiURL("/api/sessions/" + url.PathEscape(sessionID) + "/periodic")) +// GetLoop returns the loop configuration for a session. +func (c *Client) GetLoop(sessionID string) (*LoopConfig, error) { + resp, err := c.httpClient.Get(c.apiURL("/api/sessions/" + url.PathEscape(sessionID) + "/loop")) if err != nil { - return nil, fmt.Errorf("get periodic: %w", err) + return nil, fmt.Errorf("get loop: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("periodic not configured for session: %s", sessionID) + return nil, fmt.Errorf("loop not configured for session: %s", sessionID) } if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("get periodic: status %d: %s", resp.StatusCode, string(respBody)) + return nil, fmt.Errorf("get loop: status %d: %s", resp.StatusCode, string(respBody)) } - var config PeriodicConfig + var config LoopConfig if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { - return nil, fmt.Errorf("get periodic: decode: %w", err) + return nil, fmt.Errorf("get loop: decode: %w", err) } return &config, nil } -// RunPeriodicNow triggers an immediate run of the periodic prompt. +// RunLoopNow triggers an immediate run of the loop prompt. // resetTimer controls whether the next scheduled run timer is reset. -func (c *Client) RunPeriodicNow(sessionID string, resetTimer bool) error { +func (c *Client) RunLoopNow(sessionID string, resetTimer bool) error { reqBody := struct { ResetTimer bool `json:"reset_timer"` }{ResetTimer: resetTimer} body, err := json.Marshal(reqBody) if err != nil { - return fmt.Errorf("run periodic now: marshal: %w", err) + return fmt.Errorf("run loop now: marshal: %w", err) } resp, err := c.httpClient.Post( - c.apiURL("/api/sessions/"+url.PathEscape(sessionID)+"/periodic/run-now"), + c.apiURL("/api/sessions/"+url.PathEscape(sessionID)+"/loop/run-now"), "application/json", bytes.NewReader(body), ) if err != nil { - return fmt.Errorf("run periodic now: %w", err) + return fmt.Errorf("run loop now: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("run periodic now: status %d: %s", resp.StatusCode, string(respBody)) + return fmt.Errorf("run loop now: status %d: %s", resp.StatusCode, string(respBody)) } return nil } diff --git a/internal/config/beads_watcher.go b/internal/config/beads_watcher.go index 855ba51f..df408ae0 100644 --- a/internal/config/beads_watcher.go +++ b/internal/config/beads_watcher.go @@ -231,14 +231,22 @@ func (bw *BeadsWatcher) addWatch(dir string) error { parent := filepath.Dir(dir) if parent == dir { - return err + // Reached the filesystem root without an existing directory to watch. + // The .beads dir is simply absent; handle quietly rather than as an error. + if bw.logger != nil { + bw.logger.Debug("Beads dir absent up to filesystem root, not watching", "dir", dir) + } + return nil } if _, err := os.Stat(parent); err != nil { + // Neither the .beads dir nor its parent exists. Nothing to watch, but a + // missing .beads dir is expected for workspaces without beads tracking, so + // this is not a warnable condition. if bw.logger != nil { bw.logger.Debug("Parent directory doesn't exist, cannot watch beads dir", "dir", dir, "parent", parent) } - return err + return nil } if bw.logger != nil { bw.logger.Debug("Watching parent directory for beads dir creation", diff --git a/internal/config/beads_watcher_test.go b/internal/config/beads_watcher_test.go index 915780b2..18f96d8c 100644 --- a/internal/config/beads_watcher_test.go +++ b/internal/config/beads_watcher_test.go @@ -1,6 +1,8 @@ package config import ( + "context" + "log/slog" "os" "path/filepath" "sync" @@ -9,6 +11,33 @@ import ( "time" ) +// warnCountHandler is a minimal slog.Handler that counts records at WARN or above. +type warnCountHandler struct { + mu sync.Mutex + warns int +} + +func (h *warnCountHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } + +func (h *warnCountHandler) Handle(_ context.Context, r slog.Record) error { + if r.Level >= slog.LevelWarn { + h.mu.Lock() + h.warns++ + h.mu.Unlock() + } + return nil +} + +func (h *warnCountHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } + +func (h *warnCountHandler) WithGroup(_ string) slog.Handler { return h } + +func (h *warnCountHandler) count() int { + h.mu.Lock() + defer h.mu.Unlock() + return h.warns +} + // mockBeadsSubscriber implements BeadsSubscriber for testing. type mockBeadsSubscriber struct { mu sync.Mutex @@ -143,6 +172,37 @@ func TestBeadsWatcher_NotYetExistingBeadsDir(t *testing.T) { } } +func TestBeadsWatcher_MissingBeadsAndParent_NoWarn(t *testing.T) { + // A configured workspace whose directory (and therefore its .beads dir) does + // not exist must not produce a WARN — absence is expected and handled at + // DEBUG at most (mitto-3zr). + tmpDir := t.TempDir() + missingWorkspace := filepath.Join(tmpDir, "does-not-exist") + beadsDir := filepath.Join(missingWorkspace, ".beads") + + h := &warnCountHandler{} + bw, err := NewBeadsWatcher(slog.New(h)) + if err != nil { + t.Fatalf("NewBeadsWatcher: %v", err) + } + defer bw.Close() + bw.Start() + + sub := newMockBeadsSubscriber() + if err := bw.Subscribe(sub, []string{beadsDir}); err != nil { + t.Fatalf("Subscribe returned error for missing beads dir: %v", err) + } + + if got := h.count(); got != 0 { + t.Errorf("Expected no WARN for missing .beads dir, got %d", got) + } + + // The subscriber is still registered even though nothing can be watched yet. + if bw.SubscriberCount() != 1 { + t.Errorf("Expected 1 subscriber, got %d", bw.SubscriberCount()) + } +} + func TestBeadsWatcher_Unsubscribe_RemovesWatch(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") diff --git a/internal/config/cel_context.go b/internal/config/cel_context.go index ef5453b4..b1c3be80 100644 --- a/internal/config/cel_context.go +++ b/internal/config/cel_context.go @@ -32,27 +32,27 @@ type PromptEnabledContext struct { // template func ({{ UserData "NAME" }}), the .UserData map, and the CEL UserData // variable. nil at menu time is safe (nil map indexes to ""). UserData map[string]string - // Iteration holds periodic-iteration info for the current run, enabling prompt + // Iteration holds loop-iteration info for the current run, enabling prompt // bodies to branch on which run they are in (e.g. {{ if .Iteration.IsFirst }}). - // All-zero (Number=0, IsPeriodic=false) for non-periodic prompts. + // All-zero (Number=0, IsLoop=false) for non-loop prompts. Iteration IterationContext } -// IterationContext holds periodic-iteration info for CEL/template evaluation. +// IterationContext holds loop-iteration info for CEL/template evaluation. // Number is the 0-based index of the current run (IterationCount at dispatch). -// Values are zero for non-periodic prompts. +// Values are zero for non-loop prompts. type IterationContext struct { - // Number is the 0-based index of the current periodic run. + // Number is the 0-based index of the current loop run. Number int // Max is the configured maximum number of runs (0 = unlimited). Max int - // IsPeriodic indicates the current prompt was triggered by the periodic runner. - IsPeriodic bool + // IsLoop indicates the current prompt was triggered by the loop runner. + IsLoop bool // IsFirst is true when Number == 0. IsFirst bool // IsLast is true when Max > 0 && Number == Max-1. IsLast bool - // IsUninterrupted is true ONLY on a scheduled (non-forced) periodic run that + // IsUninterrupted is true ONLY on a scheduled (non-forced) loop run that // directly follows another such run of this same loop with nothing in between: // no user interjection, no forced "run now", no FreshContext, and within the same // process lifetime. Powered by a session-scoped in-memory marker that resets across @@ -130,17 +130,17 @@ type SessionContext struct { IsAutoChild bool // ParentID is the ID of the parent session (empty if not a child) ParentID string - // IsPeriodic indicates whether the current prompt was triggered by the periodic runner - IsPeriodic bool - // IsPeriodicForced indicates whether a periodic prompt was triggered manually via + // IsLoop indicates whether the current prompt was triggered by the loop runner + IsLoop bool + // IsLoopForced indicates whether a loop prompt was triggered manually via // "run now" (as opposed to the normal scheduled delivery). Mirrors - // ProcessorInput.IsPeriodicForced and the @mitto:periodic_forced placeholder. - IsPeriodicForced bool - // IsPeriodicConversation indicates whether the conversation is configured as a - // periodic conversation (it has a periodic prompt configuration). Unlike - // IsPeriodic, this reflects the conversation TYPE, not whether the current run + // ProcessorInput.IsLoopForced and the @mitto:loop_forced placeholder. + IsLoopForced bool + // IsLoopConversation indicates whether the conversation is configured as a + // loop conversation (it has a loop prompt configuration). Unlike + // IsLoop, this reflects the conversation TYPE, not whether the current run // was triggered by the scheduler. Populated in the prompt-menu evaluation context. - IsPeriodicConversation bool + IsLoopConversation bool // HasBeadsIssue indicates whether the conversation has a beads issue associated // (the session metadata BeadsIssue field is non-empty). HasBeadsIssue bool @@ -232,16 +232,146 @@ func (c ChildrenContext) AllText() string { return FormatChildren(c.All) } // MCPText renders MCP-origin child sessions only, comma-separated. Empty when none. func (c ChildrenContext) MCPText() string { return FormatChildren(c.MCP) } +// ServerToolState represents a single MCP server's tool-list availability +// state, used to decide fail-open vs fail-closed matching in +// hasPattern/hasAllPatterns/hasAnyPattern. See docs/devel/mcp-tool-discovery.md +// (Q3.2, Q4.1): a single global latch cannot distinguish a late-starting or +// unreachable server (which should stay fail-open) from a reachable one +// (which is authoritative), so state is tracked per server instead. +type ServerToolState int + +const ( + // ServerToolStateUnknown is the cold-start default: the server has not + // yet been probed. Pattern matching against its namespace fails open. + ServerToolStateUnknown ServerToolState = iota + // ServerToolStateReachable indicates a successful probe/tool-list fetch. + // Its Names are authoritative: pattern matching is name-based (fail-closed). + ServerToolStateReachable + // ServerToolStateUnreachable indicates the server is known (e.g. present + // in ListMCPServers) but could not be reached. Matching against its + // namespace fails open, same as Unknown, pending bounded backoff retries + // (out of scope here; see mitto-sys.5). + ServerToolStateUnreachable +) + +// String returns a stable, human-readable name for the state. Also used as +// the wire representation in the CEL activation map (see buildActivation in +// cel_evaluator.go) and parsed back via parseServerToolState. +func (s ServerToolState) String() string { + switch s { + case ServerToolStateReachable: + return "reachable" + case ServerToolStateUnreachable: + return "configured-but-unreachable" + default: + return "unknown" + } +} + +// parseServerToolState parses the String() representation back into a +// ServerToolState. Unrecognized values default to Unknown (fail-open). +func parseServerToolState(s string) ServerToolState { + switch s { + case "reachable": + return ServerToolStateReachable + case "configured-but-unreachable": + return ServerToolStateUnreachable + default: + return ServerToolStateUnknown + } +} + +// ServerToolInfo holds one MCP server's tool-list availability state and, +// when State == ServerToolStateReachable, the tool names it exposes. +type ServerToolInfo struct { + // State is the server's current tool-list availability state. + State ServerToolState + // Names contains the tool names this server exposes. Only meaningful + // when State == ServerToolStateReachable. + Names []string +} + +// AllServersToolKey is a synthetic "catch-all" server key in +// ToolsContext.Servers, used by callers that don't have real per-server +// identity for their tool names (currently: message processors — see +// NewProcessorToolsContext and internal/processors/hook.go). +// hasPattern/hasAllPatterns/hasAnyPattern fall back to this entry when a +// pattern's specific owning server isn't found in Servers, so such callers +// get flat, always-authoritative matching (no per-server fail-open grace) — +// preserving pre-mitto-sys.1 processor behavior. General per-server callers +// (e.g. internal/web/session_api.go via NewReachableToolsContext) never set +// this key, so an unrecognized server still fails open for them as intended. +// "" can never be produced by resolveServerName for a non-empty pattern, so +// it can't collide with a real server name. +const AllServersToolKey = "" + // ToolsContext holds MCP tools context for CEL evaluation. +// +// Availability is tracked PER SERVER (docs/devel/mcp-tool-discovery.md, +// Q3.2/Q4.1) instead of one global latch: hasPattern/hasAllPatterns/ +// hasAnyPattern (templatefuncs.go) resolve a pattern (e.g. "jira_*") to its +// owning server (the token before the first underscore — see +// resolveServerName) and match name-based (fail-closed) only when that +// server is Reachable; Unknown/Unreachable servers, or servers absent from +// Servers entirely, fail open. type ToolsContext struct { - // Available indicates whether the tool list is known (a definitive, non-empty - // result has been fetched). When false, the tool list is unknown / not yet - // fetched, and the tool-pattern functions (tools.hasPattern/hasAllPatterns/ - // hasAnyPattern) fail open (return true) so tool-gated prompts are not hidden - // during the MCP-tools cache warm-up window. - Available bool - // Names contains the names of available tools + // Servers maps server name (e.g. "jira", "github") to its state and tool + // names. Nil/empty means no server has been probed at all yet (genuine + // cold start): every pattern falls through to fail-open, since no server + // name is ever found. See AllServersToolKey for the processor-only + // catch-all fallback. + Servers map[string]ServerToolInfo + // Names is the flattened list of all known tool names across all + // Reachable servers (kept for legacy readers/template display, e.g. + // `"x" in Tools.Names`). Does not drive hasPattern/hasAllPatterns/ + // hasAnyPattern directly anymore — see Servers. Names []string + // Available is a derived, legacy convenience signal: true when Servers is + // non-empty (some server has been probed / is known). It does NOT drive + // hasPattern/hasAllPatterns/hasAnyPattern — those resolve per-server + // state — but is kept for callers/tests that only care whether ANY tool + // discovery has happened yet (e.g. `Tools.Available` in CEL expressions). + Available bool +} + +// GroupToolNamesByServer groups tool names by their owning server: the token +// before the first underscore (e.g. "jira_create_issue" -> "jira"). A name +// with no underscore is grouped under itself. +func GroupToolNamesByServer(names []string) map[string][]string { + groups := make(map[string][]string) + for _, name := range names { + server := resolveServerName(name) + groups[server] = append(groups[server], name) + } + return groups +} + +// NewReachableToolsContext builds a ToolsContext from a flat, already-trusted +// tool-name list (e.g. the MCP tools cache), grouping names by their inferred +// owning server (GroupToolNamesByServer) and marking every such server +// Reachable (authoritative, fail-closed). A server with no names in the list +// is never represented, so an unrelated/not-yet-discovered server's patterns +// still fail open (per-server grace) — see internal/web/session_api.go. +func NewReachableToolsContext(names []string) ToolsContext { + servers := make(map[string]ServerToolInfo) + for server, ns := range GroupToolNamesByServer(names) { + servers[server] = ServerToolInfo{State: ServerToolStateReachable, Names: ns} + } + return ToolsContext{Servers: servers, Names: names, Available: len(servers) > 0} +} + +// NewProcessorToolsContext builds a ToolsContext for processor (message hook) +// evaluation, where names is the full, authoritative tool list at message- +// processing time (the cache is warmed on connect) but no real per-server +// identity is available. It marks a single AllServersToolKey entry Reachable +// so hasPattern/hasAllPatterns/hasAnyPattern are always fail-closed — +// matching pre-mitto-sys.1 processor behavior (no warm-up grace period), +// regardless of whether names is empty. See internal/processors/hook.go. +func NewProcessorToolsContext(names []string) ToolsContext { + servers := map[string]ServerToolInfo{ + AllServersToolKey: {State: ServerToolStateReachable, Names: names}, + } + return ToolsContext{Servers: servers, Names: names, Available: true} } // ItemContext holds the generic per-row item context for CEL evaluation of list menus. diff --git a/internal/config/cel_evaluator.go b/internal/config/cel_evaluator.go index 137c7c68..113b857f 100644 --- a/internal/config/cel_evaluator.go +++ b/internal/config/cel_evaluator.go @@ -65,9 +65,9 @@ func NewCELEvaluator() (*CELEvaluator, error) { cel.Variable("Session.IsChild", cel.BoolType), cel.Variable("Session.IsAutoChild", cel.BoolType), cel.Variable("Session.ParentID", cel.StringType), - cel.Variable("Session.IsPeriodic", cel.BoolType), - cel.Variable("Session.IsPeriodicForced", cel.BoolType), - cel.Variable("Session.IsPeriodicConversation", cel.BoolType), + cel.Variable("Session.IsLoop", cel.BoolType), + cel.Variable("Session.IsLoopForced", cel.BoolType), + cel.Variable("Session.IsLoopConversation", cel.BoolType), cel.Variable("Session.HasMessages", cel.BoolType), cel.Variable("Session.HasBeadsIssue", cel.BoolType), cel.Variable("Session.BeadsIssue", cel.StringType), @@ -87,9 +87,18 @@ func NewCELEvaluator() (*CELEvaluator, error) { cel.Variable("Children.PromptingCount", cel.IntType), cel.Variable("Children.IdleCount", cel.IntType), - // Tools variables + // Tools variables. ServerStates/ServerNames back the per-server + // availability model (docs/devel/mcp-tool-discovery.md, Q3.2/Q4.1): + // server name -> state string (see ServerToolState.String()) and + // server name -> that server's own tool names, respectively. + // Available/Names remain as flattened, legacy convenience signals for + // expressions that reference them directly (e.g. `Tools.Available`, + // `"x" in Tools.Names`); they no longer drive HasPattern/ + // HasAllPatterns/HasAnyPattern — see the macros below. cel.Variable("Tools.Available", cel.BoolType), cel.Variable("Tools.Names", cel.ListType(cel.StringType)), + cel.Variable("Tools.ServerStates", cel.MapType(cel.StringType, cel.StringType)), + cel.Variable("Tools.ServerNames", cel.MapType(cel.StringType, cel.ListType(cel.StringType))), // Permissions variables cel.Variable("Permissions.CanDoIntrospection", cel.BoolType), @@ -133,32 +142,32 @@ func NewCELEvaluator() (*CELEvaluator, error) { // Because the bindings are pure functions of their arguments, the compiled // cel.Program can be created once at compile time and reused per evaluation. cel.Function("__mitto_hasPattern", - cel.Overload("__mitto_hasPattern_bool_list_string", - []*cel.Type{cel.BoolType, cel.ListType(cel.StringType), cel.StringType}, + cel.Overload("__mitto_hasPattern_map_map_string", + []*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.MapType(cel.StringType, cel.ListType(cel.StringType)), cel.StringType}, cel.BoolType, cel.FunctionBinding(mittoHasPattern), ), ), cel.Function("__mitto_hasAllPatterns", - cel.Overload("__mitto_hasAllPatterns_bool_list_string", - []*cel.Type{cel.BoolType, cel.ListType(cel.StringType), cel.StringType}, + cel.Overload("__mitto_hasAllPatterns_map_map_string", + []*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.MapType(cel.StringType, cel.ListType(cel.StringType)), cel.StringType}, cel.BoolType, cel.FunctionBinding(mittoHasAllPatterns), ), - cel.Overload("__mitto_hasAllPatterns_bool_list_list", - []*cel.Type{cel.BoolType, cel.ListType(cel.StringType), cel.ListType(cel.StringType)}, + cel.Overload("__mitto_hasAllPatterns_map_map_list", + []*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.MapType(cel.StringType, cel.ListType(cel.StringType)), cel.ListType(cel.StringType)}, cel.BoolType, cel.FunctionBinding(mittoHasAllPatterns), ), ), cel.Function("__mitto_hasAnyPattern", - cel.Overload("__mitto_hasAnyPattern_bool_list_string", - []*cel.Type{cel.BoolType, cel.ListType(cel.StringType), cel.StringType}, + cel.Overload("__mitto_hasAnyPattern_map_map_string", + []*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.MapType(cel.StringType, cel.ListType(cel.StringType)), cel.StringType}, cel.BoolType, cel.FunctionBinding(mittoHasAnyPattern), ), - cel.Overload("__mitto_hasAnyPattern_bool_list_list", - []*cel.Type{cel.BoolType, cel.ListType(cel.StringType), cel.ListType(cel.StringType)}, + cel.Overload("__mitto_hasAnyPattern_map_map_list", + []*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.MapType(cel.StringType, cel.ListType(cel.StringType)), cel.ListType(cel.StringType)}, cel.BoolType, cel.FunctionBinding(mittoHasAnyPattern), ), @@ -346,6 +355,28 @@ func (e *CELEvaluator) Evaluate(compiled *CompiledExpression, ctx *PromptEnabled return bool(result), nil } +// toolServerStatesMap converts ctx.Tools.Servers into the server-name -> +// state-string map fed to the CEL Tools.ServerStates activation variable +// (see ServerToolState.String()). Never nil, so an empty/nil Servers map +// (genuine cold start) still yields a valid, empty CEL map value. +func toolServerStatesMap(servers map[string]ServerToolInfo) map[string]string { + m := make(map[string]string, len(servers)) + for name, info := range servers { + m[name] = info.State.String() + } + return m +} + +// toolServerNamesMap converts ctx.Tools.Servers into the server-name -> +// tool-names map fed to the CEL Tools.ServerNames activation variable. +func toolServerNamesMap(servers map[string]ServerToolInfo) map[string][]string { + m := make(map[string][]string, len(servers)) + for name, info := range servers { + m[name] = info.Names + } + return m +} + // buildActivation converts a PromptEnabledContext into a CEL activation map. func buildActivation(ctx *PromptEnabledContext) map[string]any { // Convert Args to map[string]any (matching the args variable's DynType declaration) @@ -378,18 +409,18 @@ func buildActivation(ctx *PromptEnabledContext) map[string]any { "Workspace.HasMittoRC": ctx.Workspace.HasMittoRC, "Workspace.HasMetadataDescription": ctx.Workspace.HasMetadataDescription, - "Session.ID": ctx.Session.ID, - "Session.Name": ctx.Session.Name, - "Session.IsChild": ctx.Session.IsChild, - "Session.IsAutoChild": ctx.Session.IsAutoChild, - "Session.ParentID": ctx.Session.ParentID, - "Session.IsPeriodic": ctx.Session.IsPeriodic, - "Session.IsPeriodicForced": ctx.Session.IsPeriodicForced, - "Session.IsPeriodicConversation": ctx.Session.IsPeriodicConversation, - "Session.HasMessages": ctx.Session.HasMessages, - "Session.HasBeadsIssue": ctx.Session.HasBeadsIssue, - "Session.BeadsIssue": ctx.Session.BeadsIssue, - "Session.ModelTags": ctx.Session.ModelTags, + "Session.ID": ctx.Session.ID, + "Session.Name": ctx.Session.Name, + "Session.IsChild": ctx.Session.IsChild, + "Session.IsAutoChild": ctx.Session.IsAutoChild, + "Session.ParentID": ctx.Session.ParentID, + "Session.IsLoop": ctx.Session.IsLoop, + "Session.IsLoopForced": ctx.Session.IsLoopForced, + "Session.IsLoopConversation": ctx.Session.IsLoopConversation, + "Session.HasMessages": ctx.Session.HasMessages, + "Session.HasBeadsIssue": ctx.Session.HasBeadsIssue, + "Session.BeadsIssue": ctx.Session.BeadsIssue, + "Session.ModelTags": ctx.Session.ModelTags, "Parent.Exists": ctx.Parent.Exists, "Parent.Name": ctx.Parent.Name, @@ -403,8 +434,10 @@ func buildActivation(ctx *PromptEnabledContext) map[string]any { "Children.PromptingCount": int64(ctx.Children.PromptingCount), "Children.IdleCount": int64(ctx.Children.IdleCount), - "Tools.Available": ctx.Tools.Available, - "Tools.Names": ctx.Tools.Names, + "Tools.Available": ctx.Tools.Available, + "Tools.Names": ctx.Tools.Names, + "Tools.ServerStates": toolServerStatesMap(ctx.Tools.Servers), + "Tools.ServerNames": toolServerNamesMap(ctx.Tools.Servers), "Permissions.CanDoIntrospection": ctx.Permissions.CanDoIntrospection, "Permissions.CanSendPrompt": ctx.Permissions.CanSendPrompt, @@ -456,28 +489,31 @@ func isIdent(e celast.Expr, name string) bool { return e != nil && e.Kind() == celast.IdentKind && e.AsIdent() == name } -// toolsHasPatternMacro rewrites Tools.HasPattern(p) -> __mitto_hasPattern(Tools.Available, Tools.Names, p). +// toolsHasPatternMacro rewrites Tools.HasPattern(p) -> +// __mitto_hasPattern(Tools.ServerStates, Tools.ServerNames, p). func toolsHasPatternMacro(eh cel.MacroExprFactory, target celast.Expr, args []celast.Expr) (celast.Expr, *celcommon.Error) { if !isIdent(target, "Tools") { return nil, nil } - return eh.NewCall("__mitto_hasPattern", eh.NewIdent("Tools.Available"), eh.NewIdent("Tools.Names"), args[0]), nil + return eh.NewCall("__mitto_hasPattern", eh.NewIdent("Tools.ServerStates"), eh.NewIdent("Tools.ServerNames"), args[0]), nil } -// toolsHasAllPatternsMacro rewrites Tools.HasAllPatterns(a) -> __mitto_hasAllPatterns(Tools.Available, Tools.Names, a). +// toolsHasAllPatternsMacro rewrites Tools.HasAllPatterns(a) -> +// __mitto_hasAllPatterns(Tools.ServerStates, Tools.ServerNames, a). func toolsHasAllPatternsMacro(eh cel.MacroExprFactory, target celast.Expr, args []celast.Expr) (celast.Expr, *celcommon.Error) { if !isIdent(target, "Tools") { return nil, nil } - return eh.NewCall("__mitto_hasAllPatterns", eh.NewIdent("Tools.Available"), eh.NewIdent("Tools.Names"), args[0]), nil + return eh.NewCall("__mitto_hasAllPatterns", eh.NewIdent("Tools.ServerStates"), eh.NewIdent("Tools.ServerNames"), args[0]), nil } -// toolsHasAnyPatternMacro rewrites Tools.HasAnyPattern(a) -> __mitto_hasAnyPattern(Tools.Available, Tools.Names, a). +// toolsHasAnyPatternMacro rewrites Tools.HasAnyPattern(a) -> +// __mitto_hasAnyPattern(Tools.ServerStates, Tools.ServerNames, a). func toolsHasAnyPatternMacro(eh cel.MacroExprFactory, target celast.Expr, args []celast.Expr) (celast.Expr, *celcommon.Error) { if !isIdent(target, "Tools") { return nil, nil } - return eh.NewCall("__mitto_hasAnyPattern", eh.NewIdent("Tools.Available"), eh.NewIdent("Tools.Names"), args[0]), nil + return eh.NewCall("__mitto_hasAnyPattern", eh.NewIdent("Tools.ServerStates"), eh.NewIdent("Tools.ServerNames"), args[0]), nil } // sessionHasModelTagMacro rewrites Session.HasModelTag(t) -> __mitto_hasModelTag(Session.ModelTags, t). @@ -550,56 +586,116 @@ func valToString(v ref.Val) string { return "" } -// mittoHasPattern reports whether any name (args[1], a list) matches the glob -// pattern (args[2]). args[0] is tools.available. Context-free so the compiled -// program can be cached. Delegates to hasPattern (templatefuncs.go) for the -// pure-Go logic (single source of truth shared with the template FuncMap). +// extractServerStates converts the CEL Tools.ServerStates map value (server +// name -> state string, see toolServerStatesMap) into a Go map[string]string. +// Returns nil when v isn't map-shaped (unexpected type — treated by callers +// as an empty/cold-start Servers set, i.e. fail-open). +func extractServerStates(v ref.Val) map[string]string { + mapper, ok := v.(traits.Mapper) + if !ok { + return nil + } + result := make(map[string]string) + it := mapper.Iterator() + for it.HasNext() == types.True { + k := it.Next() + ks, ok := k.(types.String) + if !ok { + continue + } + val, found := mapper.Find(k) + if !found { + continue + } + if vs, ok := val.(types.String); ok { + result[string(ks)] = string(vs) + } + } + return result +} + +// extractServerNamesMap converts the CEL Tools.ServerNames map value (server +// name -> tool names, see toolServerNamesMap) into a Go map[string][]string. +func extractServerNamesMap(v ref.Val) map[string][]string { + mapper, ok := v.(traits.Mapper) + if !ok { + return nil + } + result := make(map[string][]string) + it := mapper.Iterator() + for it.HasNext() == types.True { + k := it.Next() + ks, ok := k.(types.String) + if !ok { + continue + } + val, found := mapper.Find(k) + if !found { + continue + } + result[string(ks)] = extractStringArgs([]ref.Val{val}) + } + return result +} + +// buildServersFromCEL reconstructs the map[string]ServerToolInfo shape from +// the CEL Tools.ServerStates/Tools.ServerNames activation values so +// mittoHasPattern/mittoHasAllPatterns/mittoHasAnyPattern can delegate to the +// exact same hasPattern/hasAllPatterns/hasAnyPattern logic used by the +// template FuncMap path (templatefuncs.go) — single source of truth for the +// per-server MCP tool availability rule (docs/devel/mcp-tool-discovery.md, +// Q3.2/Q4.1). Returns nil when statesVal isn't map-shaped, which hasPattern +// treats the same as an empty Servers map (fail-open). +func buildServersFromCEL(statesVal, namesVal ref.Val) map[string]ServerToolInfo { + states := extractServerStates(statesVal) + if states == nil { + return nil + } + names := extractServerNamesMap(namesVal) + servers := make(map[string]ServerToolInfo, len(states)) + for name, stateStr := range states { + servers[name] = ServerToolInfo{State: parseServerToolState(stateStr), Names: names[name]} + } + return servers +} + +// mittoHasPattern reports whether the pattern (args[2]) is satisfied per the +// per-server MCP tool availability rule, given the CEL Tools.ServerStates +// (args[0]) and Tools.ServerNames (args[1]) activation values. Context-free +// so the compiled program can be cached. Delegates to hasPattern +// (templatefuncs.go) for the actual resolution logic. func mittoHasPattern(args ...ref.Val) ref.Val { if len(args) != 3 { return types.Bool(false) } - available, ok := args[0].(types.Bool) - if !ok { - return types.Bool(true) // type error → treat as unavailable → fail-open - } + servers := buildServersFromCEL(args[0], args[1]) pattern, ok := args[2].(types.String) if !ok { return types.Bool(false) } - names := extractStringArgs([]ref.Val{args[1]}) - return types.Bool(hasPattern(bool(available), names, string(pattern))) + return types.Bool(hasPattern(servers, string(pattern))) } // mittoHasAllPatterns reports whether ALL patterns (args[2], string or list) -// are satisfied by at least one name each (args[1], a list). args[0] is -// tools.available. Delegates to hasAllPatterns (templatefuncs.go). +// are satisfied. Delegates to hasAllPatterns (templatefuncs.go). func mittoHasAllPatterns(args ...ref.Val) ref.Val { if len(args) != 3 { return types.Bool(false) } - available, ok := args[0].(types.Bool) - if !ok { - return types.Bool(true) // type error → fail-open - } - names := extractStringArgs([]ref.Val{args[1]}) + servers := buildServersFromCEL(args[0], args[1]) patterns := extractStringArgs([]ref.Val{args[2]}) - return types.Bool(hasAllPatterns(bool(available), names, patterns)) + return types.Bool(hasAllPatterns(servers, patterns)) } -// mittoHasAnyPattern reports whether ANY pattern (args[2], string or list) -// is satisfied by at least one name (args[1], a list). args[0] is -// tools.available. Delegates to hasAnyPattern (templatefuncs.go). +// mittoHasAnyPattern reports whether ANY pattern (args[2], string or list) is +// satisfied. Delegates to hasAnyPattern (templatefuncs.go). func mittoHasAnyPattern(args ...ref.Val) ref.Val { if len(args) != 3 { return types.Bool(false) } - available, ok := args[0].(types.Bool) - if !ok { - return types.Bool(true) // type error → fail-open - } - names := extractStringArgs([]ref.Val{args[1]}) + servers := buildServersFromCEL(args[0], args[1]) patterns := extractStringArgs([]ref.Val{args[2]}) - return types.Bool(hasAnyPattern(bool(available), names, patterns)) + return types.Bool(hasAnyPattern(servers, patterns)) } // mittoHasModelTag reports whether tag (args[1]) is present in the model tag list diff --git a/internal/config/cel_evaluator_test.go b/internal/config/cel_evaluator_test.go index a0dcc906..02ce4abb 100644 --- a/internal/config/cel_evaluator_test.go +++ b/internal/config/cel_evaluator_test.go @@ -43,14 +43,14 @@ func TestCELEvaluator_ExampleExpressions(t *testing.T) { Session: SessionContext{ID: "child-1", IsChild: true, ParentID: "parent-1"}, Parent: ParentContext{Exists: true, Name: "Parent Session", ACPServer: "auggie"}, Children: ChildrenContext{Count: 0, Exists: false}, - Tools: ToolsContext{Available: true, Names: []string{"github_create_pr", "github_list_issues", "slack_post"}}, + Tools: NewReachableToolsContext([]string{"github_create_pr", "github_list_issues", "slack_post"}), } rootCtx := &PromptEnabledContext{ ACP: ACPContext{Name: "claude-code", Type: "claude", Tags: []string{"thinking"}}, Session: SessionContext{ID: "root-1", IsChild: false}, Children: ChildrenContext{Count: 2, Exists: true, Names: []string{"Child A", "Child B"}}, - Tools: ToolsContext{Available: true, Names: []string{"jira_create_issue", "confluence_search"}}, + Tools: NewReachableToolsContext([]string{"jira_create_issue", "confluence_search"}), } tests := []struct { @@ -74,9 +74,13 @@ func TestCELEvaluator_ExampleExpressions(t *testing.T) { {expr: "Children.Count > 0", ctx: rootCtx, want: true}, {expr: "Children.Count > 0", ctx: childCtx, want: false}, - // Tools.HasPattern("github_*") — only if GitHub tools available + // Tools.HasPattern("github_*") — per-server matching (mitto-sys.1): + // true when the "github" server is Reachable and a name matches + // (childCtx). An entirely unknown server (not in rootCtx's per-server + // map, which only has "jira"/"confluence") now fails OPEN rather than + // closed — it may simply not have been discovered/started yet. {expr: `Tools.HasPattern("github_*")`, ctx: childCtx, want: true}, - {expr: `Tools.HasPattern("github_*")`, ctx: rootCtx, want: false}, + {expr: `Tools.HasPattern("github_*")`, ctx: rootCtx, want: true}, // Children.MCPCount — only if enough MCP-created children {expr: "Children.MCPCount >= 2", ctx: &PromptEnabledContext{ @@ -203,24 +207,29 @@ func TestCELConvenienceFunctions(t *testing.T) { augCtx := &PromptEnabledContext{ ACP: ACPContext{Name: "Auggie (Opus 4.6)", Type: "augment"}, - Tools: ToolsContext{Available: true, Names: []string{"mitto_list", "jira_create_issue", "github_pr"}}, + Tools: NewReachableToolsContext([]string{"mitto_list", "jira_create_issue", "github_pr"}), } noACPCtx := &PromptEnabledContext{ ACP: ACPContext{Name: "", Type: ""}, - Tools: ToolsContext{Available: true, Names: []string{"mitto_list"}}, - } - // fetchedEmptyCtx: the tool list has been fetched and is known to be empty - // (Available: true). Tool-pattern functions evaluate normally and fail closed. + Tools: NewReachableToolsContext([]string{"mitto_list"}), + } + // fetchedEmptyCtx: the tool list has been fetched and is known to be + // empty (no names at all, so GroupToolNamesByServer/NewReachableToolsContext + // yields an empty Servers map). Under the per-server model (mitto-sys.1, + // edge case a) an empty Servers map is indistinguishable from a genuine + // cold start and preserves the legacy GLOBAL fail-open behavior — there is + // no server identity at all to resolve a pattern against. fetchedEmptyCtx := &PromptEnabledContext{ ACP: ACPContext{Name: "Auggie (Opus 4.6)", Type: "augment"}, - Tools: ToolsContext{Available: true, Names: nil}, + Tools: NewReachableToolsContext(nil), } - // unknownToolsCtx: the tool list has not been fetched yet (Available: false). - // Tool-pattern functions fail open (return true) so prompts are not hidden - // during the MCP-tools cache warm-up window. + // unknownToolsCtx: the tool list has not been fetched yet (zero-value + // ToolsContext, empty Servers map). Tool-pattern functions fail open + // (return true) so prompts are not hidden during the MCP-tools cache + // warm-up window. unknownToolsCtx := &PromptEnabledContext{ ACP: ACPContext{Name: "Auggie (Opus 4.6)", Type: "augment"}, - Tools: ToolsContext{Available: false, Names: nil}, + Tools: ToolsContext{}, } tests := []struct { @@ -248,26 +257,36 @@ func TestCELConvenienceFunctions(t *testing.T) { {"matchesServerType list none match", `ACP.MatchesServerType(["cursor", "claude-code"])`, augCtx, false}, {"matchesServerType empty list", `ACP.MatchesServerType([])`, augCtx, false}, - // Tools.HasAllPatterns — single string arg + // Tools.HasAllPatterns — single string arg. augCtx's per-server map + // only knows "mitto"/"jira"/"github"; "slack" is an unknown server, + // so it fails OPEN under the per-server model (mitto-sys.1) — unlike + // the pre-refactor global-latch behavior, where any absent tool name + // failed closed once the tool list was known. {"hasAllPatterns single satisfied", `Tools.HasAllPatterns("mitto_*")`, augCtx, true}, - {"hasAllPatterns single not satisfied", `Tools.HasAllPatterns("slack_*")`, augCtx, false}, + {"hasAllPatterns single unknown server fails open", `Tools.HasAllPatterns("slack_*")`, augCtx, true}, + {"hasAllPatterns single no match on reachable server", `Tools.HasAllPatterns("jira_other")`, augCtx, false}, // Tools.HasAllPatterns — list arg {"hasAllPatterns list all satisfied", `Tools.HasAllPatterns(["mitto_*", "jira_*"])`, augCtx, true}, - {"hasAllPatterns list some unsatisfied", `Tools.HasAllPatterns(["mitto_*", "slack_*"])`, augCtx, false}, - {"hasAllPatterns fetched-empty fails closed", `Tools.HasAllPatterns(["mitto_*"])`, fetchedEmptyCtx, false}, + {"hasAllPatterns list some unsatisfied on reachable server", `Tools.HasAllPatterns(["mitto_*", "jira_other"])`, augCtx, false}, + {"hasAllPatterns list unknown server fails open (does not fail the AND)", `Tools.HasAllPatterns(["mitto_*", "slack_*"])`, augCtx, true}, + {"hasAllPatterns cold-start empty servers fails open", `Tools.HasAllPatterns(["mitto_*"])`, fetchedEmptyCtx, true}, {"hasAllPatterns unknown tools fails open", `Tools.HasAllPatterns(["mitto_*"])`, unknownToolsCtx, true}, - // Tools.HasAnyPattern — list arg + // Tools.HasAnyPattern — list arg. "slack"/"notion" are unknown + // servers in augCtx, so they fail open individually and thus satisfy + // the OR — see the reachable-but-no-match case below for a genuine + // negative. {"hasAnyPattern list one satisfied", `Tools.HasAnyPattern(["slack_*", "jira_*"])`, augCtx, true}, - {"hasAnyPattern list none satisfied", `Tools.HasAnyPattern(["slack_*", "notion_*"])`, augCtx, false}, + {"hasAnyPattern list unknown servers fail open", `Tools.HasAnyPattern(["slack_*", "notion_*"])`, augCtx, true}, + {"hasAnyPattern list none satisfied on reachable servers", `Tools.HasAnyPattern(["mitto_other", "jira_other"])`, augCtx, false}, // Tools.HasAnyPattern — single string arg {"hasAnyPattern single satisfied", `Tools.HasAnyPattern("github_*")`, augCtx, true}, - {"hasAnyPattern fetched-empty fails closed", `Tools.HasAnyPattern(["mitto_*"])`, fetchedEmptyCtx, false}, + {"hasAnyPattern cold-start empty servers fails open", `Tools.HasAnyPattern(["mitto_*"])`, fetchedEmptyCtx, true}, {"hasAnyPattern unknown tools fails open", `Tools.HasAnyPattern(["mitto_*"])`, unknownToolsCtx, true}, {"hasPattern unknown tools fails open", `Tools.HasPattern("mitto_*")`, unknownToolsCtx, true}, - {"hasPattern fetched-empty fails closed", `Tools.HasPattern("mitto_*")`, fetchedEmptyCtx, false}, + {"hasPattern cold-start empty servers fails open", `Tools.HasPattern("mitto_*")`, fetchedEmptyCtx, true}, // Combined expression {"combined matchesServerType and hasAllPatterns", @@ -418,10 +437,10 @@ func TestCELEvaluator_AllContextFields(t *testing.T) { ctx := &PromptEnabledContext{ ACP: ACPContext{Name: "test", Type: "mytype", Tags: []string{"t1"}, AutoApprove: true}, Workspace: WorkspaceContext{UUID: "wu", Folder: "/ws", Name: "My WS"}, - Session: SessionContext{ID: "sid", Name: "sname", IsChild: true, IsAutoChild: false, ParentID: "pid", IsPeriodicConversation: true, HasMessages: true, ModelTags: []string{"smart"}}, + Session: SessionContext{ID: "sid", Name: "sname", IsChild: true, IsAutoChild: false, ParentID: "pid", IsLoopConversation: true, HasMessages: true, ModelTags: []string{"smart"}}, Parent: ParentContext{Exists: true, Name: "pname", ACPServer: "pacp"}, Children: ChildrenContext{Count: 3, Exists: true, MCPCount: 2, Names: []string{"c1"}, ACPServers: []string{"a1"}, PromptingCount: 1, IdleCount: 2}, - Tools: ToolsContext{Available: true, Names: []string{"tool_a", "tool_b"}}, + Tools: NewReachableToolsContext([]string{"tool_a", "tool_b"}), Permissions: PermissionsContext{ CanDoIntrospection: true, CanSendPrompt: true, @@ -445,7 +464,7 @@ func TestCELEvaluator_AllContextFields(t *testing.T) { `Session.IsChild`, `!Session.IsAutoChild`, `Session.ParentID == "pid"`, - `Session.IsPeriodicConversation`, + `Session.IsLoopConversation`, `Session.HasMessages`, `"smart" in Session.ModelTags`, `Session.HasModelTag("smart")`, @@ -482,23 +501,23 @@ func TestCELEvaluator_AllContextFields(t *testing.T) { } } -// TestCELEvaluator_SessionIsPeriodicConversation validates the Session.IsPeriodicConversation variable. -func TestCELEvaluator_SessionIsPeriodicConversation(t *testing.T) { +// TestCELEvaluator_SessionIsLoopConversation validates the Session.IsLoopConversation variable. +func TestCELEvaluator_SessionIsLoopConversation(t *testing.T) { e := newTestEvaluator(t) - ce := compile(t, e, "Session.IsPeriodicConversation") + ce := compile(t, e, "Session.IsLoopConversation") trueCtx := &PromptEnabledContext{ - Session: SessionContext{IsPeriodicConversation: true}, + Session: SessionContext{IsLoopConversation: true}, } if got := evaluate(t, e, ce, trueCtx); !got { - t.Error("expected true when IsPeriodicConversation=true") + t.Error("expected true when IsLoopConversation=true") } falseCtx := &PromptEnabledContext{ - Session: SessionContext{IsPeriodicConversation: false}, + Session: SessionContext{IsLoopConversation: false}, } if got := evaluate(t, e, ce, falseCtx); got { - t.Error("expected false when IsPeriodicConversation=false") + t.Error("expected false when IsLoopConversation=false") } } @@ -559,23 +578,23 @@ func TestCELEvaluator_SessionHasModelTag(t *testing.T) { } } -// TestCELEvaluator_SessionIsPeriodicForced validates the Session.IsPeriodicForced variable. -func TestCELEvaluator_SessionIsPeriodicForced(t *testing.T) { +// TestCELEvaluator_SessionIsLoopForced validates the Session.IsLoopForced variable. +func TestCELEvaluator_SessionIsLoopForced(t *testing.T) { e := newTestEvaluator(t) - ce := compile(t, e, "Session.IsPeriodicForced") + ce := compile(t, e, "Session.IsLoopForced") trueCtx := &PromptEnabledContext{ - Session: SessionContext{IsPeriodicForced: true}, + Session: SessionContext{IsLoopForced: true}, } if got := evaluate(t, e, ce, trueCtx); !got { - t.Error("expected true when IsPeriodicForced=true") + t.Error("expected true when IsLoopForced=true") } falseCtx := &PromptEnabledContext{ - Session: SessionContext{IsPeriodicForced: false}, + Session: SessionContext{IsLoopForced: false}, } if got := evaluate(t, e, ce, falseCtx); got { - t.Error("expected false when IsPeriodicForced=false") + t.Error("expected false when IsLoopForced=false") } } @@ -736,7 +755,7 @@ var benchEvalCtx = &PromptEnabledContext{ Session: SessionContext{ID: "s1", IsChild: true, ParentID: "p1"}, Parent: ParentContext{Exists: true, Name: "Parent", ACPServer: "augment"}, Children: ChildrenContext{Count: 2, Exists: true}, - Tools: ToolsContext{Available: true, Names: []string{"github_create_pr", "github_list_issues", "mitto_list"}}, + Tools: NewReachableToolsContext([]string{"github_create_pr", "github_list_issues", "mitto_list"}), } // BenchmarkEvaluate measures the per-evaluation cost after the program is compiled diff --git a/internal/config/config.go b/internal/config/config.go index ed08a1e9..6edcb8ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -193,10 +193,10 @@ type WebPrompt struct { // A nil value means enabled (default true). Only explicit false disables. // This is used during merge to allow higher-priority sources to disable prompts. Enabled *bool `json:"enabled,omitempty"` - // Periodic, if non-nil, declares that selecting this prompt in a menu creates - // a periodic (recurring) conversation instead of a one-time seed. The fields + // Loop, if non-nil, declares that selecting this prompt in a menu creates + // a loop (recurring) conversation instead of a one-time seed. The fields // provide default schedule values for the schedule dialog. - Periodic *PromptPeriodic `json:"periodic,omitempty"` + Loop *PromptLoop `json:"loop,omitempty"` // PreferredModels is an ordered list of references to global model profiles // (Settings → Models), by profile name or capability tag. The first entry that // resolves to an available model wins. Empty/absent means use the session's @@ -716,13 +716,13 @@ type ConversationsConfig struct { // workspace auto_children config) are NOT counted toward this limit. // nil means use default (10). 0 means unlimited. MaxChildConversations *int `json:"max_child_conversations,omitempty" yaml:"max_child_conversations,omitempty"` - // MaxPeriodicIterations caps the number of scheduled runs a periodic conversation - // performs before it auto-stops. nil = use default (DefaultMaxPeriodicIterations); - // 0 = unlimited (still bounded by the hardcoded GlobalMaxPeriodicIterations backstop). - MaxPeriodicIterations *int `json:"max_periodic_iterations,omitempty" yaml:"max_periodic_iterations,omitempty"` - // MinPeriodicCompletionDelaySeconds is the global lower limit (floor) for the - // on-completion periodic trigger's delay. nil = use default (DefaultMinPeriodicCompletionDelaySeconds). - MinPeriodicCompletionDelaySeconds *int `json:"min_periodic_completion_delay_seconds,omitempty" yaml:"min_periodic_completion_delay_seconds,omitempty"` + // MaxLoopIterations caps the number of scheduled runs a loop conversation + // performs before it auto-stops. nil = use default (DefaultMaxLoopIterations); + // 0 = unlimited (still bounded by the hardcoded GlobalMaxLoopIterations backstop). + MaxLoopIterations *int `json:"max_loop_iterations,omitempty" yaml:"max_loop_iterations,omitempty"` + // MinLoopCompletionDelaySeconds is the global lower limit (floor) for the + // on-completion loop trigger's delay. nil = use default (DefaultMinLoopCompletionDelaySeconds). + MinLoopCompletionDelaySeconds *int `json:"min_loop_completion_delay_seconds,omitempty" yaml:"min_loop_completion_delay_seconds,omitempty"` } // ActionButtonsConfig configures the follow-up suggestions feature. @@ -929,51 +929,51 @@ func (c *ConversationsConfig) GetMaxChildConversations() int { return *c.MaxChildConversations } -// DefaultMaxPeriodicIterations is the default user-facing cap on scheduled runs -// for a periodic conversation when no explicit limit is configured. -const DefaultMaxPeriodicIterations = 100 +// DefaultMaxLoopIterations is the default user-facing cap on scheduled runs +// for a loop conversation when no explicit limit is configured. +const DefaultMaxLoopIterations = 100 -// DefaultMinPeriodicCompletionDelaySeconds is the default floor (seconds) applied to the -// on-completion periodic delay to prevent hot loops. -const DefaultMinPeriodicCompletionDelaySeconds = 5 +// DefaultMinLoopCompletionDelaySeconds is the default floor (seconds) applied to the +// on-completion loop delay to prevent hot loops. +const DefaultMinLoopCompletionDelaySeconds = 5 -// GlobalMaxPeriodicIterations is the hardcoded absolute backstop on scheduled runs -// for any periodic conversation. It can never be exceeded by config. -const GlobalMaxPeriodicIterations = 1000 +// GlobalMaxLoopIterations is the hardcoded absolute backstop on scheduled runs +// for any loop conversation. It can never be exceeded by config. +const GlobalMaxLoopIterations = 1000 -// GetMaxPeriodicIterations returns the configured default max periodic-iterations cap. -// Safe to call on nil receiver - returns DefaultMaxPeriodicIterations when unset. -// Returns 0 for unlimited. The returned value is clamped to GlobalMaxPeriodicIterations. -func (c *ConversationsConfig) GetMaxPeriodicIterations() int { - if c == nil || c.MaxPeriodicIterations == nil { - return DefaultMaxPeriodicIterations +// GetMaxLoopIterations returns the configured default max loop-iterations cap. +// Safe to call on nil receiver - returns DefaultMaxLoopIterations when unset. +// Returns 0 for unlimited. The returned value is clamped to GlobalMaxLoopIterations. +func (c *ConversationsConfig) GetMaxLoopIterations() int { + if c == nil || c.MaxLoopIterations == nil { + return DefaultMaxLoopIterations } - v := *c.MaxPeriodicIterations - if v > GlobalMaxPeriodicIterations { - return GlobalMaxPeriodicIterations + v := *c.MaxLoopIterations + if v > GlobalMaxLoopIterations { + return GlobalMaxLoopIterations } return v } -// GetMinPeriodicCompletionDelaySeconds returns the configured floor for the on-completion delay. -// Safe to call on nil receiver - returns DefaultMinPeriodicCompletionDelaySeconds when unset. +// GetMinLoopCompletionDelaySeconds returns the configured floor for the on-completion delay. +// Safe to call on nil receiver - returns DefaultMinLoopCompletionDelaySeconds when unset. // A configured value < 0 is treated as 0. -func (c *ConversationsConfig) GetMinPeriodicCompletionDelaySeconds() int { - if c == nil || c.MinPeriodicCompletionDelaySeconds == nil { - return DefaultMinPeriodicCompletionDelaySeconds +func (c *ConversationsConfig) GetMinLoopCompletionDelaySeconds() int { + if c == nil || c.MinLoopCompletionDelaySeconds == nil { + return DefaultMinLoopCompletionDelaySeconds } - v := *c.MinPeriodicCompletionDelaySeconds + v := *c.MinLoopCompletionDelaySeconds if v < 0 { return 0 } return v } -// EffectiveMaxPeriodicIterations returns the binding iteration cap for a periodic -// conversation: the smallest positive of { promptMax, configMax, GlobalMaxPeriodicIterations }. +// EffectiveMaxLoopIterations returns the binding iteration cap for a loop +// conversation: the smallest positive of { promptMax, configMax, GlobalMaxLoopIterations }. // The hardcoded backstop always applies, so the result is always positive. -func EffectiveMaxPeriodicIterations(promptMax, configMax int) int { - effective := GlobalMaxPeriodicIterations +func EffectiveMaxLoopIterations(promptMax, configMax int) int { + effective := GlobalMaxLoopIterations if promptMax > 0 && promptMax < effective { effective = promptMax } @@ -1257,6 +1257,10 @@ type Config struct { // Models is the list of named model profiles (criteria + tags) for tag-based // model-capability lookups. Models []ModelProfile + // Shortcuts holds global per-section configurable shortcut buttons, keyed by + // section ID (e.g. "conversations", "tasksList", "beadsIssue"). These are + // merged with folder-level shortcuts at render time (global entries first). + Shortcuts map[string][]ShortcutButton } // rawModelCriteria is used for YAML unmarshaling of a model profile's criteria. @@ -1291,7 +1295,7 @@ type rawACPServerConfig struct { Menus string `yaml:"menus"` Enabled *bool `yaml:"enabled"` EnabledWhen string `yaml:"enabledWhen"` - Periodic *PromptPeriodic `yaml:"periodic,omitempty"` + Loop *PromptLoop `yaml:"loop,omitempty"` Parameters []PromptParameter `yaml:"parameters"` Tags []string `yaml:"tags"` Singleton bool `yaml:"singleton"` @@ -1316,14 +1320,16 @@ type rawConfig struct { Menus string `yaml:"menus"` Enabled *bool `yaml:"enabled"` EnabledWhen string `yaml:"enabledWhen"` - Periodic *PromptPeriodic `yaml:"periodic,omitempty"` + Loop *PromptLoop `yaml:"loop,omitempty"` Parameters []PromptParameter `yaml:"parameters"` Tags []string `yaml:"tags"` Singleton bool `yaml:"singleton"` } `yaml:"prompts"` // PromptsDirs is a list of additional directories to search for prompt files PromptsDirs []string `yaml:"prompts_dirs"` - Web struct { + // Shortcuts is the top-level global shortcut buttons section, keyed by section ID. + Shortcuts map[string][]ShortcutButton `yaml:"shortcuts"` + Web struct { Host string `yaml:"host"` Port int `yaml:"port"` ExternalPort int `yaml:"external_port"` @@ -1418,10 +1424,10 @@ type rawConfig struct { ExternalImages *struct { Enabled *bool `yaml:"enabled"` } `yaml:"external_images"` - DefaultFlags map[string]bool `yaml:"default_flags"` - MaxChildConversations *int `yaml:"max_child_conversations"` - MaxPeriodicIterations *int `yaml:"max_periodic_iterations"` - MinPeriodicCompletionDelaySeconds *int `yaml:"min_periodic_completion_delay_seconds"` + DefaultFlags map[string]bool `yaml:"default_flags"` + MaxChildConversations *int `yaml:"max_child_conversations"` + MaxLoopIterations *int `yaml:"max_loop_iterations"` + MinLoopCompletionDelaySeconds *int `yaml:"min_loop_completion_delay_seconds"` } `yaml:"conversations"` // RestrictedRunners is the top-level per-runner-type configuration RestrictedRunners map[string]*WorkspaceRunnerConfig `yaml:"restricted_runners"` @@ -1431,14 +1437,15 @@ type rawConfig struct { } `yaml:"permissions"` // Session is the session storage/startup configuration Session *struct { - MaxMessagesPerSession int `yaml:"max_messages_per_session"` - MaxSessionSizeBytes int64 `yaml:"max_session_size_bytes"` - ArchiveRetentionPeriod string `yaml:"archive_retention_period"` - AutoArchiveInactiveAfter string `yaml:"auto_archive_inactive_after"` - StartupStaggerMs int `yaml:"startup_stagger_ms"` - StartupPeriodicDelaySeconds int `yaml:"startup_periodic_delay_seconds"` - PeriodicSuspendTimeout string `yaml:"periodic_suspend_timeout"` - MemoryRecycleThreshold string `yaml:"memory_recycle_threshold"` + MaxMessagesPerSession int `yaml:"max_messages_per_session"` + MaxSessionSizeBytes int64 `yaml:"max_session_size_bytes"` + ArchiveRetentionPeriod string `yaml:"archive_retention_period"` + AutoArchiveInactiveAfter string `yaml:"auto_archive_inactive_after"` + StartupStaggerMs int `yaml:"startup_stagger_ms"` + StartupLoopDelaySeconds int `yaml:"startup_loop_delay_seconds"` + LoopSuspendTimeout string `yaml:"loop_suspend_timeout"` + MemoryRecycleThreshold string `yaml:"memory_recycle_threshold"` + AgentInactivityTimeout string `yaml:"agent_inactivity_timeout"` } `yaml:"session"` // MCP is the MCP server configuration MCP *struct { @@ -1523,7 +1530,7 @@ func Parse(data []byte) (*Config, error) { Singleton: p.Singleton, Tags: p.Tags, EnabledWhen: p.EnabledWhen, - Periodic: p.Periodic, + Loop: p.Loop, Parameters: p.Parameters, } acpServer.Prompts = append(acpServer.Prompts, wp) @@ -1577,7 +1584,7 @@ func Parse(data []byte) (*Config, error) { Tags: p.Tags, EnabledWhen: p.EnabledWhen, Enabled: p.Enabled, - Periodic: p.Periodic, + Loop: p.Loop, Parameters: p.Parameters, } cfg.Prompts = append(cfg.Prompts, wp) @@ -1586,6 +1593,9 @@ func Parse(data []byte) (*Config, error) { // Populate prompts directories cfg.PromptsDirs = raw.PromptsDirs + // Populate global shortcut buttons + cfg.Shortcuts = raw.Shortcuts + // Populate web config cfg.Web.Host = raw.Web.Host cfg.Web.Port = raw.Web.Port @@ -1758,21 +1768,21 @@ func Parse(data []byte) (*Config, error) { cfg.Conversations.MaxChildConversations = raw.Conversations.MaxChildConversations } - // Copy max periodic iterations - if raw.Conversations.MaxPeriodicIterations != nil { - cfg.Conversations.MaxPeriodicIterations = raw.Conversations.MaxPeriodicIterations + // Copy max loop iterations + if raw.Conversations.MaxLoopIterations != nil { + cfg.Conversations.MaxLoopIterations = raw.Conversations.MaxLoopIterations } - // Copy min periodic completion delay - if raw.Conversations.MinPeriodicCompletionDelaySeconds != nil { - cfg.Conversations.MinPeriodicCompletionDelaySeconds = raw.Conversations.MinPeriodicCompletionDelaySeconds + // Copy min loop completion delay + if raw.Conversations.MinLoopCompletionDelaySeconds != nil { + cfg.Conversations.MinLoopCompletionDelaySeconds = raw.Conversations.MinLoopCompletionDelaySeconds } // If no config was actually set, nil out the conversations config if cfg.Conversations.Processing == nil && cfg.Conversations.Queue == nil && cfg.Conversations.ActionButtons == nil && cfg.Conversations.ExternalImages == nil && cfg.Conversations.DefaultFlags == nil && cfg.Conversations.MaxChildConversations == nil && - cfg.Conversations.MaxPeriodicIterations == nil && cfg.Conversations.MinPeriodicCompletionDelaySeconds == nil { + cfg.Conversations.MaxLoopIterations == nil && cfg.Conversations.MinLoopCompletionDelaySeconds == nil { cfg.Conversations = nil } } @@ -1790,14 +1800,15 @@ func Parse(data []byte) (*Config, error) { // Parse session config if raw.Session != nil { cfg.Session = &SessionConfig{ - MaxMessagesPerSession: raw.Session.MaxMessagesPerSession, - MaxSessionSizeBytes: raw.Session.MaxSessionSizeBytes, - ArchiveRetentionPeriod: raw.Session.ArchiveRetentionPeriod, - AutoArchiveInactiveAfter: raw.Session.AutoArchiveInactiveAfter, - StartupStaggerMs: raw.Session.StartupStaggerMs, - StartupPeriodicDelaySeconds: raw.Session.StartupPeriodicDelaySeconds, - PeriodicSuspendTimeout: raw.Session.PeriodicSuspendTimeout, - MemoryRecycleThreshold: raw.Session.MemoryRecycleThreshold, + MaxMessagesPerSession: raw.Session.MaxMessagesPerSession, + MaxSessionSizeBytes: raw.Session.MaxSessionSizeBytes, + ArchiveRetentionPeriod: raw.Session.ArchiveRetentionPeriod, + AutoArchiveInactiveAfter: raw.Session.AutoArchiveInactiveAfter, + StartupStaggerMs: raw.Session.StartupStaggerMs, + StartupLoopDelaySeconds: raw.Session.StartupLoopDelaySeconds, + LoopSuspendTimeout: raw.Session.LoopSuspendTimeout, + MemoryRecycleThreshold: raw.Session.MemoryRecycleThreshold, + AgentInactivityTimeout: raw.Session.AgentInactivityTimeout, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a9a29a31..c9ce7736 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2143,52 +2143,52 @@ func TestPermissionsConfig_IsAutoApprove(t *testing.T) { } } -func TestGetMaxPeriodicIterations(t *testing.T) { +func TestGetMaxLoopIterations(t *testing.T) { t.Run("nil config returns default", func(t *testing.T) { var c *ConversationsConfig - got := c.GetMaxPeriodicIterations() - if got != DefaultMaxPeriodicIterations { - t.Errorf("GetMaxPeriodicIterations() = %d, want %d", got, DefaultMaxPeriodicIterations) + got := c.GetMaxLoopIterations() + if got != DefaultMaxLoopIterations { + t.Errorf("GetMaxLoopIterations() = %d, want %d", got, DefaultMaxLoopIterations) } }) t.Run("nil field returns default", func(t *testing.T) { c := &ConversationsConfig{} - got := c.GetMaxPeriodicIterations() - if got != DefaultMaxPeriodicIterations { - t.Errorf("GetMaxPeriodicIterations() = %d, want %d", got, DefaultMaxPeriodicIterations) + got := c.GetMaxLoopIterations() + if got != DefaultMaxLoopIterations { + t.Errorf("GetMaxLoopIterations() = %d, want %d", got, DefaultMaxLoopIterations) } }) t.Run("set value returned", func(t *testing.T) { v := 50 - c := &ConversationsConfig{MaxPeriodicIterations: &v} - got := c.GetMaxPeriodicIterations() + c := &ConversationsConfig{MaxLoopIterations: &v} + got := c.GetMaxLoopIterations() if got != 50 { - t.Errorf("GetMaxPeriodicIterations() = %d, want 50", got) + t.Errorf("GetMaxLoopIterations() = %d, want 50", got) } }) t.Run("value above backstop clamped to backstop", func(t *testing.T) { - v := GlobalMaxPeriodicIterations + 500 - c := &ConversationsConfig{MaxPeriodicIterations: &v} - got := c.GetMaxPeriodicIterations() - if got != GlobalMaxPeriodicIterations { - t.Errorf("GetMaxPeriodicIterations() = %d, want %d (backstop)", got, GlobalMaxPeriodicIterations) + v := GlobalMaxLoopIterations + 500 + c := &ConversationsConfig{MaxLoopIterations: &v} + got := c.GetMaxLoopIterations() + if got != GlobalMaxLoopIterations { + t.Errorf("GetMaxLoopIterations() = %d, want %d (backstop)", got, GlobalMaxLoopIterations) } }) t.Run("zero returns zero (unlimited)", func(t *testing.T) { v := 0 - c := &ConversationsConfig{MaxPeriodicIterations: &v} - got := c.GetMaxPeriodicIterations() + c := &ConversationsConfig{MaxLoopIterations: &v} + got := c.GetMaxLoopIterations() if got != 0 { - t.Errorf("GetMaxPeriodicIterations() = %d, want 0 (unlimited)", got) + t.Errorf("GetMaxLoopIterations() = %d, want 0 (unlimited)", got) } }) } -func TestEffectiveMaxPeriodicIterations(t *testing.T) { +func TestEffectiveMaxLoopIterations(t *testing.T) { tests := []struct { name string promptMax int @@ -2199,7 +2199,7 @@ func TestEffectiveMaxPeriodicIterations(t *testing.T) { name: "both zero → backstop", promptMax: 0, configMax: 0, - want: GlobalMaxPeriodicIterations, + want: GlobalMaxLoopIterations, }, { name: "prompt cap wins (smallest positive)", @@ -2229,34 +2229,34 @@ func TestEffectiveMaxPeriodicIterations(t *testing.T) { name: "both above backstop → backstop", promptMax: 1500, configMax: 2000, - want: GlobalMaxPeriodicIterations, + want: GlobalMaxLoopIterations, }, { name: "prompt at backstop, config zero → backstop", - promptMax: GlobalMaxPeriodicIterations, + promptMax: GlobalMaxLoopIterations, configMax: 0, - want: GlobalMaxPeriodicIterations, + want: GlobalMaxLoopIterations, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := EffectiveMaxPeriodicIterations(tt.promptMax, tt.configMax) + got := EffectiveMaxLoopIterations(tt.promptMax, tt.configMax) if got != tt.want { - t.Errorf("EffectiveMaxPeriodicIterations(%d, %d) = %d, want %d", + t.Errorf("EffectiveMaxLoopIterations(%d, %d) = %d, want %d", tt.promptMax, tt.configMax, got, tt.want) } }) } } -func TestParse_MaxPeriodicIterations(t *testing.T) { +func TestParse_MaxLoopIterations(t *testing.T) { yaml := ` acp: - test: command: "test --acp" conversations: - max_periodic_iterations: 42 + max_loop_iterations: 42 ` cfg, err := Parse([]byte(yaml)) if err != nil { @@ -2265,24 +2265,24 @@ conversations: if cfg.Conversations == nil { t.Fatal("Conversations is nil") } - if cfg.Conversations.MaxPeriodicIterations == nil { - t.Fatal("MaxPeriodicIterations is nil, want 42") + if cfg.Conversations.MaxLoopIterations == nil { + t.Fatal("MaxLoopIterations is nil, want 42") } - if *cfg.Conversations.MaxPeriodicIterations != 42 { - t.Errorf("MaxPeriodicIterations = %d, want 42", *cfg.Conversations.MaxPeriodicIterations) + if *cfg.Conversations.MaxLoopIterations != 42 { + t.Errorf("MaxLoopIterations = %d, want 42", *cfg.Conversations.MaxLoopIterations) } - if cfg.Conversations.GetMaxPeriodicIterations() != 42 { - t.Errorf("GetMaxPeriodicIterations() = %d, want 42", cfg.Conversations.GetMaxPeriodicIterations()) + if cfg.Conversations.GetMaxLoopIterations() != 42 { + t.Errorf("GetMaxLoopIterations() = %d, want 42", cfg.Conversations.GetMaxLoopIterations()) } } -func TestParse_MaxPeriodicIterations_Zero(t *testing.T) { +func TestParse_MaxLoopIterations_Zero(t *testing.T) { yaml := ` acp: - test: command: "test --acp" conversations: - max_periodic_iterations: 0 + max_loop_iterations: 0 ` cfg, err := Parse([]byte(yaml)) if err != nil { @@ -2291,69 +2291,69 @@ conversations: if cfg.Conversations == nil { t.Fatal("Conversations is nil") } - if cfg.Conversations.MaxPeriodicIterations == nil { - t.Fatal("MaxPeriodicIterations is nil, want 0") + if cfg.Conversations.MaxLoopIterations == nil { + t.Fatal("MaxLoopIterations is nil, want 0") } - if *cfg.Conversations.MaxPeriodicIterations != 0 { - t.Errorf("MaxPeriodicIterations = %d, want 0", *cfg.Conversations.MaxPeriodicIterations) + if *cfg.Conversations.MaxLoopIterations != 0 { + t.Errorf("MaxLoopIterations = %d, want 0", *cfg.Conversations.MaxLoopIterations) } - if cfg.Conversations.GetMaxPeriodicIterations() != 0 { - t.Errorf("GetMaxPeriodicIterations() = %d, want 0 (unlimited)", cfg.Conversations.GetMaxPeriodicIterations()) + if cfg.Conversations.GetMaxLoopIterations() != 0 { + t.Errorf("GetMaxLoopIterations() = %d, want 0 (unlimited)", cfg.Conversations.GetMaxLoopIterations()) } } -func TestGetMinPeriodicCompletionDelaySeconds(t *testing.T) { +func TestGetMinLoopCompletionDelaySeconds(t *testing.T) { t.Run("nil config returns default", func(t *testing.T) { var c *ConversationsConfig - got := c.GetMinPeriodicCompletionDelaySeconds() - if got != DefaultMinPeriodicCompletionDelaySeconds { - t.Errorf("GetMinPeriodicCompletionDelaySeconds() = %d, want %d", got, DefaultMinPeriodicCompletionDelaySeconds) + got := c.GetMinLoopCompletionDelaySeconds() + if got != DefaultMinLoopCompletionDelaySeconds { + t.Errorf("GetMinLoopCompletionDelaySeconds() = %d, want %d", got, DefaultMinLoopCompletionDelaySeconds) } }) t.Run("nil field returns default", func(t *testing.T) { c := &ConversationsConfig{} - got := c.GetMinPeriodicCompletionDelaySeconds() - if got != DefaultMinPeriodicCompletionDelaySeconds { - t.Errorf("GetMinPeriodicCompletionDelaySeconds() = %d, want %d", got, DefaultMinPeriodicCompletionDelaySeconds) + got := c.GetMinLoopCompletionDelaySeconds() + if got != DefaultMinLoopCompletionDelaySeconds { + t.Errorf("GetMinLoopCompletionDelaySeconds() = %d, want %d", got, DefaultMinLoopCompletionDelaySeconds) } }) t.Run("set value returned", func(t *testing.T) { v := 10 - c := &ConversationsConfig{MinPeriodicCompletionDelaySeconds: &v} - got := c.GetMinPeriodicCompletionDelaySeconds() + c := &ConversationsConfig{MinLoopCompletionDelaySeconds: &v} + got := c.GetMinLoopCompletionDelaySeconds() if got != 10 { - t.Errorf("GetMinPeriodicCompletionDelaySeconds() = %d, want 10", got) + t.Errorf("GetMinLoopCompletionDelaySeconds() = %d, want 10", got) } }) t.Run("negative value treated as zero", func(t *testing.T) { v := -3 - c := &ConversationsConfig{MinPeriodicCompletionDelaySeconds: &v} - got := c.GetMinPeriodicCompletionDelaySeconds() + c := &ConversationsConfig{MinLoopCompletionDelaySeconds: &v} + got := c.GetMinLoopCompletionDelaySeconds() if got != 0 { - t.Errorf("GetMinPeriodicCompletionDelaySeconds() = %d, want 0 (negative → 0)", got) + t.Errorf("GetMinLoopCompletionDelaySeconds() = %d, want 0 (negative → 0)", got) } }) t.Run("zero is valid (no floor)", func(t *testing.T) { v := 0 - c := &ConversationsConfig{MinPeriodicCompletionDelaySeconds: &v} - got := c.GetMinPeriodicCompletionDelaySeconds() + c := &ConversationsConfig{MinLoopCompletionDelaySeconds: &v} + got := c.GetMinLoopCompletionDelaySeconds() if got != 0 { - t.Errorf("GetMinPeriodicCompletionDelaySeconds() = %d, want 0", got) + t.Errorf("GetMinLoopCompletionDelaySeconds() = %d, want 0", got) } }) } -func TestParse_MinPeriodicCompletionDelaySeconds(t *testing.T) { +func TestParse_MinLoopCompletionDelaySeconds(t *testing.T) { yaml := ` acp: - test: command: "test --acp" conversations: - min_periodic_completion_delay_seconds: 10 + min_loop_completion_delay_seconds: 10 ` cfg, err := Parse([]byte(yaml)) if err != nil { @@ -2362,14 +2362,14 @@ conversations: if cfg.Conversations == nil { t.Fatal("Conversations is nil") } - if cfg.Conversations.MinPeriodicCompletionDelaySeconds == nil { - t.Fatal("MinPeriodicCompletionDelaySeconds is nil, want 10") + if cfg.Conversations.MinLoopCompletionDelaySeconds == nil { + t.Fatal("MinLoopCompletionDelaySeconds is nil, want 10") } - if *cfg.Conversations.MinPeriodicCompletionDelaySeconds != 10 { - t.Errorf("MinPeriodicCompletionDelaySeconds = %d, want 10", *cfg.Conversations.MinPeriodicCompletionDelaySeconds) + if *cfg.Conversations.MinLoopCompletionDelaySeconds != 10 { + t.Errorf("MinLoopCompletionDelaySeconds = %d, want 10", *cfg.Conversations.MinLoopCompletionDelaySeconds) } - if cfg.Conversations.GetMinPeriodicCompletionDelaySeconds() != 10 { - t.Errorf("GetMinPeriodicCompletionDelaySeconds() = %d, want 10", cfg.Conversations.GetMinPeriodicCompletionDelaySeconds()) + if cfg.Conversations.GetMinLoopCompletionDelaySeconds() != 10 { + t.Errorf("GetMinLoopCompletionDelaySeconds() = %d, want 10", cfg.Conversations.GetMinLoopCompletionDelaySeconds()) } } @@ -2624,3 +2624,38 @@ func TestParse_EmbeddedDefaultModelProfiles(t *testing.T) { t.Errorf("ResolveModelTags(Gemini 2.5 Pro) = %v, want [Smart LongContext]", got) } } + +// TestParse_EmbeddedDefaultShortcuts pins the safe default global shortcuts +// seeded into new installs via the embedded config/config.default.yaml. It +// guards against the shipped defaults drifting (bad YAML, renamed sections, or +// dropped buttons) so first-time users always get working shortcut buttons. +func TestParse_EmbeddedDefaultShortcuts(t *testing.T) { + cfg, err := Parse(defaultConfig.DefaultConfigYAML) + if err != nil { + t.Fatalf("Parse(embedded default) failed: %v", err) + } + + want := map[string]string{ + "conversations": "Commit changes", + "beadsIssue": "Start work", + "tasksList": "Overview", + } + + if len(cfg.Shortcuts) != len(want) { + t.Fatalf("embedded default Shortcuts sections = %d, want %d (%v)", len(cfg.Shortcuts), len(want), cfg.Shortcuts) + } + for section, wantPrompt := range want { + buttons, ok := cfg.Shortcuts[section] + if !ok { + t.Errorf("embedded default missing shortcuts section %q", section) + continue + } + if len(buttons) != 1 { + t.Errorf("section %q buttons = %d, want 1", section, len(buttons)) + continue + } + if buttons[0].Prompt != wantPrompt { + t.Errorf("section %q prompt = %q, want %q", section, buttons[0].Prompt, wantPrompt) + } + } +} diff --git a/internal/config/folders.go b/internal/config/folders.go index 81f14b6a..c00af46c 100644 --- a/internal/config/folders.go +++ b/internal/config/folders.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "fmt" + "maps" "os" "path/filepath" "strings" @@ -75,6 +76,12 @@ type BeadsFolderSettings struct { // SyncPrompt is the name of the workspace prompt to run for a sync operation. // Only meaningful when Upstream == "prompts". SyncPrompt string `json:"syncPrompt,omitempty" yaml:"syncPrompt,omitempty"` + // PullPromptArgs, PushPromptArgs, SyncPromptArgs hold the argument values to + // forward to the corresponding prompt when it is dispatched. Only meaningful + // when Upstream == "prompts". + PullPromptArgs map[string]string `json:"pullPromptArgs,omitempty" yaml:"pullPromptArgs,omitempty"` + PushPromptArgs map[string]string `json:"pushPromptArgs,omitempty" yaml:"pushPromptArgs,omitempty"` + SyncPromptArgs map[string]string `json:"syncPromptArgs,omitempty" yaml:"syncPromptArgs,omitempty"` } // FoldersFile is the on-disk representation of folders.json. It maps a working @@ -328,7 +335,10 @@ func beadsEqual(a, b *BeadsFolderSettings) bool { return a.Upstream == b.Upstream && a.PullPrompt == b.PullPrompt && a.PushPrompt == b.PushPrompt && - a.SyncPrompt == b.SyncPrompt + a.SyncPrompt == b.SyncPrompt && + maps.Equal(a.PullPromptArgs, b.PullPromptArgs) && + maps.Equal(a.PushPromptArgs, b.PushPromptArgs) && + maps.Equal(a.SyncPromptArgs, b.SyncPromptArgs) } // folderSettingsEmpty reports whether a FolderSettings carries no information @@ -428,11 +438,11 @@ func SetFolderBeadsUpstream(workingDir, upstream string) error { } // SetFolderBeadsPromptUpstream sets the beads upstream to "prompts" and -// persists the three configured prompt names to folders.json. Empty prompt names -// are allowed (the corresponding operation is simply unconfigured). This is a -// folder-native field, preserved across workspace-driven saves by -// preserveFolderNativeFields. -func SetFolderBeadsPromptUpstream(workingDir, pull, push, sync string) error { +// persists the three configured prompt names (and their argument maps) to +// folders.json. Empty prompt names are allowed (the corresponding operation is +// simply unconfigured). This is a folder-native field, preserved across +// workspace-driven saves by preserveFolderNativeFields. +func SetFolderBeadsPromptUpstream(workingDir, pull, push, sync string, pullArgs, pushArgs, syncArgs map[string]string) error { folders, err := LoadFolders() if err != nil { return err @@ -442,10 +452,13 @@ func SetFolderBeadsPromptUpstream(workingDir, pull, push, sync string) error { } fs := folders[workingDir] fs.Beads = &BeadsFolderSettings{ - Upstream: "prompts", - PullPrompt: pull, - PushPrompt: push, - SyncPrompt: sync, + Upstream: "prompts", + PullPrompt: pull, + PushPrompt: push, + SyncPrompt: sync, + PullPromptArgs: pullArgs, + PushPromptArgs: pushArgs, + SyncPromptArgs: syncArgs, } if folderSettingsEmpty(fs) { delete(folders, workingDir) @@ -484,6 +497,21 @@ func FolderBeadsPrompts(workingDir string) (pull, push, sync string) { return fs.Beads.PullPrompt, fs.Beads.PushPrompt, fs.Beads.SyncPrompt } +// FolderBeadsPromptArgs returns the three configured prompt argument maps for +// the "prompts" upstream of a folder. Returns nil maps if none are set or +// folders.json cannot be read. +func FolderBeadsPromptArgs(workingDir string) (pull, push, sync map[string]string) { + folders, err := LoadFolders() + if err != nil { + return nil, nil, nil + } + fs, ok := folders[workingDir] + if !ok || fs.Beads == nil { + return nil, nil, nil + } + return fs.Beads.PullPromptArgs, fs.Beads.PushPromptArgs, fs.Beads.SyncPromptArgs +} + // FolderShortcuts returns the configured shortcut sections for a folder, or nil. func FolderShortcuts(workingDir string) map[string][]ShortcutButton { folders, err := LoadFolders() diff --git a/internal/config/folders_test.go b/internal/config/folders_test.go index f4c8a04b..1ff99600 100644 --- a/internal/config/folders_test.go +++ b/internal/config/folders_test.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "maps" "os" "path/filepath" "testing" @@ -422,8 +423,18 @@ func TestSetFolderBeadsPromptUpstream_RoundTrip(t *testing.T) { t.Errorf("FolderBeadsPrompts() before set = (%q,%q,%q), want all empty", pull, push, sync) } - // Set prompts upstream with three names. - if err := SetFolderBeadsPromptUpstream("/proj", "My Pull", "My Push", "My Sync"); err != nil { + // Before any set, FolderBeadsPromptArgs also returns nil maps. + pullArgs, pushArgs, syncArgs := FolderBeadsPromptArgs("/proj") + if pullArgs != nil || pushArgs != nil || syncArgs != nil { + t.Errorf("FolderBeadsPromptArgs() before set = (%v,%v,%v), want all nil", pullArgs, pushArgs, syncArgs) + } + + // Set prompts upstream with three names and argument maps. + wantPullArgs := map[string]string{"IssueID": "mitto-1"} + wantPushArgs := map[string]string{"Force": "true"} + wantSyncArgs := map[string]string{"Verbose": "true"} + if err := SetFolderBeadsPromptUpstream("/proj", "My Pull", "My Push", "My Sync", + wantPullArgs, wantPushArgs, wantSyncArgs); err != nil { t.Fatalf("SetFolderBeadsPromptUpstream() returned error: %v", err) } @@ -434,13 +445,17 @@ func TestSetFolderBeadsPromptUpstream_RoundTrip(t *testing.T) { if pull != "My Pull" || push != "My Push" || sync != "My Sync" { t.Errorf("FolderBeadsPrompts() = (%q,%q,%q), want (My Pull,My Push,My Sync)", pull, push, sync) } + pullArgs, pushArgs, syncArgs = FolderBeadsPromptArgs("/proj") + if !maps.Equal(pullArgs, wantPullArgs) || !maps.Equal(pushArgs, wantPushArgs) || !maps.Equal(syncArgs, wantSyncArgs) { + t.Errorf("FolderBeadsPromptArgs() = (%v,%v,%v), want (%v,%v,%v)", pullArgs, pushArgs, syncArgs, wantPullArgs, wantPushArgs, wantSyncArgs) + } } func TestSetFolderBeadsUpstream_ClearsPromptNames(t *testing.T) { setupFoldersTestDir(t) // Set prompts upstream first. - if err := SetFolderBeadsPromptUpstream("/proj", "Pull", "Push", "Sync"); err != nil { + if err := SetFolderBeadsPromptUpstream("/proj", "Pull", "Push", "Sync", nil, nil, nil); err != nil { t.Fatalf("SetFolderBeadsPromptUpstream() returned error: %v", err) } @@ -468,7 +483,8 @@ func TestSaveWorkspaces_PreservesBeadsPromptUpstream(t *testing.T) { t.Fatalf("SaveWorkspaces() initial returned error: %v", err) } - if err := SetFolderBeadsPromptUpstream("/proj", "Pull", "Push", "Sync"); err != nil { + pullArgs := map[string]string{"IssueID": "mitto-1"} + if err := SetFolderBeadsPromptUpstream("/proj", "Pull", "Push", "Sync", pullArgs, nil, nil); err != nil { t.Fatalf("SetFolderBeadsPromptUpstream() returned error: %v", err) } @@ -484,6 +500,10 @@ func TestSaveWorkspaces_PreservesBeadsPromptUpstream(t *testing.T) { if pull != "Pull" || push != "Push" || sync != "Sync" { t.Errorf("FolderBeadsPrompts() after SaveWorkspaces = (%q,%q,%q), want (Pull,Push,Sync)", pull, push, sync) } + gotPullArgs, _, _ := FolderBeadsPromptArgs("/proj") + if !maps.Equal(gotPullArgs, pullArgs) { + t.Errorf("FolderBeadsPromptArgs() after SaveWorkspaces = %v, want %v", gotPullArgs, pullArgs) + } } // ---- LoadFoldersFromFile tests ---- diff --git a/internal/config/prompt_template.go b/internal/config/prompt_template.go index 22ad3942..46b240d7 100644 --- a/internal/config/prompt_template.go +++ b/internal/config/prompt_template.go @@ -24,8 +24,8 @@ var migratableMittoVars = map[string]string{ "workspace_uuid": "{{ .Workspace.UUID }}", "beads_issue": "{{ .Session.BeadsIssue }}", "mcp_children_count": "{{ .Children.MCPCount }}", - "periodic": "{{ .Session.IsPeriodic }}", - "periodic_forced": "{{ .Session.IsPeriodicForced }}", + "loop": "{{ .Session.IsLoop }}", + "loop_forced": "{{ .Session.IsLoopForced }}", "available_acp_servers": "{{ .ACP.AvailableText }}", "children": "{{ .Children.AllText }}", "mcp_children": "{{ .Children.MCPText }}", diff --git a/internal/config/prompt_template_test.go b/internal/config/prompt_template_test.go index 58587917..2b39b24d 100644 --- a/internal/config/prompt_template_test.go +++ b/internal/config/prompt_template_test.go @@ -300,14 +300,14 @@ func TestDeprecatedMittoVars(t *testing.T) { want: []string{"session_id", "working_dir"}, }, { - name: "periodic_forced before periodic", - body: "@mitto:periodic_forced and @mitto:periodic", - want: []string{"periodic", "periodic_forced"}, + name: "loop_forced before loop", + body: "@mitto:loop_forced and @mitto:loop", + want: []string{"loop", "loop_forced"}, }, { name: "all migratable tokens", - body: "@mitto:session_id @mitto:parent_session_id @mitto:parent @mitto:session_name @mitto:working_dir @mitto:acp_server @mitto:workspace_uuid @mitto:beads_issue @mitto:mcp_children_count @mitto:periodic @mitto:periodic_forced @mitto:available_acp_servers @mitto:children @mitto:mcp_children @mitto:user_data @mitto:user_data_schema", - want: []string{"acp_server", "available_acp_servers", "beads_issue", "children", "mcp_children", "mcp_children_count", "parent", "parent_session_id", "periodic", "periodic_forced", "session_id", "session_name", "user_data", "user_data_schema", "working_dir", "workspace_uuid"}, + body: "@mitto:session_id @mitto:parent_session_id @mitto:parent @mitto:session_name @mitto:working_dir @mitto:acp_server @mitto:workspace_uuid @mitto:beads_issue @mitto:mcp_children_count @mitto:loop @mitto:loop_forced @mitto:available_acp_servers @mitto:children @mitto:mcp_children @mitto:user_data @mitto:user_data_schema", + want: []string{"acp_server", "available_acp_servers", "beads_issue", "children", "loop", "loop_forced", "mcp_children", "mcp_children_count", "parent", "parent_session_id", "session_id", "session_name", "user_data", "user_data_schema", "working_dir", "workspace_uuid"}, }, } @@ -466,6 +466,77 @@ func TestIterateUntilComplete_TargetResolution(t *testing.T) { } } +// TestRefineImplementation_LoopAndModes verifies the beads-refine-implementation +// builtin prompt (mitto-mx4): +// +// (a) it parses cleanly — this exercises parse-time CEL validation of the +// onTasks loop.condition, so a broken expression fails the test; +// (b) its loop block declares the onTasks trigger + the documented CEL +// condition (mode: always) so selecting it starts a beads-change loop; +// (c) it renders without error and branches correctly between silent +// (scheduled loop) and interactive (forced / first send) modes — a guard +// against the pre-mitto-pei stale template vars (.Session.IsPeriodic*). +// +// Loaded from the real builtin directory so it exercises the on-disk content. +func TestRefineImplementation_LoopAndModes(t *testing.T) { + builtinDir := "../../config/prompts/builtin" + name := "beads-refine-implementation.prompt.yaml" + path := filepath.Join(builtinDir, name) + data, err := os.ReadFile(path) + if err != nil { + t.Skipf("prompt file not found at %s: %v", path, err) + } + prompt, err := ParsePromptFile(name, data, time.Now()) + if err != nil { + t.Fatalf("ParsePromptFile (parse-time CEL validation of loop.condition): %v", err) + } + + // (b) Loop block: onTasks trigger, always mode, non-empty documented condition. + if prompt.Loop == nil { + t.Fatalf("expected a loop block; got nil") + } + if prompt.Loop.Trigger != "onTasks" { + t.Errorf("loop.trigger = %q, want %q", prompt.Loop.Trigger, "onTasks") + } + if prompt.Loop.Mode != PromptLoopModeAlways { + t.Errorf("loop.mode = %q, want %q", prompt.Loop.Mode, PromptLoopModeAlways) + } + if !strings.Contains(prompt.Loop.Condition, "implementation-refined") { + t.Errorf("loop.condition should gate on the implementation-refined label; got %q", prompt.Loop.Condition) + } + + body := prompt.Content + render := func(ctx *PromptEnabledContext) string { + funcs := BuildTemplateFuncMap(ctx) + out, rerr := RenderPromptTemplate("beads-refine-implementation", body, ctx, funcs) + if rerr != nil { + t.Fatalf("RenderPromptTemplate: %v", rerr) + } + return out + } + + // (c) Silent: a scheduled (non-forced) loop run. + outSilent := render(&PromptEnabledContext{Session: SessionContext{IsLoop: true, IsLoopForced: false}}) + if !strings.Contains(outSilent, "Silent mode") { + t.Errorf("silent loop run: expected 'Silent mode' branch; got:\n%s", outSilent) + } + if strings.Contains(outSilent, "Interactive mode") { + t.Errorf("silent loop run: unexpected 'Interactive mode' branch") + } + + // (c) Interactive: a forced loop run (user present). + outForced := render(&PromptEnabledContext{Session: SessionContext{IsLoop: true, IsLoopForced: true}}) + if !strings.Contains(outForced, "Interactive mode") { + t.Errorf("forced run: expected 'Interactive mode' branch; got:\n%s", outForced) + } + + // (c) Interactive: a first / normal send (no loop context at all). + outFirst := render(&PromptEnabledContext{}) + if !strings.Contains(outFirst, "Interactive mode") { + t.Errorf("first send: expected 'Interactive mode' branch; got:\n%s", outFirst) + } +} + // TestInvestigate_ThreeModeTargetResolution tests the three target-bead // resolution branches of beads-issue-investigate.prompt.yaml: // @@ -574,7 +645,7 @@ func TestInvestigate_ThreeModeTargetResolution(t *testing.T) { } // TestDiscuss_ThreeModeTargetResolution tests the three target-bead -// resolution branches of beads-issue-discuss.prompt.yaml: +// resolution branches of beads-issue-assess.prompt.yaml: // // (a) .Session.BeadsIssue set → "linked-issue" mode: bead ID appears, no // "no linked bead" prose @@ -587,12 +658,12 @@ func TestInvestigate_ThreeModeTargetResolution(t *testing.T) { // and "conversation", and the IssueID parameter is non-required. func TestDiscuss_ThreeModeTargetResolution(t *testing.T) { builtinDir := "../../config/prompts/builtin" - path := filepath.Join(builtinDir, "beads-issue-discuss.prompt.yaml") + path := filepath.Join(builtinDir, "beads-issue-assess.prompt.yaml") data, err := os.ReadFile(path) if err != nil { t.Skipf("prompt file not found at %s: %v", path, err) } - prompt, err := ParsePromptFile("beads-issue-discuss.prompt.yaml", data, time.Now()) + prompt, err := ParsePromptFile("beads-issue-assess.prompt.yaml", data, time.Now()) if err != nil { t.Fatalf("ParsePromptFile: %v", err) } @@ -623,7 +694,7 @@ func TestDiscuss_ThreeModeTargetResolution(t *testing.T) { render := func(ctx *PromptEnabledContext) string { funcs := BuildTemplateFuncMap(ctx) - out, rerr := RenderPromptTemplate("beads-issue-discuss", body, ctx, funcs) + out, rerr := RenderPromptTemplate("beads-issue-assess", body, ctx, funcs) if rerr != nil { t.Fatalf("RenderPromptTemplate: %v", rerr) } @@ -1238,15 +1309,15 @@ func TestFollowupWork_ThreeModeTargetResolution(t *testing.T) { // TestInteractionMode_ConditionalRendering verifies that the builtin prompts // which were migrated from verbose "Interaction Mode" prose (that manually -// dumped {{ .Session.IsPeriodic }} / {{ .Session.IsPeriodicForced }}) to Go +// dumped {{ .Session.IsLoop }} / {{ .Session.IsLoopForced }}) to Go // template conditionals render the correct branch for each of the three // possible session states: // -// (1) Scheduled periodic → IsPeriodic=true, IsPeriodicForced=false → Silent -// (2) Force-triggered → IsPeriodic=true, IsPeriodicForced=true → Interactive -// (3) Regular conversation → IsPeriodic=false, IsPeriodicForced=false → Interactive +// (1) Scheduled loop → IsLoop=true, IsLoopForced=false → Silent +// (2) Force-triggered → IsLoop=true, IsLoopForced=true → Interactive +// (3) Regular conversation → IsLoop=false, IsLoopForced=false → Interactive // -// It also asserts that no raw .Session.IsPeriodic* variable text survives in +// It also asserts that no raw .Session.IsLoop* variable text survives in // the rendered output — proving the conditional directives were consumed by the // template engine and that the old verbose variable dumps are gone. // @@ -1267,43 +1338,43 @@ func TestInteractionMode_ConditionalRendering(t *testing.T) { { file: "architectural-analysis.prompt.yaml", name: "architectural-analysis", - silentMarker: "a scheduled periodic run; the user is not watching.", - interactiveMarker: "a regular conversation or a force-triggered periodic run; the user is present.", + silentMarker: "a scheduled loop run; the user is not watching.", + interactiveMarker: "a regular conversation or a force-triggered loop run; the user is present.", }, { file: "jira-sync-tasks.prompt.yaml", name: "jira-sync-tasks", - silentMarker: "a scheduled periodic run; the user is not watching.", - interactiveMarker: "a regular conversation or a force-triggered periodic run; the user is present.", + silentMarker: "a scheduled loop run; the user is not watching.", + interactiveMarker: "a regular conversation or a force-triggered loop run; the user is present.", }, { file: "github-sync-tasks.prompt.yaml", name: "github-sync-tasks", - silentMarker: "a scheduled periodic run; the user is not watching.", - interactiveMarker: "a regular conversation or a force-triggered periodic run; the user is present.", + silentMarker: "a scheduled loop run; the user is not watching.", + interactiveMarker: "a regular conversation or a force-triggered loop run; the user is present.", }, { file: "github-babysit-contributions.prompt.yaml", name: "github-babysit-contributions", - silentMarker: "a scheduled periodic run; the user is not watching.", - interactiveMarker: "a force-triggered run or a non-periodic conversation; the user may be present.", + silentMarker: "a scheduled loop run; the user is not watching.", + interactiveMarker: "a force-triggered run or a non-loop conversation; the user may be present.", }, { file: "github-babysit-my-prs.prompt.yaml", name: "github-babysit-my-prs", - silentMarker: "a scheduled periodic run; the user is not watching.", - interactiveMarker: "a force-triggered run or a non-periodic conversation; the user may be present.", + silentMarker: "a scheduled loop run; the user is not watching.", + interactiveMarker: "a force-triggered run or a non-loop conversation; the user may be present.", }, { file: "beads-issue-iterate-until-complete.prompt.yaml", name: "beads-issue-iterate-until-complete", - silentMarker: "Silent mode — a scheduled periodic run.", + silentMarker: "Silent mode — a scheduled loop run.", interactiveMarker: "(e.g. the very first send, or a force-triggered run): a user may be", }, { file: "github-iterate-babysit-new-prs.prompt.yaml", name: "github-iterate-babysit-new-prs", - silentMarker: "Silent mode — scheduled periodic run.", + silentMarker: "Silent mode — scheduled loop run.", interactiveMarker: "(e.g. the very first send, or a force-triggered run): a user may be", }, } @@ -1315,37 +1386,38 @@ func TestInteractionMode_ConditionalRendering(t *testing.T) { if err != nil { t.Skipf("prompt file not found at %s: %v", path, err) } + prompt, err := ParsePromptFile(tc.file, data, time.Now()) if err != nil { t.Fatalf("ParsePromptFile(%s): %v", tc.file, err) } body := prompt.Content - render := func(periodic, forced bool) string { + render := func(loop, forced bool) string { ctx := &PromptEnabledContext{ Session: SessionContext{ - IsPeriodic: periodic, - IsPeriodicForced: forced, + IsLoop: loop, + IsLoopForced: forced, }, } out, rerr := RenderPromptTemplate(tc.name, body, ctx, BuildTemplateFuncMap(ctx)) if rerr != nil { - t.Fatalf("RenderPromptTemplate(%s) periodic=%v forced=%v: %v", tc.name, periodic, forced, rerr) + t.Fatalf("RenderPromptTemplate(%s) loop=%v forced=%v: %v", tc.name, loop, forced, rerr) } // The conditionals must be consumed; no raw variable dumps may survive. - if strings.Contains(out, ".Session.IsPeriodic") { - t.Errorf("%s periodic=%v forced=%v: raw '.Session.IsPeriodic' leaked into rendered output:\n%s", tc.name, periodic, forced, out) + if strings.Contains(out, ".Session.IsLoop") { + t.Errorf("%s loop=%v forced=%v: raw '.Session.IsLoop' leaked into rendered output:\n%s", tc.name, loop, forced, out) } return out } - // (1) Scheduled periodic → Silent branch. + // (1) Scheduled loop → Silent branch. silent := render(true, false) if !strings.Contains(silent, tc.silentMarker) { - t.Errorf("scheduled periodic: expected silent marker %q in output; got:\n%s", tc.silentMarker, silent) + t.Errorf("scheduled loop: expected silent marker %q in output; got:\n%s", tc.silentMarker, silent) } if strings.Contains(silent, tc.interactiveMarker) { - t.Errorf("scheduled periodic: unexpected interactive marker %q in silent output:\n%s", tc.interactiveMarker, silent) + t.Errorf("scheduled loop: unexpected interactive marker %q in silent output:\n%s", tc.interactiveMarker, silent) } // (2) Force-triggered → Interactive branch. @@ -1377,11 +1449,11 @@ func TestRenderPromptTemplate_Iteration(t *testing.T) { // Number=0, Max=3 → "first run" ctxFirst := &PromptEnabledContext{ Iteration: IterationContext{ - Number: 0, - Max: 3, - IsPeriodic: true, - IsFirst: true, - IsLast: false, + Number: 0, + Max: 3, + IsLoop: true, + IsFirst: true, + IsLast: false, }, } gotFirst, err := RenderPromptTemplate("test-first", body, ctxFirst, nil) @@ -1395,11 +1467,11 @@ func TestRenderPromptTemplate_Iteration(t *testing.T) { // Number=2, Max=3 → "run 2 of 3" ctxLast := &PromptEnabledContext{ Iteration: IterationContext{ - Number: 2, - Max: 3, - IsPeriodic: true, - IsFirst: false, - IsLast: true, + Number: 2, + Max: 3, + IsLoop: true, + IsFirst: false, + IsLast: true, }, } gotLast, err := RenderPromptTemplate("test-last", body, ctxLast, nil) @@ -1419,7 +1491,7 @@ func TestRenderPromptTemplate_Iteration(t *testing.T) { ctxContinue := &PromptEnabledContext{ Iteration: IterationContext{ - IsPeriodic: true, + IsLoop: true, IsUninterrupted: true, }, } @@ -1433,7 +1505,7 @@ func TestRenderPromptTemplate_Iteration(t *testing.T) { ctxVerbose := &PromptEnabledContext{ Iteration: IterationContext{ - IsPeriodic: true, + IsLoop: true, IsUninterrupted: false, }, } @@ -1544,7 +1616,7 @@ func TestIterateFixingBug_RendersForRepresentativeContexts(t *testing.T) { // (b) Arg-only context, uninterrupted silent continuation run, with Commit=true. ctxB := &PromptEnabledContext{ Args: map[string]string{"IssueID": "mitto-xyz", "Commit": "true"}, - Iteration: IterationContext{IsPeriodic: true, IsUninterrupted: true}, + Iteration: IterationContext{IsLoop: true, IsUninterrupted: true}, } outB := render(ctxB) if !strings.Contains(outB, "mitto-xyz") { @@ -1597,13 +1669,13 @@ func TestIterateFixingBug_RendersForRepresentativeContexts(t *testing.T) { // (a) default context — no Args, no Session.BeadsIssue → body renders, // references the per-bug driver name "Iterate fixing bug", declares itself // a list-level orchestrator, calls out the top-level-only rule, and shows -// the spawn+wait+archive tool triplet with the exact periodic budget +// the spawn+wait+archive tool triplet with the exact loop budget // (30 / 20 / 14400) that mirrors the per-bug driver's own block. Commit // defaults to "true" in the child arguments when the Commit arg is absent. // (b) Commit="false" — the child-arguments literal for Commit flips to // "false", confirming the boolean forwarding is wired correctly. // -// The frontmatter assertions (menus: beadsList; NO periodic: block; name is +// The frontmatter assertions (menus: beadsList; NO loop: block; name is // "Iterate fixing bugs") are checked once, alongside the (a) render. // // The test loads the file from the real builtin directory so it always @@ -1622,15 +1694,15 @@ func TestIterateFixingBugs_RendersForRepresentativeContexts(t *testing.T) { } // Frontmatter assertions — this is a list-level orchestrator with no - // Item.* context and no periodic block of its own (single-run internal loop). + // Item.* context and no loop block of its own (single-run internal loop). if prompt.Name != "Iterate fixing bugs" { t.Errorf("Name = %q, want %q", prompt.Name, "Iterate fixing bugs") } if strings.TrimSpace(prompt.Menus) != "beadsList" { t.Errorf("Menus = %q, want %q", prompt.Menus, "beadsList") } - if prompt.Periodic != nil { - t.Errorf("Periodic = %+v, want nil — this orchestrator is a single-run internal loop", prompt.Periodic) + if prompt.Loop != nil { + t.Errorf("Loop = %+v, want nil — this orchestrator is a single-run internal loop", prompt.Loop) } body := prompt.Content @@ -1666,16 +1738,16 @@ func TestIterateFixingBugs_RendersForRepresentativeContexts(t *testing.T) { t.Errorf("branch (a): expected orchestration tool call %q in body; got:\n%s", tool, outA) } } - // Periodic re-fire mechanics that make each child self-drive. + // Loop re-fire mechanics that make each child self-drive. for _, hint := range []string{ "onCompletion", - "periodic_prompt", - "periodic_completion_delay_seconds: 30", - "periodic_max_iterations: 20", - "periodic_max_duration_seconds: 14400", + "loop_prompt", + "loop_completion_delay_seconds: 30", + "loop_max_iterations: 20", + "loop_max_duration_seconds: 14400", } { if !strings.Contains(outA, hint) { - t.Errorf("branch (a): expected periodic-budget hint %q in body; got:\n%s", hint, outA) + t.Errorf("branch (a): expected loop-budget hint %q in body; got:\n%s", hint, outA) } } // Preflight-flag guidance so a user with either flag off gets a graceful stop. @@ -1705,18 +1777,18 @@ func TestIterateFixingBugs_RendersForRepresentativeContexts(t *testing.T) { // TestIterateImplementingFeatures_RendersForRepresentativeContexts is the // list-level orchestrator counterpart for the feature flow (mitto-gap.6): // it parses beads-issue-iterate-implementing-features.prompt.yaml from disk, -// asserts the orchestrator frontmatter shape (menus: beadsList, no periodic +// asserts the orchestrator frontmatter shape (menus: beadsList, no loop // block, name = "Iterate implementing features"), and renders the body across // two representative Args contexts: // // (a) Commit absent — the child arguments literal defaults to "true"; // the body dispatches to the per-feature driver by name and wires up -// the spawn+wait+archive tool triplet with the exact periodic budget +// the spawn+wait+archive tool triplet with the exact loop budget // (30 / 30 / 28800) that mirrors the per-feature driver's own block. // (b) Commit="false" — the child-arguments literal for Commit flips to // "false", confirming the boolean forwarding is wired correctly. // -// The frontmatter assertions (menus: beadsList; NO periodic: block; name is +// The frontmatter assertions (menus: beadsList; NO loop: block; name is // "Iterate implementing features") are checked once, alongside the (a) render. // // The test loads the file from the real builtin directory so it always @@ -1735,15 +1807,15 @@ func TestIterateImplementingFeatures_RendersForRepresentativeContexts(t *testing } // Frontmatter assertions — this is a list-level orchestrator with no - // Item.* context and no periodic block of its own (single-run internal loop). + // Item.* context and no loop block of its own (single-run internal loop). if prompt.Name != "Iterate implementing features" { t.Errorf("Name = %q, want %q", prompt.Name, "Iterate implementing features") } if strings.TrimSpace(prompt.Menus) != "beadsList" { t.Errorf("Menus = %q, want %q", prompt.Menus, "beadsList") } - if prompt.Periodic != nil { - t.Errorf("Periodic = %+v, want nil — this orchestrator is a single-run internal loop", prompt.Periodic) + if prompt.Loop != nil { + t.Errorf("Loop = %+v, want nil — this orchestrator is a single-run internal loop", prompt.Loop) } body := prompt.Content @@ -1779,16 +1851,16 @@ func TestIterateImplementingFeatures_RendersForRepresentativeContexts(t *testing t.Errorf("branch (a): expected orchestration tool call %q in body; got:\n%s", tool, outA) } } - // Periodic re-fire mechanics that make each child self-drive. + // Loop re-fire mechanics that make each child self-drive. for _, hint := range []string{ "onCompletion", - "periodic_prompt", - "periodic_completion_delay_seconds: 30", - "periodic_max_iterations: 30", - "periodic_max_duration_seconds: 28800", + "loop_prompt", + "loop_completion_delay_seconds: 30", + "loop_max_iterations: 30", + "loop_max_duration_seconds: 28800", } { if !strings.Contains(outA, hint) { - t.Errorf("branch (a): expected periodic-budget hint %q in body; got:\n%s", hint, outA) + t.Errorf("branch (a): expected loop-budget hint %q in body; got:\n%s", hint, outA) } } // Preflight-flag guidance so a user with either flag off gets a graceful stop. @@ -2072,7 +2144,7 @@ func TestIterateImplementingFeature_RendersForRepresentativeContexts(t *testing. // (b) Arg-only context, uninterrupted silent continuation run, with Commit=true. ctxB := &PromptEnabledContext{ Args: map[string]string{"IssueID": "mitto-xyz", "Commit": "true"}, - Iteration: IterationContext{IsPeriodic: true, IsUninterrupted: true}, + Iteration: IterationContext{IsLoop: true, IsUninterrupted: true}, } outB := render(ctxB) if !strings.Contains(outB, "mitto-xyz") { @@ -2275,19 +2347,19 @@ func TestFeaturePhasePrompts_RenderForRepresentativeContexts(t *testing.T) { } } -// TestBuiltinPromptPeriodicModes verifies the mitto-92x.6 mechanical flagging +// TestBuiltinPromptLoopModes verifies the mitto-92x.6 mechanical flagging // pass: every builtin prompt assigned a mode/default in the epic's -// classification table parses with the expected PromptPeriodic.Mode/Default, -// and a representative sample of the "never periodic" set has no periodic +// classification table parses with the expected PromptLoop.Mode/Default, +// and a representative sample of the "never loop" set has no loop // block at all. -func TestBuiltinPromptPeriodicModes(t *testing.T) { +func TestBuiltinPromptLoopModes(t *testing.T) { builtinDir := "../../config/prompts/builtin" boolPtr := func(b bool) *bool { return &b } type want struct { mode string - def *bool // nil means PromptPeriodic.Default must be nil + def *bool // nil means PromptLoop.Default must be nil } cases := map[string]want{ @@ -2329,27 +2401,27 @@ func TestBuiltinPromptPeriodicModes(t *testing.T) { if err != nil { t.Fatalf("ParsePromptFile(%s): %v", file, err) } - if prompt.Periodic == nil { - t.Fatalf("%s: Periodic = nil, want non-nil", file) + if prompt.Loop == nil { + t.Fatalf("%s: Loop = nil, want non-nil", file) } - if prompt.Periodic.Mode != w.mode { - t.Errorf("%s: Periodic.Mode = %q, want %q", file, prompt.Periodic.Mode, w.mode) + if prompt.Loop.Mode != w.mode { + t.Errorf("%s: Loop.Mode = %q, want %q", file, prompt.Loop.Mode, w.mode) } if w.def == nil { - if prompt.Periodic.Default != nil { - t.Errorf("%s: Periodic.Default = %v, want nil", file, *prompt.Periodic.Default) + if prompt.Loop.Default != nil { + t.Errorf("%s: Loop.Default = %v, want nil", file, *prompt.Loop.Default) } } else { - if prompt.Periodic.Default == nil { - t.Errorf("%s: Periodic.Default = nil, want %v", file, *w.def) - } else if *prompt.Periodic.Default != *w.def { - t.Errorf("%s: Periodic.Default = %v, want %v", file, *prompt.Periodic.Default, *w.def) + if prompt.Loop.Default == nil { + t.Errorf("%s: Loop.Default = nil, want %v", file, *w.def) + } else if *prompt.Loop.Default != *w.def { + t.Errorf("%s: Loop.Default = %v, want %v", file, *prompt.Loop.Default, *w.def) } } }) } - // Representative sample of the "never periodic" set: no periodic block at all. + // Representative sample of the "never loop" set: no loop block at all. neverFiles := []string{ "explain.prompt.yaml", "refactor.prompt.yaml", @@ -2360,7 +2432,7 @@ func TestBuiltinPromptPeriodicModes(t *testing.T) { "continue.prompt.yaml", "beads-issue-decompose.prompt.yaml", // Tasks prompts that are one-shot reports, context-bound, or - // confirmation-gated — periodic re-firing makes no sense for them. + // confirmation-gated — loop re-firing makes no sense for them. "beads-followup-work.prompt.yaml", "beads-cleanup-stale.prompt.yaml", "beads-group-epics.prompt.yaml", @@ -2383,8 +2455,8 @@ func TestBuiltinPromptPeriodicModes(t *testing.T) { if err != nil { t.Fatalf("ParsePromptFile(%s): %v", file, err) } - if prompt.Periodic != nil { - t.Errorf("%s: Periodic = %+v, want nil (never-periodic set)", file, prompt.Periodic) + if prompt.Loop != nil { + t.Errorf("%s: Loop = %+v, want nil (never-loop set)", file, prompt.Loop) } }) } diff --git a/internal/config/prompts.go b/internal/config/prompts.go index 8e257583..25ede216 100644 --- a/internal/config/prompts.go +++ b/internal/config/prompts.go @@ -15,47 +15,56 @@ import ( "gopkg.in/yaml.v3" ) -// PromptPeriodic declares that selecting this prompt should start a periodic +// PromptLoop declares that selecting this prompt should start a loop // (recurring) conversation instead of a one-time one. A prompt falls into one // of three categories: -// - No `periodic:` block at all → never periodic (unchanged one-time send). -// - `mode: always` (or `mode` absent) → always periodic; not user-toggleable. +// - No `loop:` block at all → never loop (unchanged one-time send). +// - `mode: always` (or `mode` absent) → always loop; not user-toggleable. // - `mode: optional` → user-choosable; `default` sets the initial per-send state. // -// Example frontmatter (always periodic, schedule-based): +// Example frontmatter (always loop, schedule-based): // -// periodic: +// loop: // value: 1 // unit: hours # minutes | hours | days // at: "09:00" # optional, only for days (UTC) // maxIterations: 10 # optional; 0/absent = unlimited scheduled runs // -// Example frontmatter (always periodic, on-completion trigger): +// Example frontmatter (always loop, on-completion trigger): // -// periodic: +// loop: // trigger: onCompletion # fire after the agent stops responding // delay: 30 # seconds to wait after agent stops (clamped to floor at consumption) // maxIterations: 20 # optional safety cap // maxDuration: "4h" # optional wall-clock cap; 0/absent = unlimited // -// Example frontmatter (optionally periodic, off by default): +// Example frontmatter (always loop, on-tasks trigger with CEL condition): // -// periodic: +// loop: +// trigger: onTasks +// condition: 'tasks.exists(t, t.status == "open" && "backend" in t.labels)' +// maxIterations: 20 +// maxDuration: "4h" +// +// Example frontmatter (optionally loop, off by default): +// +// loop: // mode: optional // default: false # initial per-send toggle state; nil/absent => true (on) // trigger: onCompletion // delay: 30 -type PromptPeriodic struct { +type PromptLoop struct { // Value is the number of time units between runs (min 1). Used for trigger: schedule (default). Value int `yaml:"value" json:"value"` // Unit is the time unit: "minutes", "hours", or "days". Used for trigger: schedule (default). Unit string `yaml:"unit" json:"unit"` // At is the time of day in HH:MM format (UTC). Only meaningful for the "days" unit. At string `yaml:"at,omitempty" json:"at,omitempty"` - // MaxIterations caps the number of scheduled runs when the conversation is made periodic (0 / absent = unlimited). + // MaxIterations caps the number of scheduled runs when the conversation is made loop (0 / absent = unlimited). MaxIterations int `yaml:"maxIterations,omitempty" json:"maxIterations,omitempty"` - // Trigger selects how the periodic run fires: "" or "schedule" (default, frequency-based) - // vs "onCompletion" (fire after the agent stops responding + Delay seconds). + // Trigger selects how the loop run fires: "" or "schedule" (default, frequency-based), + // "onCompletion" (fire after the agent stops responding + Delay seconds), or + // "onTasks" (fire when beads/tasks in the workspace change, optionally gated by Condition). Trigger string `yaml:"trigger,omitempty" json:"trigger,omitempty"` // Delay is the number of seconds to wait after the agent stops responding before the // next run. Only meaningful for trigger: onCompletion. Clamped to a global minimum @@ -64,40 +73,44 @@ type PromptPeriodic struct { // MaxDuration is an optional wall-clock cap (e.g. "2h", "30m"); 0/absent = unlimited. // Parsed to seconds at the consumption boundary. MaxDuration string `yaml:"maxDuration,omitempty" json:"maxDuration,omitempty"` - // Mode selects whether periodic is mandatory or user-toggleable: "always" - // (default when empty/absent) or "optional". Validated by ValidatePromptPeriodic. + // Condition is an optional CEL expression gating which beads/task changes fire + // the run; empty = fire on any change. Only meaningful for trigger: onTasks. + // Validated at parse time in ParsePromptFile. + Condition string `yaml:"condition,omitempty" json:"condition,omitempty"` + // Mode selects whether loop is mandatory or user-toggleable: "always" + // (default when empty/absent) or "optional". Validated by ValidatePromptLoop. Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // Default is the initial per-send toggle state when Mode is "optional". // nil/absent => true (on). Ignored (with a lint warning) when Mode is "always". Default *bool `yaml:"default,omitempty" json:"default,omitempty"` } -// PromptPeriodicModeAlways means the prompt is always periodic; not user-toggleable. -// Also the implied mode when PromptPeriodic.Mode is empty. -const PromptPeriodicModeAlways = "always" +// PromptLoopModeAlways means the prompt is always loop; not user-toggleable. +// Also the implied mode when PromptLoop.Mode is empty. +const PromptLoopModeAlways = "always" -// PromptPeriodicModeOptional means periodic is user-choosable for this prompt; -// PromptPeriodic.Default sets the initial per-send toggle state. -const PromptPeriodicModeOptional = "optional" +// PromptLoopModeOptional means loop is user-choosable for this prompt; +// PromptLoop.Default sets the initial per-send toggle state. +const PromptLoopModeOptional = "optional" -// knownPromptPeriodicModes enumerates valid PromptPeriodic.Mode values (besides ""). -var knownPromptPeriodicModes = map[string]bool{ - PromptPeriodicModeAlways: true, - PromptPeriodicModeOptional: true, +// knownPromptLoopModes enumerates valid PromptLoop.Mode values (besides ""). +var knownPromptLoopModes = map[string]bool{ + PromptLoopModeAlways: true, + PromptLoopModeOptional: true, } -// ValidatePromptPeriodic validates the periodic block's mode/default combination. +// ValidatePromptLoop validates the loop block's mode/default combination. // Returns an error for unknown mode values. Emits a non-fatal warning when default // is set together with mode: always (or mode absent), since the value is ignored. -func ValidatePromptPeriodic(promptName string, p *PromptPeriodic) error { +func ValidatePromptLoop(promptName string, p *PromptLoop) error { if p == nil { return nil } - if p.Mode != "" && !knownPromptPeriodicModes[p.Mode] { - return fmt.Errorf("prompt %q: periodic.mode %q is not valid (must be one of: always, optional)", promptName, p.Mode) + if p.Mode != "" && !knownPromptLoopModes[p.Mode] { + return fmt.Errorf("prompt %q: loop.mode %q is not valid (must be one of: always, optional)", promptName, p.Mode) } - if p.Default != nil && p.Mode != PromptPeriodicModeOptional { - slog.Warn("prompt periodic.default is ignored unless periodic.mode is \"optional\"", + if p.Default != nil && p.Mode != PromptLoopModeOptional { + slog.Warn("prompt loop.default is ignored unless loop.mode is \"optional\"", "prompt", promptName, "mode", p.Mode) } return nil @@ -206,11 +219,11 @@ type PromptFile struct { // Example: "!session.isChild" hides the prompt in child conversations. EnabledWhen string `yaml:"enabledWhen,omitempty" json:"-"` - // Periodic, if set, declares that selecting this prompt in a menu creates a - // periodic (recurring) conversation instead of a one-time seed. + // Loop, if set, declares that selecting this prompt in a menu creates a + // loop (recurring) conversation instead of a one-time seed. // Presence implies opt-in; the fields provide default values for the schedule // dialog. The "at" field is in HH:MM UTC and is only valid for the "days" unit. - Periodic *PromptPeriodic `yaml:"periodic,omitempty" json:"periodic,omitempty"` + Loop *PromptLoop `yaml:"loop,omitempty" json:"loop,omitempty"` // PreferredModels is an ordered list of references to global model profiles // (Settings → Models), by profile name or capability tag. The first entry that @@ -274,7 +287,7 @@ func (p *PromptFile) ToWebPrompt() WebPrompt { Source: PromptSourceFile, EnabledWhen: p.EnabledWhen, Enabled: p.Enabled, - Periodic: p.Periodic, + Loop: p.Loop, PreferredModels: p.PreferredModels, Parameters: p.Parameters, Tags: p.Tags, @@ -322,11 +335,20 @@ func ParsePromptFile(path string, data []byte, modTime time.Time) (*PromptFile, return nil, fmt.Errorf("prompt file %s: %w", path, err) } - // Validate periodic block (mode/default combination). - if err := ValidatePromptPeriodic(prompt.Name, prompt.Periodic); err != nil { + // Validate loop block (mode/default combination). + if err := ValidatePromptLoop(prompt.Name, prompt.Loop); err != nil { return nil, fmt.Errorf("prompt file %s: %w", path, err) } + // Validate loop.condition CEL expression when non-empty (fail-fast, mirrors + // how the runtime seam is wired via session.ConditionValidator). Applies to + // any loop block that declares a Condition; only meaningful for trigger: onTasks. + if prompt.Loop != nil && prompt.Loop.Condition != "" { + if err := ValidateCondition(prompt.Loop.Condition); err != nil { + return nil, fmt.Errorf("prompt file %s: loop.condition: %w", path, err) + } + } + // Validate Go-template syntax + cond/when CEL literals (mitto-m7sb.6). // Fast-path no-op for bodies without "{{". Fail-fast on invalid templates. if err := PrecompileTemplateConds(prompt.Name, prompt.Content); err != nil { diff --git a/internal/config/prompts_test.go b/internal/config/prompts_test.go index e40584ee..b9c75a15 100644 --- a/internal/config/prompts_test.go +++ b/internal/config/prompts_test.go @@ -171,10 +171,10 @@ func TestToWebPrompt(t *testing.T) { } } -func TestParsePromptFile_WithPeriodic(t *testing.T) { +func TestParsePromptFile_WithLoop(t *testing.T) { data := []byte(`name: "Daily Standup" description: "Run daily standup" -periodic: +loop: value: 1 unit: days at: "09:00" @@ -190,38 +190,38 @@ prompt: | if prompt.Name != "Daily Standup" { t.Errorf("Name = %q, want %q", prompt.Name, "Daily Standup") } - if prompt.Periodic == nil { - t.Fatal("Periodic = nil, want non-nil") + if prompt.Loop == nil { + t.Fatal("Loop = nil, want non-nil") } - if prompt.Periodic.Value != 1 { - t.Errorf("Periodic.Value = %d, want 1", prompt.Periodic.Value) + if prompt.Loop.Value != 1 { + t.Errorf("Loop.Value = %d, want 1", prompt.Loop.Value) } - if prompt.Periodic.Unit != "days" { - t.Errorf("Periodic.Unit = %q, want %q", prompt.Periodic.Unit, "days") + if prompt.Loop.Unit != "days" { + t.Errorf("Loop.Unit = %q, want %q", prompt.Loop.Unit, "days") } - if prompt.Periodic.At != "09:00" { - t.Errorf("Periodic.At = %q, want %q", prompt.Periodic.At, "09:00") + if prompt.Loop.At != "09:00" { + t.Errorf("Loop.At = %q, want %q", prompt.Loop.At, "09:00") } - // Verify ToWebPrompt carries the Periodic field. + // Verify ToWebPrompt carries the Loop field. wp := prompt.ToWebPrompt() - if wp.Periodic == nil { - t.Fatal("WebPrompt.Periodic = nil, want non-nil after ToWebPrompt()") + if wp.Loop == nil { + t.Fatal("WebPrompt.Loop = nil, want non-nil after ToWebPrompt()") } - if wp.Periodic.Value != 1 { - t.Errorf("WebPrompt.Periodic.Value = %d, want 1", wp.Periodic.Value) + if wp.Loop.Value != 1 { + t.Errorf("WebPrompt.Loop.Value = %d, want 1", wp.Loop.Value) } - if wp.Periodic.Unit != "days" { - t.Errorf("WebPrompt.Periodic.Unit = %q, want %q", wp.Periodic.Unit, "days") + if wp.Loop.Unit != "days" { + t.Errorf("WebPrompt.Loop.Unit = %q, want %q", wp.Loop.Unit, "days") } - if wp.Periodic.At != "09:00" { - t.Errorf("WebPrompt.Periodic.At = %q, want %q", wp.Periodic.At, "09:00") + if wp.Loop.At != "09:00" { + t.Errorf("WebPrompt.Loop.At = %q, want %q", wp.Loop.At, "09:00") } } -func TestParsePromptFile_WithPeriodic_NoAt(t *testing.T) { +func TestParsePromptFile_WithLoop_NoAt(t *testing.T) { data := []byte(`name: "Hourly Check" -periodic: +loop: value: 2 unit: hours prompt: | @@ -233,23 +233,23 @@ prompt: | t.Fatalf("ParsePromptFile failed: %v", err) } - if prompt.Periodic == nil { - t.Fatal("Periodic = nil, want non-nil") + if prompt.Loop == nil { + t.Fatal("Loop = nil, want non-nil") } - if prompt.Periodic.Value != 2 { - t.Errorf("Periodic.Value = %d, want 2", prompt.Periodic.Value) + if prompt.Loop.Value != 2 { + t.Errorf("Loop.Value = %d, want 2", prompt.Loop.Value) } - if prompt.Periodic.Unit != "hours" { - t.Errorf("Periodic.Unit = %q, want %q", prompt.Periodic.Unit, "hours") + if prompt.Loop.Unit != "hours" { + t.Errorf("Loop.Unit = %q, want %q", prompt.Loop.Unit, "hours") } - if prompt.Periodic.At != "" { - t.Errorf("Periodic.At = %q, want empty (no at for hours)", prompt.Periodic.At) + if prompt.Loop.At != "" { + t.Errorf("Loop.At = %q, want empty (no at for hours)", prompt.Loop.At) } } -func TestParsePromptFile_WithPeriodic_MaxIterations(t *testing.T) { +func TestParsePromptFile_WithLoop_MaxIterations(t *testing.T) { data := []byte(`name: "Capped" -periodic: +loop: value: 1 unit: hours maxIterations: 5 @@ -262,24 +262,24 @@ prompt: | t.Fatalf("ParsePromptFile failed: %v", err) } - if prompt.Periodic == nil { - t.Fatal("Periodic = nil, want non-nil") + if prompt.Loop == nil { + t.Fatal("Loop = nil, want non-nil") } - if prompt.Periodic.MaxIterations != 5 { - t.Errorf("Periodic.MaxIterations = %d, want 5", prompt.Periodic.MaxIterations) + if prompt.Loop.MaxIterations != 5 { + t.Errorf("Loop.MaxIterations = %d, want 5", prompt.Loop.MaxIterations) } // Verify ToWebPrompt carries the MaxIterations field. wp := prompt.ToWebPrompt() - if wp.Periodic == nil { - t.Fatal("WebPrompt.Periodic = nil, want non-nil after ToWebPrompt()") + if wp.Loop == nil { + t.Fatal("WebPrompt.Loop = nil, want non-nil after ToWebPrompt()") } - if wp.Periodic.MaxIterations != 5 { - t.Errorf("WebPrompt.Periodic.MaxIterations = %d, want 5", wp.Periodic.MaxIterations) + if wp.Loop.MaxIterations != 5 { + t.Errorf("WebPrompt.Loop.MaxIterations = %d, want 5", wp.Loop.MaxIterations) } } -func TestParsePromptFile_NoPeriodic(t *testing.T) { +func TestParsePromptFile_NoLoop(t *testing.T) { data := []byte(`name: "One-time Prompt" prompt: | Just a regular prompt. @@ -289,13 +289,75 @@ prompt: | if err != nil { t.Fatalf("ParsePromptFile failed: %v", err) } - if prompt.Periodic != nil { - t.Errorf("Periodic = %+v, want nil for prompt without periodic field", prompt.Periodic) + if prompt.Loop != nil { + t.Errorf("Loop = %+v, want nil for prompt without loop field", prompt.Loop) } wp := prompt.ToWebPrompt() - if wp.Periodic != nil { - t.Errorf("WebPrompt.Periodic = %+v, want nil", wp.Periodic) + if wp.Loop != nil { + t.Errorf("WebPrompt.Loop = %+v, want nil", wp.Loop) + } +} + +func TestParsePromptFile_WithLoop_OnTasksCondition(t *testing.T) { + data := []byte(`name: "On Tasks" +loop: + trigger: onTasks + condition: 'Tasks.Open > Prev.Open' + maxIterations: 20 + maxDuration: "4h" +prompt: | + Fire when open task count grows. +`) + + prompt, err := ParsePromptFile("on-tasks.prompt.yaml", data, time.Now()) + if err != nil { + t.Fatalf("ParsePromptFile failed: %v", err) + } + if prompt.Loop == nil { + t.Fatal("Loop = nil, want non-nil") + } + if prompt.Loop.Trigger != "onTasks" { + t.Errorf("Loop.Trigger = %q, want %q", prompt.Loop.Trigger, "onTasks") + } + if prompt.Loop.Condition != `Tasks.Open > Prev.Open` { + t.Errorf("Loop.Condition = %q, want %q", prompt.Loop.Condition, `Tasks.Open > Prev.Open`) + } + if prompt.Loop.MaxIterations != 20 { + t.Errorf("Loop.MaxIterations = %d, want 20", prompt.Loop.MaxIterations) + } + if prompt.Loop.MaxDuration != "4h" { + t.Errorf("Loop.MaxDuration = %q, want %q", prompt.Loop.MaxDuration, "4h") + } + + // Verify ToWebPrompt carries the Condition and Trigger fields through. + wp := prompt.ToWebPrompt() + if wp.Loop == nil { + t.Fatal("WebPrompt.Loop = nil, want non-nil after ToWebPrompt()") + } + if wp.Loop.Trigger != "onTasks" { + t.Errorf("WebPrompt.Loop.Trigger = %q, want %q", wp.Loop.Trigger, "onTasks") + } + if wp.Loop.Condition != `Tasks.Open > Prev.Open` { + t.Errorf("WebPrompt.Loop.Condition = %q, want %q", wp.Loop.Condition, `Tasks.Open > Prev.Open`) + } +} + +func TestParsePromptFile_WithLoop_InvalidCondition(t *testing.T) { + data := []byte(`name: "Bad Condition" +loop: + trigger: onTasks + condition: 'Tasks.Open > ' +prompt: | + Broken CEL. +`) + + _, err := ParsePromptFile("bad-condition.prompt.yaml", data, time.Now()) + if err == nil { + t.Fatal("ParsePromptFile succeeded, want error for invalid CEL condition") + } + if !strings.Contains(err.Error(), "loop.condition") { + t.Errorf("error = %q, want it to mention loop.condition", err.Error()) } } @@ -341,34 +403,34 @@ prompt: | } } -func TestMergePrompts_PreservesPeriodicField(t *testing.T) { - periodic := &PromptPeriodic{Value: 3, Unit: "hours"} +func TestMergePrompts_PreservesLoopField(t *testing.T) { + loop := &PromptLoop{Value: 3, Unit: "hours"} globalPrompts := []WebPrompt{ - {Name: "Periodic Prompt", Prompt: "do it", Periodic: periodic, Source: PromptSourceFile}, + {Name: "Loop Prompt", Prompt: "do it", Loop: loop, Source: PromptSourceFile}, {Name: "Regular Prompt", Prompt: "also do it", Source: PromptSourceFile}, } - // MergePrompts should carry the Periodic field through. + // MergePrompts should carry the Loop field through. merged := MergePrompts(globalPrompts, nil, nil) var found *WebPrompt for i := range merged { - if merged[i].Name == "Periodic Prompt" { + if merged[i].Name == "Loop Prompt" { found = &merged[i] break } } if found == nil { - t.Fatal("Periodic Prompt not found in merged result") + t.Fatal("Loop Prompt not found in merged result") } - if found.Periodic == nil { - t.Fatal("merged Periodic Prompt has nil Periodic field, want non-nil") + if found.Loop == nil { + t.Fatal("merged Loop Prompt has nil Loop field, want non-nil") } - if found.Periodic.Value != 3 { - t.Errorf("merged Periodic.Value = %d, want 3", found.Periodic.Value) + if found.Loop.Value != 3 { + t.Errorf("merged Loop.Value = %d, want 3", found.Loop.Value) } - if found.Periodic.Unit != "hours" { - t.Errorf("merged Periodic.Unit = %q, want hours", found.Periodic.Unit) + if found.Loop.Unit != "hours" { + t.Errorf("merged Loop.Unit = %q, want hours", found.Loop.Unit) } } @@ -781,9 +843,9 @@ func TestFilterPromptsSpecificToACP(t *testing.T) { } } -func TestParsePromptFile_WithPeriodic_OnCompletion(t *testing.T) { +func TestParsePromptFile_WithLoop_OnCompletion(t *testing.T) { data := []byte(`name: "On Completion Prompt" -periodic: +loop: trigger: onCompletion delay: 10 maxDuration: "2h" @@ -796,30 +858,30 @@ prompt: | t.Fatalf("ParsePromptFile failed: %v", err) } - if prompt.Periodic == nil { - t.Fatal("Periodic = nil, want non-nil") + if prompt.Loop == nil { + t.Fatal("Loop = nil, want non-nil") } - if prompt.Periodic.Trigger != "onCompletion" { - t.Errorf("Periodic.Trigger = %q, want %q", prompt.Periodic.Trigger, "onCompletion") + if prompt.Loop.Trigger != "onCompletion" { + t.Errorf("Loop.Trigger = %q, want %q", prompt.Loop.Trigger, "onCompletion") } - if prompt.Periodic.Delay != 10 { - t.Errorf("Periodic.Delay = %d, want 10", prompt.Periodic.Delay) + if prompt.Loop.Delay != 10 { + t.Errorf("Loop.Delay = %d, want 10", prompt.Loop.Delay) } - if prompt.Periodic.MaxDuration != "2h" { - t.Errorf("Periodic.MaxDuration = %q, want %q", prompt.Periodic.MaxDuration, "2h") + if prompt.Loop.MaxDuration != "2h" { + t.Errorf("Loop.MaxDuration = %q, want %q", prompt.Loop.MaxDuration, "2h") } // value/unit absent → zero values - if prompt.Periodic.Value != 0 { - t.Errorf("Periodic.Value = %d, want 0 (not set)", prompt.Periodic.Value) + if prompt.Loop.Value != 0 { + t.Errorf("Loop.Value = %d, want 0 (not set)", prompt.Loop.Value) } - if prompt.Periodic.Unit != "" { - t.Errorf("Periodic.Unit = %q, want empty (not set)", prompt.Periodic.Unit) + if prompt.Loop.Unit != "" { + t.Errorf("Loop.Unit = %q, want empty (not set)", prompt.Loop.Unit) } } -func TestParsePromptFile_WithPeriodic_ScheduleNoTrigger(t *testing.T) { +func TestParsePromptFile_WithLoop_ScheduleNoTrigger(t *testing.T) { data := []byte(`name: "Schedule Prompt" -periodic: +loop: value: 2 unit: hours maxIterations: 5 @@ -832,27 +894,27 @@ prompt: | t.Fatalf("ParsePromptFile failed: %v", err) } - if prompt.Periodic == nil { - t.Fatal("Periodic = nil, want non-nil") + if prompt.Loop == nil { + t.Fatal("Loop = nil, want non-nil") } // Trigger absent → empty string (schedule default) - if prompt.Periodic.Trigger != "" { - t.Errorf("Periodic.Trigger = %q, want empty (schedule default)", prompt.Periodic.Trigger) + if prompt.Loop.Trigger != "" { + t.Errorf("Loop.Trigger = %q, want empty (schedule default)", prompt.Loop.Trigger) } - if prompt.Periodic.Value != 2 { - t.Errorf("Periodic.Value = %d, want 2", prompt.Periodic.Value) + if prompt.Loop.Value != 2 { + t.Errorf("Loop.Value = %d, want 2", prompt.Loop.Value) } - if prompt.Periodic.Unit != "hours" { - t.Errorf("Periodic.Unit = %q, want %q", prompt.Periodic.Unit, "hours") + if prompt.Loop.Unit != "hours" { + t.Errorf("Loop.Unit = %q, want %q", prompt.Loop.Unit, "hours") } - if prompt.Periodic.MaxIterations != 5 { - t.Errorf("Periodic.MaxIterations = %d, want 5", prompt.Periodic.MaxIterations) + if prompt.Loop.MaxIterations != 5 { + t.Errorf("Loop.MaxIterations = %d, want 5", prompt.Loop.MaxIterations) } - if prompt.Periodic.Delay != 0 { - t.Errorf("Periodic.Delay = %d, want 0 (not set)", prompt.Periodic.Delay) + if prompt.Loop.Delay != 0 { + t.Errorf("Loop.Delay = %d, want 0 (not set)", prompt.Loop.Delay) } - if prompt.Periodic.MaxDuration != "" { - t.Errorf("Periodic.MaxDuration = %q, want empty (not set)", prompt.Periodic.MaxDuration) + if prompt.Loop.MaxDuration != "" { + t.Errorf("Loop.MaxDuration = %q, want empty (not set)", prompt.Loop.MaxDuration) } } @@ -860,7 +922,7 @@ func TestToWebPrompt_OnCompletion_JSONRoundTrip(t *testing.T) { pf := &PromptFile{ Name: "On Completion", Content: "body", - Periodic: &PromptPeriodic{ + Loop: &PromptLoop{ Trigger: "onCompletion", Delay: 10, MaxDuration: "2h", @@ -868,8 +930,8 @@ func TestToWebPrompt_OnCompletion_JSONRoundTrip(t *testing.T) { } wp := pf.ToWebPrompt() - if wp.Periodic == nil { - t.Fatal("WebPrompt.Periodic = nil, want non-nil") + if wp.Loop == nil { + t.Fatal("WebPrompt.Loop = nil, want non-nil") } raw, err := json.Marshal(wp) @@ -889,20 +951,20 @@ func TestToWebPrompt_OnCompletion_JSONRoundTrip(t *testing.T) { } // Also verify via struct fields. - if wp.Periodic.Trigger != "onCompletion" { - t.Errorf("WebPrompt.Periodic.Trigger = %q, want %q", wp.Periodic.Trigger, "onCompletion") + if wp.Loop.Trigger != "onCompletion" { + t.Errorf("WebPrompt.Loop.Trigger = %q, want %q", wp.Loop.Trigger, "onCompletion") } - if wp.Periodic.Delay != 10 { - t.Errorf("WebPrompt.Periodic.Delay = %d, want 10", wp.Periodic.Delay) + if wp.Loop.Delay != 10 { + t.Errorf("WebPrompt.Loop.Delay = %d, want 10", wp.Loop.Delay) } - if wp.Periodic.MaxDuration != "2h" { - t.Errorf("WebPrompt.Periodic.MaxDuration = %q, want %q", wp.Periodic.MaxDuration, "2h") + if wp.Loop.MaxDuration != "2h" { + t.Errorf("WebPrompt.Loop.MaxDuration = %q, want %q", wp.Loop.MaxDuration, "2h") } } -func TestParsePromptFile_WithPeriodic_OptionalDefaultFalse(t *testing.T) { - data := []byte(`name: "Optional Periodic" -periodic: +func TestParsePromptFile_WithLoop_OptionalDefaultFalse(t *testing.T) { + data := []byte(`name: "Optional Loop" +loop: mode: optional default: false trigger: onCompletion @@ -914,26 +976,26 @@ prompt: | if err != nil { t.Fatalf("ParsePromptFile failed: %v", err) } - if prompt.Periodic == nil { - t.Fatal("Periodic = nil, want non-nil") + if prompt.Loop == nil { + t.Fatal("Loop = nil, want non-nil") } - if prompt.Periodic.Mode != "optional" { - t.Errorf("Periodic.Mode = %q, want %q", prompt.Periodic.Mode, "optional") + if prompt.Loop.Mode != "optional" { + t.Errorf("Loop.Mode = %q, want %q", prompt.Loop.Mode, "optional") } - if prompt.Periodic.Default == nil || *prompt.Periodic.Default != false { - t.Errorf("Periodic.Default = %v, want *false", prompt.Periodic.Default) + if prompt.Loop.Default == nil || *prompt.Loop.Default != false { + t.Errorf("Loop.Default = %v, want *false", prompt.Loop.Default) } // Round-trips through ToWebPrompt (whole-pointer copy). wp := prompt.ToWebPrompt() - if wp.Periodic == nil { - t.Fatal("WebPrompt.Periodic = nil, want non-nil") + if wp.Loop == nil { + t.Fatal("WebPrompt.Loop = nil, want non-nil") } - if wp.Periodic.Mode != "optional" { - t.Errorf("WebPrompt.Periodic.Mode = %q, want %q", wp.Periodic.Mode, "optional") + if wp.Loop.Mode != "optional" { + t.Errorf("WebPrompt.Loop.Mode = %q, want %q", wp.Loop.Mode, "optional") } - if wp.Periodic.Default == nil || *wp.Periodic.Default != false { - t.Errorf("WebPrompt.Periodic.Default = %v, want *false", wp.Periodic.Default) + if wp.Loop.Default == nil || *wp.Loop.Default != false { + t.Errorf("WebPrompt.Loop.Default = %v, want *false", wp.Loop.Default) } raw, err := json.Marshal(wp) @@ -949,9 +1011,9 @@ prompt: | } } -func TestParsePromptFile_WithPeriodic_NoMode(t *testing.T) { - data := []byte(`name: "Always Periodic" -periodic: +func TestParsePromptFile_WithLoop_NoMode(t *testing.T) { + data := []byte(`name: "Always Loop" +loop: value: 1 unit: hours prompt: | @@ -962,20 +1024,20 @@ prompt: | if err != nil { t.Fatalf("ParsePromptFile failed: %v", err) } - if prompt.Periodic == nil { - t.Fatal("Periodic = nil, want non-nil") + if prompt.Loop == nil { + t.Fatal("Loop = nil, want non-nil") } - if prompt.Periodic.Mode != "" { - t.Errorf("Periodic.Mode = %q, want empty (absent => treated as always)", prompt.Periodic.Mode) + if prompt.Loop.Mode != "" { + t.Errorf("Loop.Mode = %q, want empty (absent => treated as always)", prompt.Loop.Mode) } - if prompt.Periodic.Default != nil { - t.Errorf("Periodic.Default = %v, want nil (absent)", prompt.Periodic.Default) + if prompt.Loop.Default != nil { + t.Errorf("Loop.Default = %v, want nil (absent)", prompt.Loop.Default) } } -func TestParsePromptFile_PeriodicUnknownMode(t *testing.T) { +func TestParsePromptFile_LoopUnknownMode(t *testing.T) { data := []byte(`name: "Bad Mode" -periodic: +loop: mode: sometimes prompt: | body @@ -983,43 +1045,43 @@ prompt: | _, err := ParsePromptFile("bad-mode.prompt.yaml", data, time.Now()) if err == nil { - t.Fatal("ParsePromptFile should fail for unknown periodic.mode, got nil error") + t.Fatal("ParsePromptFile should fail for unknown loop.mode, got nil error") } - if !strings.Contains(err.Error(), "periodic.mode") { - t.Errorf("error = %q, want it to mention 'periodic.mode'", err.Error()) + if !strings.Contains(err.Error(), "loop.mode") { + t.Errorf("error = %q, want it to mention 'loop.mode'", err.Error()) } if !strings.Contains(err.Error(), "sometimes") { t.Errorf("error = %q, want it to mention the invalid value 'sometimes'", err.Error()) } } -func TestValidatePromptPeriodic(t *testing.T) { - t.Run("nil periodic is OK", func(t *testing.T) { - if err := ValidatePromptPeriodic("p", nil); err != nil { +func TestValidatePromptLoop(t *testing.T) { + t.Run("nil loop is OK", func(t *testing.T) { + if err := ValidatePromptLoop("p", nil); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("empty mode is OK (treated as always)", func(t *testing.T) { - if err := ValidatePromptPeriodic("p", &PromptPeriodic{}); err != nil { + if err := ValidatePromptLoop("p", &PromptLoop{}); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("mode=always is OK", func(t *testing.T) { - if err := ValidatePromptPeriodic("p", &PromptPeriodic{Mode: "always"}); err != nil { + if err := ValidatePromptLoop("p", &PromptLoop{Mode: "always"}); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("mode=optional is OK", func(t *testing.T) { - if err := ValidatePromptPeriodic("p", &PromptPeriodic{Mode: "optional"}); err != nil { + if err := ValidatePromptLoop("p", &PromptLoop{Mode: "optional"}); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("unknown mode returns error mentioning prompt name and value", func(t *testing.T) { - err := ValidatePromptPeriodic("My Prompt", &PromptPeriodic{Mode: "bogus"}) + err := ValidatePromptLoop("My Prompt", &PromptLoop{Mode: "bogus"}) if err == nil { t.Fatal("expected error, got nil") } @@ -1033,21 +1095,21 @@ func TestValidatePromptPeriodic(t *testing.T) { t.Run("default set with mode=always does not error (warning only)", func(t *testing.T) { f := false - if err := ValidatePromptPeriodic("p", &PromptPeriodic{Mode: "always", Default: &f}); err != nil { + if err := ValidatePromptLoop("p", &PromptLoop{Mode: "always", Default: &f}); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("default set with mode absent does not error (warning only)", func(t *testing.T) { tr := true - if err := ValidatePromptPeriodic("p", &PromptPeriodic{Default: &tr}); err != nil { + if err := ValidatePromptLoop("p", &PromptLoop{Default: &tr}); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("default set with mode=optional does not error and does not warn", func(t *testing.T) { f := false - if err := ValidatePromptPeriodic("p", &PromptPeriodic{Mode: "optional", Default: &f}); err != nil { + if err := ValidatePromptLoop("p", &PromptLoop{Mode: "optional", Default: &f}); err != nil { t.Errorf("unexpected error: %v", err) } }) diff --git a/internal/config/settings.go b/internal/config/settings.go index f8921531..ea4bc3e5 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,8 +1,10 @@ package config import ( + "encoding/json" "fmt" "os" + "strings" "time" "github.com/inercia/mitto/internal/appdir" @@ -71,16 +73,20 @@ type Settings struct { MCP *MCPConfig `json:"mcp,omitempty"` // Models is the list of named model profiles (criteria + tags) Models []ModelProfile `json:"models,omitempty"` + // Shortcuts holds global per-section configurable shortcut buttons, keyed by + // section ID (e.g. "conversations"). Merged with folder-level shortcuts at + // render time (global entries first). + Shortcuts map[string][]ShortcutButton `json:"shortcuts,omitempty"` } // DefaultStartupStaggerMs is the default stagger delay in milliseconds between // session resumes on startup for sessions sharing the same ACP process. const DefaultStartupStaggerMs = 300 -// DefaultStartupPeriodicDelay is the default delay before the periodic runner +// DefaultStartupLoopDelay is the default delay before the loop runner // starts its first poll on startup. This gives interactive sessions time to // resume first via WebSocket connections. -const DefaultStartupPeriodicDelay = 15 * time.Second +const DefaultStartupLoopDelay = 15 * time.Second // SessionConfig represents session storage configuration. type SessionConfig struct { @@ -105,25 +111,31 @@ type SessionConfig struct { // notification channel when many sessions resume simultaneously. // Default: 0 (use DefaultStartupStaggerMs = 300 ms). Set to -1 to disable staggering entirely. StartupStaggerMs int `json:"startup_stagger_ms,omitempty"` - // StartupPeriodicDelaySeconds is the delay in seconds before the periodic runner + // StartupLoopDelaySeconds is the delay in seconds before the loop runner // starts its first poll on startup. This gives interactive sessions time to resume // first via WebSocket connections, preventing thundering herd on ACP. // Default: 15 seconds. Set to 0 to disable (not recommended). - StartupPeriodicDelaySeconds int `json:"startup_periodic_delay_seconds,omitempty"` - // PeriodicSuspendTimeout controls when idle periodic conversations have their ACP - // connection suspended to save memory. When a periodic conversation's next prompt + StartupLoopDelaySeconds int `json:"startup_loop_delay_seconds,omitempty"` + // LoopSuspendTimeout controls when idle loop conversations have their ACP + // connection suspended to save memory. When a loop conversation's next prompt // is farther away than this timeout, its ACP session is closed even if the user has // it open in the sidebar. The conversation resumes transparently when focused or - // when its periodic prompt is due. + // when its loop prompt is due. // Values: "" (default - 30 minutes), "disabled", "15m", "30m", "1h", "2h" // Exposed in the Settings dialog under Conversations > Suspend Settings. - PeriodicSuspendTimeout string `json:"periodic_suspend_timeout,omitempty"` + LoopSuspendTimeout string `json:"loop_suspend_timeout,omitempty"` // MemoryRecycleThreshold controls when an idle shared ACP agent process is // recycled (stopped) to reclaim memory once its RSS (summed over the process // tree) exceeds this size. Recycling only affects fully-idle processes; // conversations resume transparently when focused. Values: "" (default, // disabled), "disabled", "3g", "4g", "6g", "8g". MemoryRecycleThreshold string `json:"memory_recycle_threshold,omitempty"` + // AgentInactivityTimeout controls how long a prompt may go with zero streamed + // agent activity (no tool call/UI prompt in flight) before the prompt inactivity + // watchdog cancels it, clearing is_prompting and surfacing a recoverable error. + // This breaks the GC deadlock where a wedged shared ACP process pins a session + // as stuck forever. Values: "" (default, 10m), "disabled", "5m", "10m", "15m", "30m". + AgentInactivityTimeout string `json:"agent_inactivity_timeout,omitempty"` } // ArchiveRetentionNever is the value for keeping archived conversations forever. @@ -148,22 +160,22 @@ func (c *SessionConfig) GetAutoArchiveInactiveAfter() string { return c.AutoArchiveInactiveAfter } -// ValidPeriodicSuspendTimeouts contains all valid periodic suspend timeout values. -var ValidPeriodicSuspendTimeouts = []string{"", "disabled", "15m", "30m", "1h", "2h"} +// ValidLoopSuspendTimeouts contains all valid loop suspend timeout values. +var ValidLoopSuspendTimeouts = []string{"", "disabled", "15m", "30m", "1h", "2h"} -// GetPeriodicSuspendTimeout returns the periodic suspend timeout string, or "" if not set. -func (c *SessionConfig) GetPeriodicSuspendTimeout() string { +// GetLoopSuspendTimeout returns the loop suspend timeout string, or "" if not set. +func (c *SessionConfig) GetLoopSuspendTimeout() string { if c == nil { return "" } - return c.PeriodicSuspendTimeout + return c.LoopSuspendTimeout } -// ParsePeriodicSuspendTimeout converts the periodic suspend timeout string to a time.Duration. +// ParseLoopSuspendTimeout converts the loop suspend timeout string to a time.Duration. // Returns the duration and true if the feature is enabled, or 0 and false if disabled. // An empty string returns the default of 30 minutes. -func (c *SessionConfig) ParsePeriodicSuspendTimeout() (time.Duration, bool) { - val := c.GetPeriodicSuspendTimeout() +func (c *SessionConfig) ParseLoopSuspendTimeout() (time.Duration, bool) { + val := c.GetLoopSuspendTimeout() switch val { case "disabled": return 0, false @@ -212,6 +224,39 @@ func (c *SessionConfig) ParseMemoryRecycleThreshold() (uint64, bool) { } } +// ValidAgentInactivityTimeouts contains all valid agent inactivity timeout values. +var ValidAgentInactivityTimeouts = []string{"", "disabled", "5m", "10m", "15m", "30m"} + +// GetAgentInactivityTimeout returns the agent inactivity timeout string, or "" if not set. +func (c *SessionConfig) GetAgentInactivityTimeout() string { + if c == nil { + return "" + } + return c.AgentInactivityTimeout +} + +// ParseAgentInactivityTimeout converts the agent inactivity timeout string to a +// time.Duration. Returns the duration and true if the watchdog cancellation is +// enabled, or 0 and false if disabled. An empty string returns the default of 10 +// minutes (enabled) — unlike MemoryRecycleThreshold, this feature defaults to on. +func (c *SessionConfig) ParseAgentInactivityTimeout() (time.Duration, bool) { + switch c.GetAgentInactivityTimeout() { + case "disabled": + return 0, false + case "", "10m": + return 10 * time.Minute, true + case "5m": + return 5 * time.Minute, true + case "15m": + return 15 * time.Minute, true + case "30m": + return 30 * time.Minute, true + default: + // Unknown value — use default + return 10 * time.Minute, true + } +} + // GetStartupStaggerMs returns the stagger delay in milliseconds between consecutive session // resumes on startup for sessions sharing the same ACP process. // Returns DefaultStartupStaggerMs (300 ms) if not configured (0). @@ -226,17 +271,17 @@ func (c *SessionConfig) GetStartupStaggerMs() int { return c.StartupStaggerMs } -// GetStartupPeriodicDelay returns the startup delay for the periodic runner. -// Returns DefaultStartupPeriodicDelay (15s) if not configured (0). +// GetStartupLoopDelay returns the startup delay for the loop runner. +// Returns DefaultStartupLoopDelay (15s) if not configured (0). // Returns 0 to disable if explicitly set to a negative value. -func (c *SessionConfig) GetStartupPeriodicDelay() time.Duration { - if c == nil || c.StartupPeriodicDelaySeconds == 0 { - return DefaultStartupPeriodicDelay +func (c *SessionConfig) GetStartupLoopDelay() time.Duration { + if c == nil || c.StartupLoopDelaySeconds == 0 { + return DefaultStartupLoopDelay } - if c.StartupPeriodicDelaySeconds < 0 { + if c.StartupLoopDelaySeconds < 0 { return 0 } - return time.Duration(c.StartupPeriodicDelaySeconds) * time.Second + return time.Duration(c.StartupLoopDelaySeconds) * time.Second } // ScannerDefenseConfig holds configuration for the scanner defense system. @@ -314,6 +359,7 @@ func (s *Settings) ToConfig() *Config { RestrictedRunners: s.RestrictedRunners, MCP: s.MCP, Models: s.Models, + Shortcuts: s.Shortcuts, } for i, srv := range s.ACPServers { cfg.ACPServers[i] = ACPServer(srv) @@ -335,6 +381,7 @@ func ConfigToSettings(cfg *Config) *Settings { RestrictedRunners: cfg.RestrictedRunners, MCP: cfg.MCP, Models: cfg.Models, + Shortcuts: cfg.Shortcuts, } for i, srv := range cfg.ACPServers { s.ACPServers[i] = ACPServerSettings(srv) @@ -342,6 +389,65 @@ func ConfigToSettings(cfg *Config) *Settings { return s } +// loadRawSettings reads settings.json into a Settings struct WITHOUT the +// keychain/password migration performed by LoadSettings. It ensures the file +// exists (creating it from embedded defaults if missing) so callers always get +// a usable struct. Reading raw avoids materialising the keychain-stored auth +// password back into settings.json on rewrite. +func loadRawSettings() (*Settings, error) { + if err := appdir.EnsureDir(); err != nil { + return nil, fmt.Errorf("failed to create Mitto directory: %w", err) + } + settingsPath, err := appdir.SettingsPath() + if err != nil { + return nil, err + } + if _, statErr := os.Stat(settingsPath); os.IsNotExist(statErr) { + if err := createDefaultSettings(); err != nil { + return nil, fmt.Errorf("failed to create default settings: %w", err) + } + } + var settings Settings + if err := fileutil.ReadJSON(settingsPath, &settings); err != nil { + return nil, fmt.Errorf("failed to read settings file %s: %w", settingsPath, err) + } + return &settings, nil +} + +// GlobalShortcuts returns the global shortcut sections stored in settings.json, +// or nil if none are configured or settings cannot be read. +func GlobalShortcuts() map[string][]ShortcutButton { + settings, err := loadRawSettings() + if err != nil || settings == nil { + return nil + } + return settings.Shortcuts +} + +// SetGlobalShortcuts persists global shortcut sections to settings.json. +// Empty/absent sections are pruned. The existing settings file is read raw, its +// Shortcuts field replaced, and the whole file rewritten so no other settings +// (including the keychain-managed auth password) are lost or leaked. +func SetGlobalShortcuts(sections map[string][]ShortcutButton) error { + settings, err := loadRawSettings() + if err != nil { + return err + } + // Prune sections with no buttons. + cleaned := map[string][]ShortcutButton{} + for k, v := range sections { + if len(v) > 0 { + cleaned[k] = v + } + } + if len(cleaned) == 0 { + settings.Shortcuts = nil + } else { + settings.Shortcuts = cleaned + } + return SaveSettings(settings) +} + // LoadSettings loads settings from the Mitto data directory. // If settings.json doesn't exist, it creates it from the embedded default config. // This function also ensures the Mitto directory exists. @@ -369,6 +475,12 @@ func LoadSettings() (*Config, error) { } } + // One-time, idempotent rewrite of legacy periodic_* keys to loop_* (mitto-8ir.12). + // Runs on the raw JSON before unmarshalling so old data isn't silently dropped. + if err := migrateSettingsFileIfNeeded(settingsPath); err != nil { + return nil, fmt.Errorf("failed to migrate settings file %s: %w", settingsPath, err) + } + // Load settings from JSON file var settings Settings if err := fileutil.ReadJSON(settingsPath, &settings); err != nil { @@ -411,6 +523,83 @@ func LoadSettings() (*Config, error) { return cfg, nil } +// migrateSettingsFileIfNeeded performs a one-time, idempotent rewrite of legacy +// periodic_* keys in settings.json to their loop_* equivalents (mitto-8ir.12). +// It operates on the raw JSON (map[string]interface{}), not the typed Settings +// struct, so old data is fixed BEFORE the new (loop_*-only) struct tags read it. +// +// It is a no-op when settings.json doesn't exist yet, is empty, isn't a JSON +// object, or contains none of the legacy keys. +func migrateSettingsFileIfNeeded(settingsPath string) error { + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + // Malformed or non-object JSON — let the normal load path surface the error. + return nil + } + + if !migrateSettingsPeriodicKeys(raw) { + return nil + } + + return fileutil.WriteJSONAtomic(settingsPath, raw, 0644) +} + +// migrateSettingsPeriodicKeys renames legacy periodic_* settings keys to their +// loop_* equivalents in-place on the raw settings map. These settings live +// nested under the "session" and "conversations" objects (matching the +// SessionConfig and ConversationsConfig struct layout): +// +// session.startup_periodic_delay_seconds -> session.startup_loop_delay_seconds +// session.periodic_suspend_timeout -> session.loop_suspend_timeout +// conversations.max_periodic_iterations -> conversations.max_loop_iterations +// conversations.min_periodic_completion_delay_seconds -> conversations.min_loop_completion_delay_seconds +// +// For each mapping, the value is moved only when the old key is present and +// the new key is absent (old moved only when new absent); an already-migrated +// or partially-migrated file is left untouched for that key. Returns true if +// any key was renamed. +func migrateSettingsPeriodicKeys(raw map[string]interface{}) bool { + changed := false + + renameKey := func(m map[string]interface{}, oldKey, newKey string) { + if m == nil { + return + } + oldVal, hasOld := m[oldKey] + if !hasOld { + return + } + if _, hasNew := m[newKey]; hasNew { + return + } + m[newKey] = oldVal + delete(m, oldKey) + changed = true + } + + if session, ok := raw["session"].(map[string]interface{}); ok { + renameKey(session, "startup_periodic_delay_seconds", "startup_loop_delay_seconds") + renameKey(session, "periodic_suspend_timeout", "loop_suspend_timeout") + } + if conversations, ok := raw["conversations"].(map[string]interface{}); ok { + renameKey(conversations, "max_periodic_iterations", "max_loop_iterations") + renameKey(conversations, "min_periodic_completion_delay_seconds", "min_loop_completion_delay_seconds") + } + + return changed +} + // deduplicateACPServerPrompts removes duplicate prompts from ACP server configurations. // This is a cleanup function to fix settings files that accumulated duplicates due to // a bug where file-based prompts were being merged without deduplication. @@ -547,6 +736,12 @@ func LoadSettingsWithFallback() (*LoadResult, error) { } } + // One-time, idempotent rewrite of legacy periodic_* keys to loop_* (mitto-8ir.12). + // Runs on the raw JSON before unmarshalling so old data isn't silently dropped. + if err := migrateSettingsFileIfNeeded(settingsPath); err != nil { + return nil, fmt.Errorf("failed to migrate settings file %s: %w", settingsPath, err) + } + // Load settings.json var settings Settings if err := fileutil.ReadJSON(settingsPath, &settings); err != nil { diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index b3d9539a..d01f9126 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -4,7 +4,9 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" + "time" "github.com/inercia/mitto/internal/appdir" ) @@ -37,6 +39,55 @@ func TestLoadSettings_CreatesDefaultSettings(t *testing.T) { _ = cfg // config is valid even with no servers } +func TestGlobalShortcuts_RoundTrip(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(appdir.MittoDirEnv, tmpDir) + appdir.ResetCache() + t.Cleanup(appdir.ResetCache) + + // Persist a couple of sections. + in := map[string][]ShortcutButton{ + "conversations": {{Prompt: "Commit changes"}}, + "beadsIssue": {{Icon: "lightning", Prompt: "Start work"}}, + // Empty section must be pruned on save. + "tasksList": {}, + } + if err := SetGlobalShortcuts(in); err != nil { + t.Fatalf("SetGlobalShortcuts failed: %v", err) + } + + got := GlobalShortcuts() + if len(got) != 2 { + t.Fatalf("GlobalShortcuts sections = %d, want 2 (%v)", len(got), got) + } + if len(got["conversations"]) != 1 || got["conversations"][0].Prompt != "Commit changes" { + t.Errorf("conversations = %+v, want one Commit changes button", got["conversations"]) + } + if len(got["beadsIssue"]) != 1 || got["beadsIssue"][0].Icon != "lightning" { + t.Errorf("beadsIssue = %+v, want one button with icon lightning", got["beadsIssue"]) + } + if _, ok := got["tasksList"]; ok { + t.Errorf("empty tasksList section should have been pruned, got %+v", got["tasksList"]) + } + + // Global shortcuts must survive a full settings load/convert round-trip. + cfg, err := LoadSettings() + if err != nil { + t.Fatalf("LoadSettings failed: %v", err) + } + if len(cfg.Shortcuts) != 2 { + t.Errorf("cfg.Shortcuts sections = %d, want 2 (%v)", len(cfg.Shortcuts), cfg.Shortcuts) + } + + // Clearing all sections removes the field entirely. + if err := SetGlobalShortcuts(map[string][]ShortcutButton{}); err != nil { + t.Fatalf("SetGlobalShortcuts(clear) failed: %v", err) + } + if got := GlobalShortcuts(); len(got) != 0 { + t.Errorf("GlobalShortcuts after clear = %v, want empty", got) + } +} + func TestLoadSettings_ReadsExistingSettings(t *testing.T) { // Use temp dir - t.Setenv automatically restores original value tmpDir := t.TempDir() @@ -448,6 +499,37 @@ func TestParseMemoryRecycleThreshold(t *testing.T) { } } +func TestParseAgentInactivityTimeout(t *testing.T) { + tests := []struct { + value string + wantDur time.Duration + wantEnabled bool + }{ + {"", 10 * time.Minute, true}, // empty defaults to enabled, unlike MemoryRecycleThreshold + {"disabled", 0, false}, + {"5m", 5 * time.Minute, true}, + {"10m", 10 * time.Minute, true}, + {"15m", 15 * time.Minute, true}, + {"30m", 30 * time.Minute, true}, + {"bogus", 10 * time.Minute, true}, // unknown → default (10m, enabled) + } + + for _, tc := range tests { + c := &SessionConfig{AgentInactivityTimeout: tc.value} + gotDur, gotEnabled := c.ParseAgentInactivityTimeout() + if gotDur != tc.wantDur || gotEnabled != tc.wantEnabled { + t.Errorf("ParseAgentInactivityTimeout(%q) = (%s, %t), want (%s, %t)", + tc.value, gotDur, gotEnabled, tc.wantDur, tc.wantEnabled) + } + } + + // Nil receiver must be safe and report the enabled default. + var nilCfg *SessionConfig + if gotDur, gotEnabled := nilCfg.ParseAgentInactivityTimeout(); gotDur != 10*time.Minute || !gotEnabled { + t.Errorf("nil ParseAgentInactivityTimeout() = (%s, %t), want (10m, true)", gotDur, gotEnabled) + } +} + func TestContextFlushCommand_RoundTrip(t *testing.T) { original := &Config{ ACPServers: []ACPServer{ @@ -533,3 +615,263 @@ func TestConfigToSettings_RoundTripWithModels(t *testing.T) { t.Errorf("Models[1].Tags = %v, want [Fast]", tagsOnly.Tags) } } + +// TestMigrateSettingsPeriodicKeys_MovesOldToNew verifies that legacy periodic_* +// keys nested under "session" and "conversations" are moved to their loop_* +// equivalents in-place, and that the change is reported. +func TestMigrateSettingsPeriodicKeys_MovesOldToNew(t *testing.T) { + raw := map[string]interface{}{ + "session": map[string]interface{}{ + "startup_periodic_delay_seconds": float64(15), + "periodic_suspend_timeout": "30m", + }, + "conversations": map[string]interface{}{ + "max_periodic_iterations": float64(42), + "min_periodic_completion_delay_seconds": float64(10), + }, + } + + changed := migrateSettingsPeriodicKeys(raw) + if !changed { + t.Fatal("expected changed=true when legacy keys are present") + } + + session := raw["session"].(map[string]interface{}) + if v, ok := session["startup_periodic_delay_seconds"]; ok { + t.Errorf("startup_periodic_delay_seconds should have been removed, still present: %v", v) + } + if v := session["startup_loop_delay_seconds"]; v != float64(15) { + t.Errorf("startup_loop_delay_seconds = %v, want 15", v) + } + if v, ok := session["periodic_suspend_timeout"]; ok { + t.Errorf("periodic_suspend_timeout should have been removed, still present: %v", v) + } + if v := session["loop_suspend_timeout"]; v != "30m" { + t.Errorf("loop_suspend_timeout = %v, want 30m", v) + } + + conversations := raw["conversations"].(map[string]interface{}) + if v, ok := conversations["max_periodic_iterations"]; ok { + t.Errorf("max_periodic_iterations should have been removed, still present: %v", v) + } + if v := conversations["max_loop_iterations"]; v != float64(42) { + t.Errorf("max_loop_iterations = %v, want 42", v) + } + if v, ok := conversations["min_periodic_completion_delay_seconds"]; ok { + t.Errorf("min_periodic_completion_delay_seconds should have been removed, still present: %v", v) + } + if v := conversations["min_loop_completion_delay_seconds"]; v != float64(10) { + t.Errorf("min_loop_completion_delay_seconds = %v, want 10", v) + } +} + +// TestMigrateSettingsPeriodicKeys_Idempotent verifies that running the migration +// twice in a row on an already-migrated map is a no-op (second call reports no change). +func TestMigrateSettingsPeriodicKeys_Idempotent(t *testing.T) { + raw := map[string]interface{}{ + "session": map[string]interface{}{ + "startup_periodic_delay_seconds": float64(15), + }, + } + + if !migrateSettingsPeriodicKeys(raw) { + t.Fatal("expected changed=true on first run") + } + if migrateSettingsPeriodicKeys(raw) { + t.Error("expected changed=false on idempotent re-run") + } +} + +// TestMigrateSettingsPeriodicKeys_NewKeyedUntouched verifies that a settings map +// already using the new loop_* keys is left completely untouched. +func TestMigrateSettingsPeriodicKeys_NewKeyedUntouched(t *testing.T) { + raw := map[string]interface{}{ + "session": map[string]interface{}{ + "startup_loop_delay_seconds": float64(20), + "loop_suspend_timeout": "1h", + }, + "conversations": map[string]interface{}{ + "max_loop_iterations": float64(99), + "min_loop_completion_delay_seconds": float64(7), + }, + } + + if migrateSettingsPeriodicKeys(raw) { + t.Error("expected changed=false for an already new-keyed map") + } + + session := raw["session"].(map[string]interface{}) + if v := session["startup_loop_delay_seconds"]; v != float64(20) { + t.Errorf("startup_loop_delay_seconds = %v, want 20", v) + } + if v := session["loop_suspend_timeout"]; v != "1h" { + t.Errorf("loop_suspend_timeout = %v, want 1h", v) + } +} + +// TestMigrateSettingsPeriodicKeys_PartialOldNewMix verifies that when a key has +// BOTH the old and the new name present, the old value is not moved (new wins, +// old moved only when new absent), while sibling old-only keys still migrate. +func TestMigrateSettingsPeriodicKeys_PartialOldNewMix(t *testing.T) { + raw := map[string]interface{}{ + "session": map[string]interface{}{ + // Both present: new key must win untouched. + "startup_periodic_delay_seconds": float64(15), + "startup_loop_delay_seconds": float64(20), + // Only old present: must migrate. + "periodic_suspend_timeout": "30m", + }, + } + + if !migrateSettingsPeriodicKeys(raw) { + t.Fatal("expected changed=true because periodic_suspend_timeout has no new-key counterpart") + } + + session := raw["session"].(map[string]interface{}) + // Old key with an existing new counterpart is left as-is (not deleted, not moved). + if v := session["startup_loop_delay_seconds"]; v != float64(20) { + t.Errorf("startup_loop_delay_seconds = %v, want 20 (new value preserved)", v) + } + if v, ok := session["startup_periodic_delay_seconds"]; !ok || v != float64(15) { + t.Errorf("startup_periodic_delay_seconds should be left untouched when new key already present, got %v (ok=%v)", v, ok) + } + // Old-only key migrates normally. + if v, ok := session["periodic_suspend_timeout"]; ok { + t.Errorf("periodic_suspend_timeout should have been removed, still present: %v", v) + } + if v := session["loop_suspend_timeout"]; v != "30m" { + t.Errorf("loop_suspend_timeout = %v, want 30m", v) + } +} + +// TestMigrateSettingsPeriodicKeys_NoSectionsPresent verifies the migration is a +// safe no-op when neither "session" nor "conversations" objects are present. +func TestMigrateSettingsPeriodicKeys_NoSectionsPresent(t *testing.T) { + raw := map[string]interface{}{ + "web": map[string]interface{}{"port": float64(8080)}, + } + if migrateSettingsPeriodicKeys(raw) { + t.Error("expected changed=false when session/conversations sections are absent") + } +} + +// TestLoadSettings_MigratesLegacyPeriodicKeys is an end-to-end test: an +// old-keyed settings.json on disk is migrated to loop_* keys on load, values +// are preserved under the new names, the on-disk file is rewritten, and a +// second load is a no-op (idempotent; file content unchanged). +func TestLoadSettings_MigratesLegacyPeriodicKeys(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(appdir.MittoDirEnv, tmpDir) + appdir.ResetCache() + t.Cleanup(appdir.ResetCache) + + settingsPath := filepath.Join(tmpDir, appdir.SettingsFileName) + legacySettings := `{ + "acp_servers": [], + "web": {"port": 9999}, + "session": { + "startup_periodic_delay_seconds": 15, + "periodic_suspend_timeout": "30m" + }, + "conversations": { + "max_periodic_iterations": 42, + "min_periodic_completion_delay_seconds": 10 + } + }` + if err := os.WriteFile(settingsPath, []byte(legacySettings), 0644); err != nil { + t.Fatalf("failed to create test settings.json: %v", err) + } + + cfg, err := LoadSettings() + if err != nil { + t.Fatalf("LoadSettings() failed: %v", err) + } + + if got := cfg.Session.GetStartupLoopDelay(); got.Seconds() != 15 { + t.Errorf("GetStartupLoopDelay() = %v, want 15s", got) + } + if got := cfg.Session.GetLoopSuspendTimeout(); got != "30m" { + t.Errorf("GetLoopSuspendTimeout() = %q, want %q", got, "30m") + } + if got := cfg.Conversations.GetMaxLoopIterations(); got != 42 { + t.Errorf("GetMaxLoopIterations() = %d, want 42", got) + } + if got := cfg.Conversations.GetMinLoopCompletionDelaySeconds(); got != 10 { + t.Errorf("GetMinLoopCompletionDelaySeconds() = %d, want 10", got) + } + + // The on-disk file must have been rewritten to the new keys. + rewritten, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json after migration: %v", err) + } + if strings.Contains(string(rewritten), "periodic") { + t.Errorf("settings.json still contains a legacy periodic_* key after migration:\n%s", rewritten) + } + if !strings.Contains(string(rewritten), "startup_loop_delay_seconds") { + t.Errorf("settings.json missing startup_loop_delay_seconds after migration:\n%s", rewritten) + } + + // Second load must be idempotent: file content stays exactly the same. + if _, err := LoadSettings(); err != nil { + t.Fatalf("second LoadSettings() failed: %v", err) + } + rewrittenAgain, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json after second load: %v", err) + } + if string(rewritten) != string(rewrittenAgain) { + t.Errorf("settings.json changed on idempotent second load:\nfirst:\n%s\nsecond:\n%s", rewritten, rewrittenAgain) + } +} + +// TestLoadSettings_NewKeyedSettingsUntouched verifies that a settings.json +// already using the new loop_* keys loads correctly and is not rewritten. +func TestLoadSettings_NewKeyedSettingsUntouched(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(appdir.MittoDirEnv, tmpDir) + appdir.ResetCache() + t.Cleanup(appdir.ResetCache) + + settingsPath := filepath.Join(tmpDir, appdir.SettingsFileName) + newSettings := `{ + "acp_servers": [], + "web": {"port": 9999}, + "session": { + "startup_loop_delay_seconds": 20, + "loop_suspend_timeout": "1h" + }, + "conversations": { + "max_loop_iterations": 99, + "min_loop_completion_delay_seconds": 7 + } + }` + if err := os.WriteFile(settingsPath, []byte(newSettings), 0644); err != nil { + t.Fatalf("failed to create test settings.json: %v", err) + } + + before, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json before load: %v", err) + } + + cfg, err := LoadSettings() + if err != nil { + t.Fatalf("LoadSettings() failed: %v", err) + } + + if got := cfg.Session.GetStartupLoopDelay(); got.Seconds() != 20 { + t.Errorf("GetStartupLoopDelay() = %v, want 20s", got) + } + if got := cfg.Conversations.GetMaxLoopIterations(); got != 99 { + t.Errorf("GetMaxLoopIterations() = %d, want 99", got) + } + + after, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json after load: %v", err) + } + if string(before) != string(after) { + t.Errorf("settings.json was rewritten even though it already used loop_* keys:\nbefore:\n%s\nafter:\n%s", before, after) + } +} diff --git a/internal/config/templatefuncs.go b/internal/config/templatefuncs.go index 67bfe200..94a00eb7 100644 --- a/internal/config/templatefuncs.go +++ b/internal/config/templatefuncs.go @@ -20,13 +20,40 @@ const gitCmdTimeout = 5 * time.Second // Changing logic here propagates identically to both callers. // ============================================================================= -// hasPattern reports whether any name in names matches the glob pattern. -// Fail-open: returns true when available is false (tool list not yet fetched). -func hasPattern(available bool, names []string, pattern string) bool { - if !available { - return true // fail-open during MCP-tools cache warm-up +// resolveServerName extracts the owning server name from a tool pattern or +// concrete tool name: the token before the first underscore (e.g. "jira_*" +// or "jira_create_issue" -> "jira"). A pattern/name with no underscore +// resolves to itself. +func resolveServerName(pattern string) string { + if idx := strings.IndexByte(pattern, '_'); idx > 0 { + return pattern[:idx] + } + return pattern +} + +// resolveServerEntry returns pattern's owning server entry from servers, +// falling back to AllServersToolKey when the specific server isn't present +// (used by callers without real per-server identity, e.g. processors — see +// NewProcessorToolsContext). ok is false when neither is found. +func resolveServerEntry(servers map[string]ServerToolInfo, pattern string) (ServerToolInfo, bool) { + if info, ok := servers[resolveServerName(pattern)]; ok { + return info, true + } + info, ok := servers[AllServersToolKey] + return info, ok +} + +// hasPattern reports whether pattern is satisfied under the per-server MCP +// tool availability rule (docs/devel/mcp-tool-discovery.md, Q3.2/Q4.1): +// fail-open (true) unless pattern's owning server is known AND Reachable, in +// which case matching is name-based against that server's own tool names. +// Single source of truth shared with the CEL path (cel_evaluator.go). +func hasPattern(servers map[string]ServerToolInfo, pattern string) bool { + info, ok := resolveServerEntry(servers, pattern) + if !ok || info.State != ServerToolStateReachable { + return true } - for _, name := range names { + for _, name := range info.Names { if matched, err := filepath.Match(pattern, name); err == nil && matched { return true } @@ -34,38 +61,21 @@ func hasPattern(available bool, names []string, pattern string) bool { return false } -// hasAllPatterns reports whether every pattern is matched by at least one name. -// Fail-open: returns true when available is false. -func hasAllPatterns(available bool, names []string, patterns []string) bool { - if !available { - return true - } +// hasAllPatterns reports whether every pattern is satisfied (see hasPattern). +func hasAllPatterns(servers map[string]ServerToolInfo, patterns []string) bool { for _, pattern := range patterns { - found := false - for _, name := range names { - if matched, err := filepath.Match(pattern, name); err == nil && matched { - found = true - break - } - } - if !found { + if !hasPattern(servers, pattern) { return false } } return true } -// hasAnyPattern reports whether any pattern is matched by at least one name. -// Fail-open: returns true when available is false. -func hasAnyPattern(available bool, names []string, patterns []string) bool { - if !available { - return true - } +// hasAnyPattern reports whether any pattern is satisfied (see hasPattern). +func hasAnyPattern(servers map[string]ServerToolInfo, patterns []string) bool { for _, pattern := range patterns { - for _, name := range names { - if matched, err := filepath.Match(pattern, name); err == nil && matched { - return true - } + if hasPattern(servers, pattern) { + return true } } return false @@ -337,17 +347,15 @@ func FormatChildren(children []ChildInfo) string { // - join(sep, elems) — strings.Join with sep first (template-natural argument order). func BuildTemplateFuncMap(ctx *PromptEnabledContext) template.FuncMap { var ( - folder string - toolsAvailable bool - toolNames []string - args map[string]string - userData map[string]string - modelTags []string + folder string + toolServers map[string]ServerToolInfo + args map[string]string + userData map[string]string + modelTags []string ) if ctx != nil { folder = ctx.Workspace.Folder - toolsAvailable = ctx.Tools.Available - toolNames = ctx.Tools.Names + toolServers = ctx.Tools.Servers args = ctx.Args userData = ctx.UserData modelTags = ctx.Session.ModelTags @@ -406,7 +414,7 @@ func BuildTemplateFuncMap(ctx *PromptEnabledContext) template.FuncMap { }, "GitFileTracked": func(path string) bool { return gitFileTracked(folder, path) }, "GitFileDeleted": func(path string) bool { return gitFileDeleted(folder, path) }, - "HasPattern": func(pattern string) bool { return hasPattern(toolsAvailable, toolNames, pattern) }, + "HasPattern": func(pattern string) bool { return hasPattern(toolServers, pattern) }, // Model(tag) — true iff the session's current model carries the capability tag // (case-insensitive), resolved from the models: profiles. False for an unknown model. "Model": func(tag string) bool { return hasModelTag(modelTags, tag) }, diff --git a/internal/config/templatefuncs_test.go b/internal/config/templatefuncs_test.go index 2421ee79..17da41e8 100644 --- a/internal/config/templatefuncs_test.go +++ b/internal/config/templatefuncs_test.go @@ -292,31 +292,32 @@ func TestBuildTemplateFuncMap_GitFuncsRenderSmoke(t *testing.T) { func TestParity_HasPattern(t *testing.T) { e := newTestEvaluator(t) - names := []string{"github_pr", "jira_create", "slack_post"} + reachable := NewReachableToolsContext([]string{"github_pr", "jira_create", "slack_post"}).Servers cases := []struct { - name string - available bool - pattern string - want bool + name string + servers map[string]ServerToolInfo + pattern string + want bool }{ - {"match", true, "github_*", true}, - {"no match", true, "notion_*", false}, - {"fail-open unavailable", false, "anything_*", true}, - {"exact match", true, "jira_create", true}, + {"match", reachable, "github_*", true}, + {"unknown server fails open", reachable, "notion_*", true}, + {"no match on reachable server", reachable, "jira_other", false}, + {"cold start fails open", nil, "anything_*", true}, + {"exact match", reachable, "jira_create", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - goResult := hasPattern(tc.available, names, tc.pattern) + goResult := hasPattern(tc.servers, tc.pattern) if goResult != tc.want { - t.Errorf("hasPattern(%v, names, %q) = %v, want %v", tc.available, tc.pattern, goResult, tc.want) + t.Errorf("hasPattern(servers, %q) = %v, want %v", tc.pattern, goResult, tc.want) } - ctx := &PromptEnabledContext{Tools: ToolsContext{Available: tc.available, Names: names}} + ctx := &PromptEnabledContext{Tools: ToolsContext{Servers: tc.servers}} celExpr := fmt.Sprintf("Tools.HasPattern(%q)", tc.pattern) celResult := evalCEL(t, e, celExpr, ctx) if goResult != celResult { - t.Errorf("parity failure: go=%v cel=%v for pattern %q available=%v", goResult, celResult, tc.pattern, tc.available) + t.Errorf("parity failure: go=%v cel=%v for pattern %q", goResult, celResult, tc.pattern) } }) } @@ -324,28 +325,29 @@ func TestParity_HasPattern(t *testing.T) { func TestParity_HasAllPatterns(t *testing.T) { e := newTestEvaluator(t) - names := []string{"github_pr", "jira_create", "slack_post"} + reachable := NewReachableToolsContext([]string{"github_pr", "jira_create", "slack_post"}).Servers cases := []struct { - name string - available bool - patterns []string - want bool + name string + servers map[string]ServerToolInfo + patterns []string + want bool }{ - {"all satisfied", true, []string{"github_*", "jira_*"}, true}, - {"one unsatisfied", true, []string{"github_*", "notion_*"}, false}, - {"fail-open unavailable", false, []string{"notion_*"}, true}, - {"empty patterns", true, []string{}, true}, + {"all satisfied", reachable, []string{"github_*", "jira_*"}, true}, + {"one unsatisfied on reachable server", reachable, []string{"github_*", "jira_other"}, false}, + {"unknown server fails open (does not fail the AND)", reachable, []string{"notion_*"}, true}, + {"cold start fails open", nil, []string{"notion_*"}, true}, + {"empty patterns", reachable, []string{}, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - goResult := hasAllPatterns(tc.available, names, tc.patterns) + goResult := hasAllPatterns(tc.servers, tc.patterns) if goResult != tc.want { t.Errorf("hasAllPatterns = %v, want %v", goResult, tc.want) } // Build CEL list literal for patterns - ctx := &PromptEnabledContext{Tools: ToolsContext{Available: tc.available, Names: names}} + ctx := &PromptEnabledContext{Tools: ToolsContext{Servers: tc.servers}} var celPatterns string for i, p := range tc.patterns { if i > 0 { @@ -356,7 +358,7 @@ func TestParity_HasAllPatterns(t *testing.T) { celExpr := fmt.Sprintf("Tools.HasAllPatterns([%s])", celPatterns) celResult := evalCEL(t, e, celExpr, ctx) if goResult != celResult { - t.Errorf("parity failure: go=%v cel=%v for patterns %v available=%v", goResult, celResult, tc.patterns, tc.available) + t.Errorf("parity failure: go=%v cel=%v for patterns %v", goResult, celResult, tc.patterns) } }) } @@ -364,26 +366,27 @@ func TestParity_HasAllPatterns(t *testing.T) { func TestParity_HasAnyPattern(t *testing.T) { e := newTestEvaluator(t) - names := []string{"github_pr", "jira_create"} + reachable := NewReachableToolsContext([]string{"github_pr", "jira_create"}).Servers cases := []struct { - name string - available bool - patterns []string - want bool + name string + servers map[string]ServerToolInfo + patterns []string + want bool }{ - {"one matches", true, []string{"github_*", "notion_*"}, true}, - {"none match", true, []string{"slack_*", "notion_*"}, false}, - {"fail-open unavailable", false, []string{"notion_*"}, true}, + {"one matches", reachable, []string{"github_*", "notion_*"}, true}, + {"none match on reachable servers", reachable, []string{"github_other", "jira_other"}, false}, + {"unknown server fails open (satisfies the OR)", reachable, []string{"notion_*"}, true}, + {"cold start fails open", nil, []string{"notion_*"}, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - goResult := hasAnyPattern(tc.available, names, tc.patterns) + goResult := hasAnyPattern(tc.servers, tc.patterns) if goResult != tc.want { t.Errorf("hasAnyPattern = %v, want %v", goResult, tc.want) } - ctx := &PromptEnabledContext{Tools: ToolsContext{Available: tc.available, Names: names}} + ctx := &PromptEnabledContext{Tools: ToolsContext{Servers: tc.servers}} var celPatterns string for i, p := range tc.patterns { if i > 0 { @@ -400,6 +403,52 @@ func TestParity_HasAnyPattern(t *testing.T) { } } +// TestHasPattern_PerServerStates covers the three per-server MCP tool +// availability states (docs/devel/mcp-tool-discovery.md, Q3.2/Q4.1) and the +// prefix->server pattern resolution required by mitto-sys.1, keeping the Go +// template path (hasPattern) and the CEL path (Tools.HasPattern) in parity. +func TestHasPattern_PerServerStates(t *testing.T) { + e := newTestEvaluator(t) + + servers := map[string]ServerToolInfo{ + "jira": {State: ServerToolStateReachable, Names: []string{"jira_create_issue"}}, + "github": {State: ServerToolStateReachable, Names: []string{"github_list_prs"}}, + "slack": {State: ServerToolStateUnknown}, + "notion": {State: ServerToolStateUnreachable}, + } + + cases := []struct { + name string + servers map[string]ServerToolInfo + pattern string + want bool + }{ + {"unknown server fails open even with no matching tool", servers, "slack_post", true}, + {"configured-but-unreachable server fails open", servers, "notion_search", true}, + {"reachable server fails closed on match", servers, "jira_*", true}, + {"reachable server fails closed on no match", servers, "jira_other_thing", false}, + {"prefix mapping resolves to owning server, unaffected by a different reachable server", servers, "github_*", true}, + {"prefix mapping: no match on owning reachable server, unaffected by a different reachable server", servers, "github_nonexistent", false}, + {"cold start: nil server map globally fails open", nil, "jira_*", true}, + {"cold start: empty (non-nil) server map globally fails open", map[string]ServerToolInfo{}, "jira_*", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + goResult := hasPattern(tc.servers, tc.pattern) + if goResult != tc.want { + t.Errorf("hasPattern(servers, %q) = %v, want %v", tc.pattern, goResult, tc.want) + } + ctx := &PromptEnabledContext{Tools: ToolsContext{Servers: tc.servers}} + celExpr := fmt.Sprintf("Tools.HasPattern(%q)", tc.pattern) + celResult := evalCEL(t, e, celExpr, ctx) + if goResult != celResult { + t.Errorf("parity failure: go=%v cel=%v for pattern %q", goResult, celResult, tc.pattern) + } + }) + } +} + func TestParity_MatchesServerType(t *testing.T) { e := newTestEvaluator(t) @@ -913,7 +962,7 @@ func TestCond_Parity(t *testing.T) { ACP: ACPContext{Name: "auggie", Type: "augment"}, Session: SessionContext{IsChild: true}, Workspace: WorkspaceContext{Folder: tmpDir}, - Tools: ToolsContext{Available: true, Names: []string{"mitto_list", "jira_create"}}, + Tools: NewReachableToolsContext([]string{"mitto_list", "jira_create"}), } e := newTestEvaluator(t) diff --git a/internal/config/workspaces.go b/internal/config/workspaces.go index 8a25df9c..2149a652 100644 --- a/internal/config/workspaces.go +++ b/internal/config/workspaces.go @@ -47,6 +47,9 @@ type AutoChild struct { // TargetWorkspaceUUID is the UUID of the workspace to use for the child. // If empty, uses the parent's workspace. TargetWorkspaceUUID string `json:"target_workspace_uuid,omitempty" yaml:"target_workspace_uuid,omitempty"` + // ModelProfile is the name of a global Model profile (Config.Models) to apply as the + // child's initial/baseline model. Empty = use the ACP server's default model selection. + ModelProfile string `json:"model_profile,omitempty" yaml:"model_profile,omitempty"` } // WorkspaceSettings is the JSON representation of a workspace. @@ -108,6 +111,12 @@ type WorkspaceSettings struct { // AuxiliaryModelSelection matchMode/pattern. Empty falls back to // AuxiliaryModelSelection when present. AuxiliaryModelProfile string `json:"auxiliary_model_profile,omitempty" yaml:"auxiliary_model_profile,omitempty"` + // AuxiliaryModelTag selects the auxiliary-session model by capability tag + // (e.g. "Fast"). Resolved to the first Model profile (Config.Models, in + // definition order) carrying this tag whose Criteria matches an available + // model. Mutually exclusive with AuxiliaryModelProfile in the UI; when both + // are set, AuxiliaryModelProfile wins. Falls back to AuxiliaryModelSelection. + AuxiliaryModelTag string `json:"auxiliary_model_tag,omitempty" yaml:"auxiliary_model_tag,omitempty"` // IsDefault marks this workspace as the default for its working directory. // When multiple workspaces share the same folder (e.g. different ACP servers // or model variants), the one with IsDefault set is preferred when a workspace diff --git a/internal/conversation/background_session.go b/internal/conversation/background_session.go index 0768d316..1aed6e6e 100644 --- a/internal/conversation/background_session.go +++ b/internal/conversation/background_session.go @@ -186,7 +186,7 @@ type BackgroundSession struct { // onTurnIdle is called after a turn completes and the session is fully idle // (turn succeeded and no further queued message was dispatched). Used to arm - // the on-completion periodic timer. + // the on-completion loop timer. onTurnIdle func(sessionID string) // isChildPrompting checks if a child session is currently prompting. @@ -250,6 +250,18 @@ type BackgroundSession struct { lastUsage *acp.Usage lastUsageMu sync.Mutex + // Cumulative token usage across all prompts on this session. In-memory only; + // resets on restart (unlike event-derived statistics, which survive restarts). + cumInputTokens atomic.Int64 + cumOutputTokens atomic.Int64 + cumTotalTokens atomic.Int64 + + // Child-wait accumulation for blocking mitto_children_tasks_wait calls made + // FROM this session (i.e. this session acting as a parent). In-memory only; + // resets on restart. + mcpChildWaitCount atomic.Int64 + mcpChildWaitTotalNanos atomic.Int64 + // Context window usage — updated from SessionUsageUpdate notifications. contextSize int contextUsed int @@ -313,14 +325,14 @@ type BackgroundSession struct { lastQueueSendError string lastQueueSendErrAt time.Time - // Periodic continuation marker (mitto-5xjn). lastTurnScheduledPeriodic records whether + // Loop continuation marker (mitto-5xjn). lastTurnScheduledLoop records whether // the most recent COMMITTED dispatch was a scheduled (non-forced, non-FreshContext) - // periodic run of this loop. It powers Iteration.IsUninterrupted. Session-scoped + + // run of this loop. It powers Iteration.IsUninterrupted. Session-scoped + // in-memory so it auto-resets to false across archive/unarchive, GC suspend/resume, and // process restart (all recreate the BackgroundSession). Explicitly cleared on ACP reinit - // and periodic config changes (those keep the same BackgroundSession). - periodicContinuationMu sync.Mutex - lastTurnScheduledPeriodic bool + // and loop config changes (those keep the same BackgroundSession). + loopContinuationMu sync.Mutex + lastTurnScheduledLoop bool // streamingSuppressed gates streaming callbacks during an in-place context flush // (flushContextInPlace). When true the acpCallbackSink short-circuits all streaming @@ -366,6 +378,11 @@ type BackgroundSessionConfig struct { // MittoConfig is the full Mitto configuration (used for default flags) MittoConfig *config.Config + // ModelConstraintOverride, when non-nil with a non-empty Pattern, overrides the + // ACP-server-derived "model" auto-selection constraint for this session only. + // Used by auto-children to apply a per-child initial model profile. + ModelConstraintOverride *config.ACPServerConstraint + // AvailableACPServers is the pre-computed list of ACP servers that have workspaces // configured for the session's working directory. Populated by SessionManager using // the same logic as the mitto_conversation_get_current MCP tool. @@ -401,7 +418,7 @@ type BackgroundSessionConfig struct { OnSelfDestruct func(sessionID string) // OnTurnIdle is called after a turn completes and the session is fully idle. - // Used to drive event-driven on-completion periodic firing via the runner. + // Used to drive event-driven on-completion loop firing via the runner. OnTurnIdle func(sessionID string) // GlobalMCPServer is the global MCP server for session registration. @@ -584,7 +601,10 @@ func NewBackgroundSession(cfg BackgroundSessionConfig) (*BackgroundSession, erro } // Look up ACP server constraints from config - bs.acpServerConstraints = lookupACPServerConstraints(cfg.MittoConfig, cfg.ACPServer) + bs.acpServerConstraints = applyModelConstraintOverride( + lookupACPServerConstraints(cfg.MittoConfig, cfg.ACPServer), + cfg.ModelConstraintOverride, + ) // Store full config for model-tag resolution (config.ResolveModelTags). bs.mittoConfig = cfg.MittoConfig // Look up the agent-native context-flush command from config @@ -799,7 +819,10 @@ func ResumeBackgroundSession(config BackgroundSessionConfig) (*BackgroundSession } // Look up ACP server constraints from config - bs.acpServerConstraints = lookupACPServerConstraints(config.MittoConfig, config.ACPServer) + bs.acpServerConstraints = applyModelConstraintOverride( + lookupACPServerConstraints(config.MittoConfig, config.ACPServer), + config.ModelConstraintOverride, + ) // Store full config for model-tag resolution (config.ResolveModelTags). bs.mittoConfig = config.MittoConfig // Look up the agent-native context-flush command from config @@ -1536,6 +1559,26 @@ func (bs *BackgroundSession) GetContextUsage() (size, used int) { return bs.contextSize, bs.contextUsed } +// GetCumulativeUsage returns the cumulative token usage accumulated across all +// prompts on this session. In-memory only; resets on restart. +func (bs *BackgroundSession) GetCumulativeUsage() (input, output, total int64) { + return bs.cumInputTokens.Load(), bs.cumOutputTokens.Load(), bs.cumTotalTokens.Load() +} + +// RecordChildWait accumulates a completed blocking wait duration for +// mitto_children_tasks_wait calls made from this session. In-memory only; +// resets on restart. +func (bs *BackgroundSession) RecordChildWait(d time.Duration) { + bs.mcpChildWaitCount.Add(1) + bs.mcpChildWaitTotalNanos.Add(int64(d)) +} + +// GetChildWaitStats returns the number of completed blocking child waits and +// their total accumulated duration. In-memory only; resets on restart. +func (bs *BackgroundSession) GetChildWaitStats() (count int64, total time.Duration) { + return bs.mcpChildWaitCount.Load(), time.Duration(bs.mcpChildWaitTotalNanos.Load()) +} + // sessionError is a simple error type for session errors. type sessionError struct { msg string diff --git a/internal/conversation/background_session_test.go b/internal/conversation/background_session_test.go index 95f4a794..f06c5ecb 100644 --- a/internal/conversation/background_session_test.go +++ b/internal/conversation/background_session_test.go @@ -4420,12 +4420,12 @@ func TestStartACPStartupWatchdog_NilLoggerNoop(t *testing.T) { // configured timeout, emitting both a WARN and an ERROR log along the way. func TestStartPromptInactivityWatchdog_FiresWhenIdle(t *testing.T) { origWarn := promptInactivityWatchdogWarnDelay - origTimeout := promptInactivityWatchdogTimeout + origTimeout := promptInactivityWatchdogTimeout() promptInactivityWatchdogWarnDelay = 20 * time.Millisecond - promptInactivityWatchdogTimeout = 60 * time.Millisecond + SetPromptInactivityTimeout(60 * time.Millisecond) defer func() { promptInactivityWatchdogWarnDelay = origWarn - promptInactivityWatchdogTimeout = origTimeout + SetPromptInactivityTimeout(origTimeout) }() rec := newCapturingLogHandler() @@ -4461,12 +4461,12 @@ func TestStartPromptInactivityWatchdog_FiresWhenIdle(t *testing.T) { // and does not fire while streamed activity continues to arrive. func TestStartPromptInactivityWatchdog_SilentWhenActive(t *testing.T) { origWarn := promptInactivityWatchdogWarnDelay - origTimeout := promptInactivityWatchdogTimeout + origTimeout := promptInactivityWatchdogTimeout() promptInactivityWatchdogWarnDelay = 40 * time.Millisecond - promptInactivityWatchdogTimeout = 80 * time.Millisecond + SetPromptInactivityTimeout(80 * time.Millisecond) defer func() { promptInactivityWatchdogWarnDelay = origWarn - promptInactivityWatchdogTimeout = origTimeout + SetPromptInactivityTimeout(origTimeout) }() rec := newCapturingLogHandler() @@ -4505,12 +4505,12 @@ loop: // agent is legitimately blocked waiting on user input. func TestStartPromptInactivityWatchdog_PausesDuringUIPrompt(t *testing.T) { origWarn := promptInactivityWatchdogWarnDelay - origTimeout := promptInactivityWatchdogTimeout + origTimeout := promptInactivityWatchdogTimeout() promptInactivityWatchdogWarnDelay = 20 * time.Millisecond - promptInactivityWatchdogTimeout = 50 * time.Millisecond + SetPromptInactivityTimeout(50 * time.Millisecond) defer func() { promptInactivityWatchdogWarnDelay = origWarn - promptInactivityWatchdogTimeout = origTimeout + SetPromptInactivityTimeout(origTimeout) }() rec := newCapturingLogHandler() @@ -4539,12 +4539,12 @@ func TestStartPromptInactivityWatchdog_PausesDuringUIPrompt(t *testing.T) { // the tool reaches a terminal status the idle clock resumes and the warning may fire. func TestStartPromptInactivityWatchdog_PausesDuringToolCall(t *testing.T) { origWarn := promptInactivityWatchdogWarnDelay - origTimeout := promptInactivityWatchdogTimeout + origTimeout := promptInactivityWatchdogTimeout() promptInactivityWatchdogWarnDelay = 20 * time.Millisecond - promptInactivityWatchdogTimeout = 50 * time.Millisecond + SetPromptInactivityTimeout(50 * time.Millisecond) defer func() { promptInactivityWatchdogWarnDelay = origWarn - promptInactivityWatchdogTimeout = origTimeout + SetPromptInactivityTimeout(origTimeout) }() rec := newCapturingLogHandler() @@ -4594,12 +4594,12 @@ func TestStartPromptInactivityWatchdog_PausesDuringToolCall(t *testing.T) { // when both the warn delay and timeout are non-positive. func TestStartPromptInactivityWatchdog_DisabledWhenZero(t *testing.T) { origWarn := promptInactivityWatchdogWarnDelay - origTimeout := promptInactivityWatchdogTimeout + origTimeout := promptInactivityWatchdogTimeout() promptInactivityWatchdogWarnDelay = 0 - promptInactivityWatchdogTimeout = 0 + SetPromptInactivityTimeout(0) defer func() { promptInactivityWatchdogWarnDelay = origWarn - promptInactivityWatchdogTimeout = origTimeout + SetPromptInactivityTimeout(origTimeout) }() rec := newCapturingLogHandler() @@ -4630,12 +4630,12 @@ func TestStartPromptInactivityWatchdog_DisabledWhenZero(t *testing.T) { // to an automatic cancel that could kill a legitimate long-running, silent tool call. func TestStartPromptInactivityWatchdog_WarnOnlyWhenTimeoutZero(t *testing.T) { origWarn := promptInactivityWatchdogWarnDelay - origTimeout := promptInactivityWatchdogTimeout + origTimeout := promptInactivityWatchdogTimeout() promptInactivityWatchdogWarnDelay = 20 * time.Millisecond - promptInactivityWatchdogTimeout = 0 // production default: cancellation disabled + SetPromptInactivityTimeout(0) // production default: cancellation disabled defer func() { promptInactivityWatchdogWarnDelay = origWarn - promptInactivityWatchdogTimeout = origTimeout + SetPromptInactivityTimeout(origTimeout) }() rec := newCapturingLogHandler() @@ -4853,10 +4853,10 @@ func TestBuildACPProcessEnv_MittoEnvOverridesServerEnv(t *testing.T) { } } -// TestTriggerTitleGenerationFromPeriodic verifies that the helper correctly selects +// TestTriggerTitleGenerationFromLoop verifies that the helper correctly selects // the source text for title generation given various combinations of inline prompt // and prompt_name, including the UI placeholder "(pending)". -func TestTriggerTitleGenerationFromPeriodic(t *testing.T) { +func TestTriggerTitleGenerationFromLoop(t *testing.T) { // makeBS creates a minimal BackgroundSession backed by a real session.Store. // The session has no name, so NeedsTitle() returns true and retryTitleGenerationIfNeeded // will synchronously set a quick fallback title via GenerateAndSetTitle. @@ -4896,7 +4896,7 @@ func TestTriggerTitleGenerationFromPeriodic(t *testing.T) { resolverCalled = true return "should not be used", nil }) - bs.TriggerTitleGenerationFromPeriodic("Real text here", "SomeName") + bs.TriggerTitleGenerationFromLoop("Real text here", "SomeName") if resolverCalled { t.Error("resolver should not be called when inline prompt is usable") } @@ -4915,7 +4915,7 @@ func TestTriggerTitleGenerationFromPeriodic(t *testing.T) { } return "", fmt.Errorf("unexpected name %q", name) }) - bs.TriggerTitleGenerationFromPeriodic("(pending)", "X") + bs.TriggerTitleGenerationFromLoop("(pending)", "X") got := getName(t, store, "sid-2") if strings.Contains(strings.ToLower(got), "pending") { t.Errorf("title must not be derived from '(pending)' placeholder, got %q", got) @@ -4930,7 +4930,7 @@ func TestTriggerTitleGenerationFromPeriodic(t *testing.T) { bs, store := makeBS(t, "sid-3", func(name, dir string) (string, error) { return "", fmt.Errorf("resolution failed") }) - bs.TriggerTitleGenerationFromPeriodic("(pending)", "MyPromptName") + bs.TriggerTitleGenerationFromLoop("(pending)", "MyPromptName") got := getName(t, store, "sid-3") if !strings.Contains(got, "MyPromptName") { t.Errorf("expected fallback to prompt name 'MyPromptName', got %q", got) @@ -4940,7 +4940,7 @@ func TestTriggerTitleGenerationFromPeriodic(t *testing.T) { // Case 4: empty inline, no resolver configured → uses prompt name directly. t.Run("empty inline no resolver - uses name", func(t *testing.T) { bs, store := makeBS(t, "sid-4", nil) - bs.TriggerTitleGenerationFromPeriodic("", "PromptXYZ") + bs.TriggerTitleGenerationFromLoop("", "PromptXYZ") got := getName(t, store, "sid-4") if !strings.Contains(got, "PromptXYZ") { t.Errorf("expected title from prompt name, got %q", got) @@ -4950,7 +4950,7 @@ func TestTriggerTitleGenerationFromPeriodic(t *testing.T) { // Case 5: both empty → no-op, no title set. t.Run("both empty - no-op", func(t *testing.T) { bs, store := makeBS(t, "sid-5", nil) - bs.TriggerTitleGenerationFromPeriodic("", "") + bs.TriggerTitleGenerationFromLoop("", "") got := getName(t, store, "sid-5") if got != "" { t.Errorf("expected no title set when both args are empty, got %q", got) @@ -4960,7 +4960,7 @@ func TestTriggerTitleGenerationFromPeriodic(t *testing.T) { // Case 6: whitespace-only inline is treated as empty; falls back to prompt name. t.Run("whitespace-only inline treated as empty", func(t *testing.T) { bs, store := makeBS(t, "sid-6", nil) - bs.TriggerTitleGenerationFromPeriodic(" ", "WhitespaceName") + bs.TriggerTitleGenerationFromLoop(" ", "WhitespaceName") got := getName(t, store, "sid-6") if !strings.Contains(got, "WhitespaceName") { t.Errorf("expected title from prompt name, got %q", got) @@ -5027,3 +5027,49 @@ func TestACPInitializeAttemptTimeoutBound(t *testing.T) { t.Logf("acpInitializeAttemptTimeout=%v, maxRetries=%d, maxBackoff=%v → total max=%v (pre-fix was %v)", acpInitializeAttemptTimeout, maxACPStartRetries, maxBackoffTotal, totalMax, preFix) } + +// TestBackgroundSession_ChildWaitStats verifies RecordChildWait accumulates +// count and total duration, and GetChildWaitStats reports them correctly. +func TestBackgroundSession_ChildWaitStats(t *testing.T) { + bs := &BackgroundSession{} + + if count, total := bs.GetChildWaitStats(); count != 0 || total != 0 { + t.Fatalf("expected zero stats before any recording, got count=%d total=%v", count, total) + } + + bs.RecordChildWait(100 * time.Millisecond) + bs.RecordChildWait(250 * time.Millisecond) + + count, total := bs.GetChildWaitStats() + if count != 2 { + t.Errorf("count = %d, want 2", count) + } + if total != 350*time.Millisecond { + t.Errorf("total = %v, want %v", total, 350*time.Millisecond) + } +} + +// TestBackgroundSession_CumulativeUsage verifies pdAccumulateCumulativeUsage +// adds usage across multiple calls and GetCumulativeUsage reports the sum. +func TestBackgroundSession_CumulativeUsage(t *testing.T) { + bs := &BackgroundSession{} + + if in, out, total := bs.GetCumulativeUsage(); in != 0 || out != 0 || total != 0 { + t.Fatalf("expected zero usage before any accumulation, got in=%d out=%d total=%d", in, out, total) + } + + bs.pdAccumulateCumulativeUsage(&acp.Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15}) + bs.pdAccumulateCumulativeUsage(&acp.Usage{InputTokens: 20, OutputTokens: 8, TotalTokens: 28}) + bs.pdAccumulateCumulativeUsage(nil) // must be a no-op + + in, out, total := bs.GetCumulativeUsage() + if in != 30 { + t.Errorf("input = %d, want 30", in) + } + if out != 13 { + t.Errorf("output = %d, want 13", out) + } + if total != 43 { + t.Errorf("total = %d, want 43", total) + } +} diff --git a/internal/conversation/bgsession_acp_process.go b/internal/conversation/bgsession_acp_process.go index 1dcc225e..f05e8a09 100644 --- a/internal/conversation/bgsession_acp_process.go +++ b/internal/conversation/bgsession_acp_process.go @@ -160,9 +160,9 @@ func (bs *BackgroundSession) restartACPProcess(reason RestartReason) error { // Clear the old connection bs.acpConn = nil - // Breaking the periodic continuation: an ACP reinit disrupts agent context, so the next - // periodic run must render the verbose form (mitto-5xjn). - bs.ResetPeriodicContinuation() + // Breaking the loop continuation: an ACP reinit disrupts agent context, so the next + // loop run must render the verbose form (mitto-5xjn). + bs.ResetLoopContinuation() // Record this restart attempt with reason bs.recordRestart(reason) @@ -504,17 +504,32 @@ func StartACPStartupWatchdog(ctx context.Context, logger *slog.Logger, command, // Exposed as a var so tests can override it. var promptInactivityWatchdogWarnDelay = 2 * time.Minute -// promptInactivityWatchdogTimeout is the idle duration (no streamed agent activity) -// after which the prompt inactivity watchdog cancels the in-flight prompt so the -// session can recover from a live-but-unresponsive agent (one that stops streaming -// without crashing — e.g. wedged during MCP init or GC-thrashing). -// -// Default 0: automatic cancellation is DISABLED — the watchdog is WARN-only out of -// the box. This avoids ever cancelling a legitimate long-running tool call that -// produces no intermediate streamed output (the residual false-positive of an -// automatic cancel). Set to a positive duration to opt in to automatic cancellation. -// Exposed as a var so tests can override it. -var promptInactivityWatchdogTimeout time.Duration = 0 +// promptInactivityWatchdogTimeoutNanos holds the configured prompt inactivity +// watchdog cancellation timeout, in nanoseconds. It is an atomic.Int64 (rather than +// a plain time.Duration var) because it can be updated at runtime from a live config +// change (see SetPromptInactivityTimeout) while watchdog goroutines started by prior +// prompts concurrently read it. Zero means automatic cancellation is DISABLED — the +// watchdog is WARN-only. This avoids ever cancelling a legitimate long-running tool +// call that produces no intermediate streamed output (the residual false-positive of +// an automatic cancel). The zero value (disabled) is the safe default before startup +// config wiring calls SetPromptInactivityTimeout; production wires a 10m default via +// SessionConfig.ParseAgentInactivityTimeout. +var promptInactivityWatchdogTimeoutNanos atomic.Int64 + +// SetPromptInactivityTimeout sets the process-wide duration of no streamed agent +// activity after which the prompt inactivity watchdog cancels an in-flight prompt, +// clearing is_prompting and surfacing a recoverable error. Zero disables automatic +// cancellation (WARN-only). Safe to call concurrently with running watchdog +// goroutines, e.g. from a live settings update. +func SetPromptInactivityTimeout(d time.Duration) { + promptInactivityWatchdogTimeoutNanos.Store(int64(d)) +} + +// promptInactivityWatchdogTimeout returns the currently configured watchdog +// cancellation timeout. See SetPromptInactivityTimeout. +func promptInactivityWatchdogTimeout() time.Duration { + return time.Duration(promptInactivityWatchdogTimeoutNanos.Load()) +} // signalAgentActivity records the current time as the most recent streamed agent // activity. It is called on every ACP SessionUpdate so the prompt inactivity watchdog @@ -606,7 +621,7 @@ func (bs *BackgroundSession) resetInFlightToolCalls() { // Prompt() returns. It is a no-op when both delays are non-positive. func (bs *BackgroundSession) startPromptInactivityWatchdog(ctx context.Context, cancel context.CancelFunc, fired *atomic.Bool) { warnDelay := promptInactivityWatchdogWarnDelay - timeout := promptInactivityWatchdogTimeout + timeout := promptInactivityWatchdogTimeout() if warnDelay <= 0 && timeout <= 0 { return } diff --git a/internal/conversation/bgsession_prompt.go b/internal/conversation/bgsession_prompt.go index bd2da9ca..fde71c06 100644 --- a/internal/conversation/bgsession_prompt.go +++ b/internal/conversation/bgsession_prompt.go @@ -104,42 +104,47 @@ func (bs *BackgroundSession) buildPromptWithHistory(message string) string { } // SetPromptResolver sets the function used to resolve named workspace prompts to their full text. -// This is called by the server setup code (same resolver used by PeriodicRunner). +// This is called by the server setup code (same resolver used by LoopRunner). func (bs *BackgroundSession) SetPromptResolver(resolver PromptResolver) { bs.promptResolver = resolver } -// PeriodicKind classifies how a periodic prompt was triggered so the dispatch path can +// LoopKind classifies how a loop prompt was triggered so the dispatch path can // distinguish a normal scheduled/onCompletion delivery from a manual "run now" without -// matching the magic SenderID string. PeriodicKindNone means the prompt is not a -// periodic run (user/other sender). -type PeriodicKind int +// matching the magic SenderID string. LoopKindNone means the prompt is not a +// loop run (user/other sender). +type LoopKind int const ( - PeriodicKindNone PeriodicKind = iota // not a periodic run - PeriodicKindScheduled // normal scheduled / onCompletion delivery - PeriodicKindForced // manual "run now" + LoopKindNone LoopKind = iota // not a loop run + LoopKindScheduled // normal scheduled / onCompletion delivery + LoopKindForced // manual "run now" ) // PromptMeta contains optional metadata about the prompt source. type PromptMeta struct { - SenderID string // Unique identifier of the sending client (for broadcast deduplication) - PromptID string // Client-generated prompt ID (for delivery confirmation) - PromptName string // Name of workspace prompt (resolved to full text before ACP; empty for ad-hoc prompts) - ImageIDs []string // IDs of images attached to the prompt - FileIDs []string // IDs of files attached to the prompt - OnComplete func(err error) // Called when the async prompt goroutine finishes (nil = success) - IsPeriodicForced bool // True when this periodic prompt was triggered manually via "run now" - // PeriodicKind classifies a periodic run (none/scheduled/forced). Set by the - // PeriodicRunner. Drives the Iteration.IsUninterrupted continuation signal. - PeriodicKind PeriodicKind - // IterationNumber is the 0-based index of the current periodic run (periodic.IterationCount - // at dispatch). Zero for non-periodic prompts. Feeds the {{ .Iteration.* }} template namespace. + SenderID string // Unique identifier of the sending client (for broadcast deduplication) + PromptID string // Client-generated prompt ID (for delivery confirmation) + PromptName string // Name of workspace prompt (resolved to full text before ACP; empty for ad-hoc prompts) + ImageIDs []string // IDs of images attached to the prompt + FileIDs []string // IDs of files attached to the prompt + OnComplete func(err error) // Called when the async prompt goroutine finishes (nil = success) + IsLoopForced bool // True when this loop prompt was triggered manually via "run now" + // QueueOrigin carries session.QueueOrigin* for queue dispatches: "agent" for + // cross-session/MCP sends (fail-closed on template errors), "user"/empty for + // human-typed messages that were queued (fail-open, delivered verbatim); + // empty for non-queue dispatches. + QueueOrigin string + // LoopKind classifies a loop run (none/scheduled/forced). Set by the + // LoopRunner. Drives the Iteration.IsUninterrupted continuation signal. + LoopKind LoopKind + // IterationNumber is the 0-based index of the current loop run (loop.IterationCount + // at dispatch). Zero for non-loop prompts. Feeds the {{ .Iteration.* }} template namespace. IterationNumber int - // MaxIterations is the configured maximum number of periodic runs (0 = unlimited). + // MaxIterations is the configured maximum number of loop runs (0 = unlimited). MaxIterations int // IterationUninterrupted feeds {{ .Iteration.IsUninterrupted }}: true only on a - // scheduled, non-forced, non-FreshContext periodic run that directly follows another + // scheduled, non-forced, non-FreshContext loop run that directly follows another // such run with no interruption. Computed in PromptWithMeta from the session-scoped // continuation marker (peeked before body render, advanced at the dispatch commit). IterationUninterrupted bool @@ -190,7 +195,7 @@ func (bs *BackgroundSession) PromptWithAttachments(message string, imageIDs, fil // Behavioral contract: // - Sends contextFlushCommand as a single-block Prompt() RPC on the existing session. // - All streaming callbacks are suppressed (setStreamingSuppressed) for the duration. -// - Best-effort: the caller MUST continue with the main periodic prompt regardless of +// - Best-effort: the caller MUST continue with the main loop prompt regardless of // any returned error. // - Works for both direct-conn (acpConn) and shared-process (sharedProcess) sessions. func (bs *BackgroundSession) flushContextInPlace(ctx context.Context) error { @@ -239,12 +244,12 @@ func (bs *BackgroundSession) FlushContext() error { // The meta parameter contains sender information for multi-client broadcast. // The response is streamed via callbacks to the attached client (if any) and persisted. func (bs *BackgroundSession) PromptWithMeta(message string, meta PromptMeta) error { - // Periodic continuation signal (mitto-5xjn): peek BEFORE resolveAndSubstitute so the + // Loop continuation signal (mitto-5xjn): peek BEFORE resolveAndSubstitute so the // prompt-body template ({{ if .Iteration.IsUninterrupted }}) renders against it. We // only PEEK here (no mutation); the marker is advanced at the dispatch point of no // return below, so rejected/early-return dispatches never corrupt the chain. - isScheduledPeriodic := meta.PeriodicKind == PeriodicKindScheduled && !meta.FreshContext - meta.IterationUninterrupted = bs.peekPeriodicContinuation(isScheduledPeriodic) + isScheduledLoop := meta.LoopKind == LoopKindScheduled && !meta.FreshContext + meta.IterationUninterrupted = bs.peekLoopContinuation(isScheduledLoop) // Resolve prompt name, apply argument substitution, annotate meta. // See promptDispatcher.resolveAndSubstitute for the full logic. @@ -367,7 +372,7 @@ retryAfterRestart: bs.TouchActivity() // Check if we need to inject conversation history (first prompt of resumed session). - // FreshContext suppresses history injection so each periodic run starts clean. + // FreshContext suppresses history injection so each loop run starts clean. shouldInjectHistory := bs.isResumed && !bs.historyInjected && !meta.FreshContext if shouldInjectHistory { bs.historyInjected = true @@ -380,10 +385,10 @@ retryAfterRestart: } bs.promptMu.Unlock() - // Point of no return: this dispatch is committed. Advance the periodic continuation + // Point of no return: this dispatch is committed. Advance the loop continuation // marker so the NEXT dispatch can detect an uninterrupted continuation. A non-scheduled // dispatch (user/forced/FreshContext) sets it false, breaking the chain (mitto-5xjn). - bs.advancePeriodicContinuation(isScheduledPeriodic) + bs.advanceLoopContinuation(isScheduledLoop) // Notify about streaming state change (prompt started) if bs.onStreamingStateChanged != nil { @@ -573,7 +578,7 @@ retryAfterRestart: } // sessionIdle becomes true only on the success path when the turn ended and - // no further queued message was dispatched. It gates the on-completion periodic + // no further queued message was dispatched. It gates the on-completion loop // idle hook invoked after OnComplete below. sessionIdle := false @@ -913,6 +918,15 @@ func (bs *BackgroundSession) pdAccumulateTokenUsage(tokens int) { bs.processorManager.AccumulateTokenUsage(tokens) } +func (bs *BackgroundSession) pdAccumulateCumulativeUsage(usage *acp.Usage) { + if usage == nil { + return + } + bs.cumInputTokens.Add(int64(usage.InputTokens)) + bs.cumOutputTokens.Add(int64(usage.OutputTokens)) + bs.cumTotalTokens.Add(int64(usage.TotalTokens)) +} + func (bs *BackgroundSession) pdEstimateTokensFromMessage(msg string) int { return processors.EstimateTokens(msg) } @@ -1061,32 +1075,32 @@ func (bs *BackgroundSession) pdFlushContextInPlace(ctx context.Context) error { return bs.flushContextInPlace(ctx) } -// peekPeriodicContinuation reports whether the current dispatch is an uninterrupted -// continuation (a scheduled periodic run directly following another one) WITHOUT mutating +// peekLoopContinuation reports whether the current dispatch is an uninterrupted +// continuation (a scheduled loop run directly following another one) WITHOUT mutating // the marker. The marker is advanced separately at the dispatch point of no return so that // early-return/rejected dispatches do not corrupt the continuation chain. -func (bs *BackgroundSession) peekPeriodicContinuation(isScheduledPeriodic bool) bool { - bs.periodicContinuationMu.Lock() - defer bs.periodicContinuationMu.Unlock() - return isScheduledPeriodic && bs.lastTurnScheduledPeriodic +func (bs *BackgroundSession) peekLoopContinuation(isScheduledLoop bool) bool { + bs.loopContinuationMu.Lock() + defer bs.loopContinuationMu.Unlock() + return isScheduledLoop && bs.lastTurnScheduledLoop } -// advancePeriodicContinuation records whether the just-committed dispatch was a scheduled -// periodic run, so the next dispatch can detect an uninterrupted continuation. Setting it +// advanceLoopContinuation records whether the just-committed dispatch was a scheduled +// loop run, so the next dispatch can detect an uninterrupted continuation. Setting it // false (any non-scheduled dispatch: user prompt, forced run, FreshContext) breaks the chain. -func (bs *BackgroundSession) advancePeriodicContinuation(isScheduledPeriodic bool) { - bs.periodicContinuationMu.Lock() - bs.lastTurnScheduledPeriodic = isScheduledPeriodic - bs.periodicContinuationMu.Unlock() +func (bs *BackgroundSession) advanceLoopContinuation(isScheduledLoop bool) { + bs.loopContinuationMu.Lock() + bs.lastTurnScheduledLoop = isScheduledLoop + bs.loopContinuationMu.Unlock() } -// ResetPeriodicContinuation clears the continuation marker so the next periodic run renders +// ResetLoopContinuation clears the continuation marker so the next loop run renders // the verbose form. Called on lifecycle boundaries that break the "agent just finished that // exact task and still holds the context" assumption while keeping the same BackgroundSession: -// ACP process reinit/restart and periodic loop config changes (create/update/pause/re-enable). +// ACP process reinit/restart and loop config changes (create/update/pause/re-enable). // Boundaries that recreate the BackgroundSession reset it for free. -func (bs *BackgroundSession) ResetPeriodicContinuation() { - bs.periodicContinuationMu.Lock() - bs.lastTurnScheduledPeriodic = false - bs.periodicContinuationMu.Unlock() +func (bs *BackgroundSession) ResetLoopContinuation() { + bs.loopContinuationMu.Lock() + bs.lastTurnScheduledLoop = false + bs.loopContinuationMu.Unlock() } diff --git a/internal/conversation/bgsession_prompt_test.go b/internal/conversation/bgsession_prompt_test.go index b9988c82..660db0a7 100644 --- a/internal/conversation/bgsession_prompt_test.go +++ b/internal/conversation/bgsession_prompt_test.go @@ -107,9 +107,9 @@ func TestRedactArgValue_Truncation(t *testing.T) { } } -// TestPeriodicContinuation_Marker tests the peek/advance/reset lifecycle of the -// session-scoped periodic continuation marker (mitto-5xjn). -func TestPeriodicContinuation_Marker(t *testing.T) { +// TestLoopContinuation_Marker tests the peek/advance/reset lifecycle of the +// session-scoped loop continuation marker (mitto-5xjn). +func TestLoopContinuation_Marker(t *testing.T) { newBS := func() *BackgroundSession { bs := &BackgroundSession{} return bs @@ -118,62 +118,62 @@ func TestPeriodicContinuation_Marker(t *testing.T) { // (i) First scheduled run → peek returns false (no previous run recorded). t.Run("first-scheduled-peek-false", func(t *testing.T) { bs := newBS() - if got := bs.peekPeriodicContinuation(true); got { - t.Error("first scheduled run: peekPeriodicContinuation(true) should return false, got true") + if got := bs.peekLoopContinuation(true); got { + t.Error("first scheduled run: peekLoopContinuation(true) should return false, got true") } }) // (ii) Two back-to-back scheduled runs: advance true → next peek true. t.Run("back-to-back-scheduled", func(t *testing.T) { bs := newBS() - bs.advancePeriodicContinuation(true) // first run committed - if got := bs.peekPeriodicContinuation(true); !got { - t.Error("second scheduled run: peekPeriodicContinuation(true) should return true after advance(true)") + bs.advanceLoopContinuation(true) // first run committed + if got := bs.peekLoopContinuation(true); !got { + t.Error("second scheduled run: peekLoopContinuation(true) should return true after advance(true)") } }) // (iii) A user/non-scheduled dispatch between two scheduled runs resets the chain. t.Run("non-scheduled-breaks-chain", func(t *testing.T) { bs := newBS() - bs.advancePeriodicContinuation(true) // scheduled run 1 - bs.advancePeriodicContinuation(false) // user prompt (non-scheduled) - if got := bs.peekPeriodicContinuation(true); got { - t.Error("after non-scheduled advance(false): peekPeriodicContinuation(true) should return false") + bs.advanceLoopContinuation(true) // scheduled run 1 + bs.advanceLoopContinuation(false) // user prompt (non-scheduled) + if got := bs.peekLoopContinuation(true); got { + t.Error("after non-scheduled advance(false): peekLoopContinuation(true) should return false") } }) - // (iv) Forced periodic run (isScheduledPeriodic=false) → peek false and resets chain. + // (iv) Forced loop run (isScheduledLoop=false) → peek false and resets chain. t.Run("forced-run-breaks-chain", func(t *testing.T) { bs := newBS() - bs.advancePeriodicContinuation(true) // scheduled run 1 - bs.advancePeriodicContinuation(false) // forced run (PeriodicKindForced → isScheduledPeriodic=false) - if got := bs.peekPeriodicContinuation(true); got { - t.Error("after forced advance(false): peekPeriodicContinuation(true) should return false") + bs.advanceLoopContinuation(true) // scheduled run 1 + bs.advanceLoopContinuation(false) // forced run (LoopKindForced → isScheduledLoop=false) + if got := bs.peekLoopContinuation(true); got { + t.Error("after forced advance(false): peekLoopContinuation(true) should return false") } // peek with false also returns false - if got := bs.peekPeriodicContinuation(false); got { - t.Error("peekPeriodicContinuation(false) should always return false") + if got := bs.peekLoopContinuation(false); got { + t.Error("peekLoopContinuation(false) should always return false") } }) - // (v) FreshContext → isScheduledPeriodic is computed as false → peek false. + // (v) FreshContext → isScheduledLoop is computed as false → peek false. t.Run("fresh-context-peek-false", func(t *testing.T) { bs := newBS() - bs.advancePeriodicContinuation(true) - // FreshContext makes isScheduledPeriodic=false regardless of PeriodicKindScheduled - isScheduledPeriodic := false // PeriodicKindScheduled && !FreshContext → false when FreshContext=true - if got := bs.peekPeriodicContinuation(isScheduledPeriodic); got { - t.Error("FreshContext: peekPeriodicContinuation(false) should return false") + bs.advanceLoopContinuation(true) + // FreshContext makes isScheduledLoop=false regardless of LoopKindScheduled + isScheduledLoop := false // LoopKindScheduled && !FreshContext → false when FreshContext=true + if got := bs.peekLoopContinuation(isScheduledLoop); got { + t.Error("FreshContext: peekLoopContinuation(false) should return false") } }) - // (vi) ResetPeriodicContinuation makes the next peek false even after advance(true). + // (vi) ResetLoopContinuation makes the next peek false even after advance(true). t.Run("reset-makes-next-peek-false", func(t *testing.T) { bs := newBS() - bs.advancePeriodicContinuation(true) - bs.ResetPeriodicContinuation() - if got := bs.peekPeriodicContinuation(true); got { - t.Error("after ResetPeriodicContinuation: peekPeriodicContinuation(true) should return false") + bs.advanceLoopContinuation(true) + bs.ResetLoopContinuation() + if got := bs.peekLoopContinuation(true); got { + t.Error("after ResetLoopContinuation: peekLoopContinuation(true) should return false") } }) } diff --git a/internal/conversation/bgsession_title.go b/internal/conversation/bgsession_title.go index e901f967..5ec708ff 100644 --- a/internal/conversation/bgsession_title.go +++ b/internal/conversation/bgsession_title.go @@ -20,7 +20,7 @@ func (bs *BackgroundSession) NeedsTitle() bool { // (1) failed initial title generation attempts (e.g., context deadline exceeded) // (2) prompts that arrived via paths that don't trigger title generation // -// (queue processing, MCP send_prompt, periodic prompts) +// (queue processing, MCP send_prompt, loop prompts) func (bs *BackgroundSession) retryTitleGenerationIfNeeded(message string) { bs.titleCoord.retryIfNeeded(bs, message) } @@ -28,21 +28,21 @@ func (bs *BackgroundSession) retryTitleGenerationIfNeeded(message string) { // TriggerTitleGeneration triggers async title generation if the session has no title yet. // This is the public interface used by MCP tools and API handlers to generate titles // for sessions that received prompts via paths that don't normally trigger title generation -// (e.g., periodic prompt configuration, queue processing). +// (e.g., loop prompt configuration, queue processing). func (bs *BackgroundSession) TriggerTitleGeneration(message string) { bs.titleCoord.trigger(bs, message) } -// TriggerTitleGenerationFromPeriodic chooses the best source text for title -// generation given a periodic-style draft. The inline `prompt` may be empty, +// TriggerTitleGenerationFromLoop chooses the best source text for title +// generation given a loop-style draft. The inline `prompt` may be empty, // whitespace, or the UI placeholder "(pending)" — all three are treated as // "no inline prompt". When only `promptName` is meaningful, it is resolved // to its full text via the configured prompt resolver (workingDir-scoped) // before being passed to the auxiliary title generator. If resolution fails // or no resolver is configured, the bare prompt name is used as a fallback. // No-op when neither source yields any text. -func (bs *BackgroundSession) TriggerTitleGenerationFromPeriodic(prompt, promptName string) { - bs.titleCoord.triggerFromPeriodic(bs, prompt, promptName) +func (bs *BackgroundSession) TriggerTitleGenerationFromLoop(prompt, promptName string) { + bs.titleCoord.triggerFromLoop(bs, prompt, promptName) } // --- titleDeps implementation (supplies live session dependencies to titleCoordinator) --- diff --git a/internal/conversation/config_manager.go b/internal/conversation/config_manager.go index 522ee286..a37cb0e6 100644 --- a/internal/conversation/config_manager.go +++ b/internal/conversation/config_manager.go @@ -59,6 +59,22 @@ func lookupACPServerConstraints(cfg *config.Config, serverName string) map[strin return nil } +// applyModelConstraintOverride merges a per-session "model" constraint override into an +// existing ACP-server-constraints map, returning the (possibly newly allocated) map. Used +// by auto-children to apply a per-child initial model profile (mitto-9x8) without mutating +// the server's shared constraints map. No-op (returns constraints unchanged) when override +// is nil or has an empty Pattern. +func applyModelConstraintOverride(constraints map[string]*config.ACPServerConstraint, override *config.ACPServerConstraint) map[string]*config.ACPServerConstraint { + if override == nil || override.Pattern == "" { + return constraints + } + if constraints == nil { + constraints = make(map[string]*config.ACPServerConstraint) + } + constraints[ConfigOptionCategoryModel] = override + return constraints +} + // lookupContextFlushCommand returns the agent-native context-flush command (e.g. // "/clear") configured for the named ACP server, or "" when none is configured. func lookupContextFlushCommand(cfg *config.Config, serverName string) string { diff --git a/internal/conversation/follow_up_coordinator.go b/internal/conversation/follow_up_coordinator.go index e20a523b..e0a34263 100644 --- a/internal/conversation/follow_up_coordinator.go +++ b/internal/conversation/follow_up_coordinator.go @@ -392,8 +392,8 @@ func (c followUpCoordinator) applyAfterProcessors( // promptOriginFromSenderID maps a PromptMeta.SenderID to the canonical origin tag. func promptOriginFromSenderID(senderID string) string { switch senderID { - case "periodic-runner": - return "periodic-runner" + case "loop-runner": + return "loop-runner" case "queue": return "queue" default: diff --git a/internal/conversation/interfaces.go b/internal/conversation/interfaces.go index 721c3487..e19d92b2 100644 --- a/internal/conversation/interfaces.go +++ b/internal/conversation/interfaces.go @@ -45,7 +45,7 @@ type SharedProcess interface { } // PromptResolver resolves a prompt name to its full text for a given working directory. -// It is used by BackgroundSession, SessionManager, and PeriodicRunner to look up +// It is used by BackgroundSession, SessionManager, and LoopRunner to look up // named workspace prompts at execution time. type PromptResolver func(promptName string, workingDir string) (string, error) diff --git a/internal/conversation/loop_data.go b/internal/conversation/loop_data.go new file mode 100644 index 00000000..d7c4cc9c --- /dev/null +++ b/internal/conversation/loop_data.go @@ -0,0 +1,56 @@ +package conversation + +import ( + "time" + + "github.com/inercia/mitto/internal/session" +) + +// BuildLoopUpdatedData constructs the WebSocket payload map for a loop_updated event. +// loop_configured: true if a loop config exists (controls editor UI mode). +// loop_enabled: true if loop runs are active (controls sidebar category + clock icon). +func BuildLoopUpdatedData(sessionID string, loop *session.LoopPrompt) map[string]interface{} { + data := map[string]interface{}{ + "session_id": sessionID, + } + + if loop != nil { + // loop_configured: true means the session is in loop mode (shows loop UI) + data["loop_configured"] = true + // loop_enabled: true means loop runs are active (locked state) + data["loop_enabled"] = loop.Enabled + // fresh_context: true means each scheduled run starts with a clean agent context + data["fresh_context"] = loop.FreshContext + data["max_iterations"] = loop.MaxIterations + data["iteration_count"] = loop.IterationCount + data["frequency"] = map[string]interface{}{ + "value": loop.Frequency.Value, + "unit": loop.Frequency.Unit, + } + if loop.Frequency.At != "" { + data["frequency"].(map[string]interface{})["at"] = loop.Frequency.At + } + if loop.NextScheduledAt != nil && !loop.NextScheduledAt.IsZero() { + data["next_scheduled_at"] = loop.NextScheduledAt.Format(time.RFC3339) + } + if loop.StoppedReason != "" { + data["loop_stopped_reason"] = string(loop.StoppedReason) + } + // Glance fields for conversation header display (trigger resolved via EffectiveTrigger + // so schedule loops always report "schedule", not the empty-string default). + data["trigger"] = string(loop.EffectiveTrigger()) + data["delay_seconds"] = loop.DelaySeconds + data["max_duration_seconds"] = loop.MaxDurationSeconds + // Prompt presence flag and free-text preview for the selector UI. + data["loop_has_prompt"] = loop.Prompt != "" || loop.PromptName != "" + if preview := loop.PromptPreview(); preview != "" { + data["loop_prompt_preview"] = preview + } + } else { + // No loop config - session is not in loop mode + data["loop_configured"] = false + data["loop_enabled"] = false + } + + return data +} diff --git a/internal/conversation/loop_data_test.go b/internal/conversation/loop_data_test.go new file mode 100644 index 00000000..b47dcde3 --- /dev/null +++ b/internal/conversation/loop_data_test.go @@ -0,0 +1,93 @@ +package conversation + +import ( + "testing" + + "github.com/inercia/mitto/internal/session" +) + +func TestBuildLoopUpdatedData_PromptFields(t *testing.T) { + tests := []struct { + name string + loop *session.LoopPrompt + wantHasPrompt bool + wantPreviewPresent bool + wantLoopConfigured bool + }{ + { + name: "nil loop yields no prompt fields", + loop: nil, + wantHasPrompt: false, + wantPreviewPresent: false, + wantLoopConfigured: false, + }, + { + name: "free-text prompt yields has_prompt=true and non-empty preview", + loop: &session.LoopPrompt{ + Prompt: "Run the nightly report\nSecond line", + Frequency: session.Frequency{Value: 1, Unit: session.FrequencyDays}, + Enabled: true, + }, + wantHasPrompt: true, + wantPreviewPresent: true, + wantLoopConfigured: true, + }, + { + name: "named-prompt-only config yields has_prompt=true but empty preview", + loop: &session.LoopPrompt{ + PromptName: "my-workspace-prompt", + Frequency: session.Frequency{Value: 30, Unit: session.FrequencyMinutes}, + Enabled: true, + }, + wantHasPrompt: true, + wantPreviewPresent: false, + wantLoopConfigured: true, + }, + { + name: "pending placeholder prompt yields has_prompt=false and no preview", + loop: &session.LoopPrompt{ + Prompt: "(pending)", + Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, + Enabled: false, + }, + // Prompt is "(pending)" so PromptPreview() returns ""; but Prompt != "" so has_prompt is true. + wantHasPrompt: true, + wantPreviewPresent: false, + wantLoopConfigured: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := BuildLoopUpdatedData("sess-123", tt.loop) + + // loop_configured + configured, _ := data["loop_configured"].(bool) + if configured != tt.wantLoopConfigured { + t.Errorf("loop_configured = %v, want %v", configured, tt.wantLoopConfigured) + } + + // loop_has_prompt + hasPrompt, hasKey := data["loop_has_prompt"].(bool) + if !hasKey { + hasPrompt = false + } + if hasPrompt != tt.wantHasPrompt { + t.Errorf("loop_has_prompt = %v, want %v", hasPrompt, tt.wantHasPrompt) + } + + // loop_prompt_preview + preview, previewPresent := data["loop_prompt_preview"].(string) + if previewPresent && preview == "" { + previewPresent = false + } + if previewPresent != tt.wantPreviewPresent { + t.Errorf("loop_prompt_preview present = %v (value=%q), want present=%v", + previewPresent, preview, tt.wantPreviewPresent) + } + if tt.wantPreviewPresent && preview == "" { + t.Errorf("loop_prompt_preview is empty, want non-empty") + } + }) + } +} diff --git a/internal/conversation/periodic_data.go b/internal/conversation/periodic_data.go deleted file mode 100644 index 9ee6229e..00000000 --- a/internal/conversation/periodic_data.go +++ /dev/null @@ -1,56 +0,0 @@ -package conversation - -import ( - "time" - - "github.com/inercia/mitto/internal/session" -) - -// BuildPeriodicUpdatedData constructs the WebSocket payload map for a periodic_updated event. -// periodic_configured: true if a periodic config exists (controls editor UI mode). -// periodic_enabled: true if periodic runs are active (controls sidebar category + clock icon). -func BuildPeriodicUpdatedData(sessionID string, periodic *session.PeriodicPrompt) map[string]interface{} { - data := map[string]interface{}{ - "session_id": sessionID, - } - - if periodic != nil { - // periodic_configured: true means the session is in periodic mode (shows periodic UI) - data["periodic_configured"] = true - // periodic_enabled: true means periodic runs are active (locked state) - data["periodic_enabled"] = periodic.Enabled - // fresh_context: true means each scheduled run starts with a clean agent context - data["fresh_context"] = periodic.FreshContext - data["max_iterations"] = periodic.MaxIterations - data["iteration_count"] = periodic.IterationCount - data["frequency"] = map[string]interface{}{ - "value": periodic.Frequency.Value, - "unit": periodic.Frequency.Unit, - } - if periodic.Frequency.At != "" { - data["frequency"].(map[string]interface{})["at"] = periodic.Frequency.At - } - if periodic.NextScheduledAt != nil && !periodic.NextScheduledAt.IsZero() { - data["next_scheduled_at"] = periodic.NextScheduledAt.Format(time.RFC3339) - } - if periodic.StoppedReason != "" { - data["periodic_stopped_reason"] = string(periodic.StoppedReason) - } - // Glance fields for conversation header display (trigger resolved via EffectiveTrigger - // so schedule loops always report "schedule", not the empty-string default). - data["trigger"] = string(periodic.EffectiveTrigger()) - data["delay_seconds"] = periodic.DelaySeconds - data["max_duration_seconds"] = periodic.MaxDurationSeconds - // Prompt presence flag and free-text preview for the selector UI. - data["periodic_has_prompt"] = periodic.Prompt != "" || periodic.PromptName != "" - if preview := periodic.PromptPreview(); preview != "" { - data["periodic_prompt_preview"] = preview - } - } else { - // No periodic config - session is not in periodic mode - data["periodic_configured"] = false - data["periodic_enabled"] = false - } - - return data -} diff --git a/internal/conversation/periodic_data_test.go b/internal/conversation/periodic_data_test.go deleted file mode 100644 index df9bb656..00000000 --- a/internal/conversation/periodic_data_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package conversation - -import ( - "testing" - - "github.com/inercia/mitto/internal/session" -) - -func TestBuildPeriodicUpdatedData_PromptFields(t *testing.T) { - tests := []struct { - name string - periodic *session.PeriodicPrompt - wantHasPrompt bool - wantPreviewPresent bool - wantPeriodicConfigured bool - }{ - { - name: "nil periodic yields no prompt fields", - periodic: nil, - wantHasPrompt: false, - wantPreviewPresent: false, - wantPeriodicConfigured: false, - }, - { - name: "free-text prompt yields has_prompt=true and non-empty preview", - periodic: &session.PeriodicPrompt{ - Prompt: "Run the nightly report\nSecond line", - Frequency: session.Frequency{Value: 1, Unit: session.FrequencyDays}, - Enabled: true, - }, - wantHasPrompt: true, - wantPreviewPresent: true, - wantPeriodicConfigured: true, - }, - { - name: "named-prompt-only config yields has_prompt=true but empty preview", - periodic: &session.PeriodicPrompt{ - PromptName: "my-workspace-prompt", - Frequency: session.Frequency{Value: 30, Unit: session.FrequencyMinutes}, - Enabled: true, - }, - wantHasPrompt: true, - wantPreviewPresent: false, - wantPeriodicConfigured: true, - }, - { - name: "pending placeholder prompt yields has_prompt=false and no preview", - periodic: &session.PeriodicPrompt{ - Prompt: "(pending)", - Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, - Enabled: false, - }, - // Prompt is "(pending)" so PromptPreview() returns ""; but Prompt != "" so has_prompt is true. - wantHasPrompt: true, - wantPreviewPresent: false, - wantPeriodicConfigured: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := BuildPeriodicUpdatedData("sess-123", tt.periodic) - - // periodic_configured - configured, _ := data["periodic_configured"].(bool) - if configured != tt.wantPeriodicConfigured { - t.Errorf("periodic_configured = %v, want %v", configured, tt.wantPeriodicConfigured) - } - - // periodic_has_prompt - hasPrompt, hasKey := data["periodic_has_prompt"].(bool) - if !hasKey { - hasPrompt = false - } - if hasPrompt != tt.wantHasPrompt { - t.Errorf("periodic_has_prompt = %v, want %v", hasPrompt, tt.wantHasPrompt) - } - - // periodic_prompt_preview - preview, previewPresent := data["periodic_prompt_preview"].(string) - if previewPresent && preview == "" { - previewPresent = false - } - if previewPresent != tt.wantPreviewPresent { - t.Errorf("periodic_prompt_preview present = %v (value=%q), want present=%v", - previewPresent, preview, tt.wantPreviewPresent) - } - if tt.wantPreviewPresent && preview == "" { - t.Errorf("periodic_prompt_preview is empty, want non-empty") - } - }) - } -} diff --git a/internal/conversation/prompt_dispatcher.go b/internal/conversation/prompt_dispatcher.go index d272a8d4..8f42f815 100644 --- a/internal/conversation/prompt_dispatcher.go +++ b/internal/conversation/prompt_dispatcher.go @@ -114,10 +114,11 @@ type promptDeps interface { // === New in 2.5-d: post-prompt completion helpers === // Token usage bookkeeping - pdSetLastUsage(usage *acp.Usage) // lastUsageMu.Lock + lastUsage = usage + Unlock - pdAccumulateTokenUsage(tokens int) // processorManager.AccumulateTokenUsage - pdEstimateTokensFromMessage(msg string) int // processors.EstimateTokens(msg) - pdReadLastAgentMessage() string // ReadEvents + GetLastAgentMessage; returns "" on any error + pdSetLastUsage(usage *acp.Usage) // lastUsageMu.Lock + lastUsage = usage + Unlock + pdAccumulateTokenUsage(tokens int) // processorManager.AccumulateTokenUsage + pdAccumulateCumulativeUsage(usage *acp.Usage) // adds usage into cumulative in-memory counters + pdEstimateTokensFromMessage(msg string) int // processors.EstimateTokens(msg) + pdReadLastAgentMessage() string // ReadEvents + GetLastAgentMessage; returns "" on any error // Streaming state completion (promptMu critical section) pdMarkPromptComplete() // promptMu: isPrompting=false, promptStartTime=time.Time{}, lastResponseComplete=time.Now(), Broadcast @@ -156,7 +157,7 @@ type promptDeps interface { pdRestartACPProcess() error // bakes in RestartReasonCrashDuringStream pdReacquirePromptingState() // promptMu: isPrompting=true, promptStartTime=now, Unlock - // === New in mitto-2tm: in-place context flush for FreshContext periodic runs === + // === New in mitto-2tm: in-place context flush for FreshContext loop runs === // pdContextFlushCommand returns the agent-native context-flush command (e.g. "/clear") // configured for this session's ACP server, or "" when the feature is not configured. @@ -171,21 +172,12 @@ type promptDeps interface { type promptDispatcher struct{} // SenderID sentinels for non-human dispatch paths: queued messages (which include -// MCP cross-session sends via mitto_conversation_send_prompt) and periodic runs. +// MCP cross-session sends via mitto_conversation_send_prompt) and loop runs. const ( - senderIDQueue = "queue" - senderIDPeriodic = "periodic-runner" + senderIDQueue = "queue" + senderIDLoop = "loop-runner" ) -// isAutomatedDispatch reports whether a prompt originates from an automated / -// cross-session dispatch path (queue or periodic runner) rather than direct human -// input. Automated free-text dispatches fail-closed on a template parse error so a -// broken, unrenderable body is never silently delivered raw to a child that cannot -// act on it — that cascaded into a 10m child-wait timeout (mitto-e7u). -func isAutomatedDispatch(senderID string) bool { - return senderID == senderIDQueue || senderID == senderIDPeriodic -} - // resolveAndSubstitute covers the top of PromptWithMeta: // 1. Name-resolution: if meta.PromptName != "" && message == "", resolve via promptResolver // (error if no resolver, or if resolution fails). @@ -277,15 +269,20 @@ func (p promptDispatcher) resolveAndSubstitute(d promptDeps, message string, met } rendered, rerr := config.RenderPromptTemplate(name, message, tctx, funcs) if rerr != nil { - // Named prompts always fail-closed. Automated/cross-session dispatches - // (queue, periodic-runner) also fail-closed: a broken template body must - // not be silently delivered raw to a child that cannot act on it — that - // cascaded into a 10m child-wait timeout (mitto-e7u). Direct human input - // keeps fail-open so pasted text containing {{ is delivered literally. - if meta.PromptName != "" || isAutomatedDispatch(meta.SenderID) { + // Named prompts and loop-runner dispatches always fail-closed. Queue + // dispatches now distinguish origin: agent-originated (cross-session/MCP + // sends via mitto_conversation_send_prompt, or MCP-created initial + // prompts) fail-closed so a broken template body is never silently + // delivered raw to a child that cannot act on it — that cascaded into a + // 10m child-wait timeout (mitto-e7u). User-originated queue dispatches + // (human-typed free text that got queued) fail-open like direct human + // input, so pasted text containing {{ is delivered literally (mitto-nvb). + failClosed := meta.PromptName != "" || meta.SenderID == senderIDLoop || + (meta.SenderID == senderIDQueue && meta.QueueOrigin == session.QueueOriginAgent) + if failClosed { return "", 0, meta, rerr } - // free-text (direct human input): fail-open — warn and deliver raw message + // free-text (direct human input, or user-originated queue dispatch): fail-open — warn and deliver raw message if l := d.pdLogger(); l != nil { l.Warn("free-text template render failed, delivering raw message", "session_id", d.pdSessionID(), @@ -514,8 +511,8 @@ func (p promptDispatcher) buildProcessorInput(d promptDeps, message string, isFi AvailableACPServers: d.pdAvailableACPServers(), ChildSessions: childSessions, MCPToolNames: mcpToolNames, - IsPeriodic: meta.SenderID == senderIDPeriodic, - IsPeriodicForced: meta.IsPeriodicForced, + IsLoop: meta.SenderID == senderIDLoop, + IsLoopForced: meta.IsLoopForced, IterationNumber: meta.IterationNumber, MaxIterations: meta.MaxIterations, IterationUninterrupted: meta.IterationUninterrupted, @@ -668,13 +665,13 @@ func (p promptDispatcher) completeHandshakeOrAbort(d promptDeps) bool { return false } -// createFreshContextSession prepares a fresh context for a FreshContext periodic run. +// createFreshContextSession prepares a fresh context for a FreshContext loop run. // // When a contextFlushCommand is configured for the ACP server, it performs an // in-place flush (sends the command on the existing session with streaming suppressed) // rather than creating a new ACP session. This works for both direct-conn and // shared-process sessions. The flush is best-effort: errors are logged as warnings -// but never abort the main periodic prompt. Returns "" in this path — the main +// but never abort the main loop prompt. Returns "" in this path — the main // Prompt() continues on the existing session. // // When no flush command is configured, falls back to the original NewSession path @@ -692,7 +689,7 @@ func (p promptDispatcher) createFreshContextSession(d promptDeps, meta PromptMet flushCancel() if err == nil { if l := d.pdLogger(); l != nil { - l.Info("In-place context flush succeeded for periodic FreshContext run", + l.Info("In-place context flush succeeded for loop FreshContext run", "session_id", d.pdSessionID()) } } else { @@ -719,7 +716,7 @@ func (p promptDispatcher) createFreshContextSession(d promptDeps, meta PromptMet freshCancel() if err == nil { if l := d.pdLogger(); l != nil { - l.Info("Created fresh ACP session for periodic run", + l.Info("Created fresh ACP session for loop run", "fresh_session_id", sessID, "session_id", d.pdSessionID()) } @@ -822,6 +819,7 @@ func (p promptDispatcher) applyModelPreference(d promptDeps, meta PromptMeta) { func (p promptDispatcher) accumulateTokenUsage(d promptDeps, promptResp acp.PromptResponse, message string) { if promptResp.Usage != nil { d.pdSetLastUsage(promptResp.Usage) + d.pdAccumulateCumulativeUsage(promptResp.Usage) } if !d.pdHasProcessorManager() { @@ -948,7 +946,7 @@ func (p promptDispatcher) finalizeTurn(d promptDeps, err error, meta PromptMeta, meta.OnComplete(err) } - // Notify the on-completion periodic hook once the agent has stopped and the + // Notify the on-completion loop hook once the agent has stopped and the // session is fully idle. if sessionIdle { d.pdOnTurnIdle() diff --git a/internal/conversation/prompt_dispatcher_test.go b/internal/conversation/prompt_dispatcher_test.go index f15a7996..9af5fe4b 100644 --- a/internal/conversation/prompt_dispatcher_test.go +++ b/internal/conversation/prompt_dispatcher_test.go @@ -86,6 +86,7 @@ type fakePromptDeps struct { // === New in 2.5-d === lastUsageSet *acp.Usage + cumulativeUsageSet []*acp.Usage accumulatedTokens []int estimatedTokenCalls []string // messages passed to pdEstimateTokensFromMessage lastAgentMessage string // returned by pdReadLastAgentMessage / pdReadLastAgentMessageFromStore @@ -308,6 +309,11 @@ func (f *fakePromptDeps) pdAccumulateTokenUsage(tokens int) { defer f.mu.Unlock() f.accumulatedTokens = append(f.accumulatedTokens, tokens) } +func (f *fakePromptDeps) pdAccumulateCumulativeUsage(usage *acp.Usage) { + f.mu.Lock() + defer f.mu.Unlock() + f.cumulativeUsageSet = append(f.cumulativeUsageSet, usage) +} func (f *fakePromptDeps) pdEstimateTokensFromMessage(msg string) int { f.mu.Lock() defer f.mu.Unlock() @@ -674,18 +680,27 @@ func TestResolveAndSubstitute_FreeText_InvalidTemplate_FailOpen(t *testing.T) { // TestResolveAndSubstitute_AutomatedDispatch_InvalidTemplate_FailClosed verifies // that a free-text body with unbalanced template syntax dispatched via an automated -// path (queue / periodic-runner) fails CLOSED — it returns a non-nil error instead -// of silently delivering the raw, unrenderable body to a child (mitto-e7u). +// path (agent-originated queue dispatch / loop-runner) fails CLOSED — it returns a +// non-nil error instead of silently delivering the raw, unrenderable body to a +// child (mitto-e7u). func TestResolveAndSubstitute_AutomatedDispatch_InvalidTemplate_FailClosed(t *testing.T) { p := promptDispatcher{} body := "{{ if .Broken }}" // unbalanced action -> "unexpected EOF" - for _, senderID := range []string{senderIDQueue, senderIDPeriodic} { - t.Run(senderID, func(t *testing.T) { + cases := []struct { + name string + meta PromptMeta + }{ + {name: senderIDQueue, meta: PromptMeta{SenderID: senderIDQueue, QueueOrigin: session.QueueOriginAgent}}, + {name: senderIDLoop, meta: PromptMeta{SenderID: senderIDLoop}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { d := newFakePromptDeps() - msg, _, _, err := p.resolveAndSubstitute(d, body, PromptMeta{SenderID: senderID}) + msg, _, _, err := p.resolveAndSubstitute(d, body, tc.meta) if err == nil { - t.Fatalf("expected non-nil error for automated dispatch (sender=%q) with invalid template, got msg=%q", senderID, msg) + t.Fatalf("expected non-nil error for automated dispatch (sender=%q) with invalid template, got msg=%q", tc.name, msg) } if msg != "" { t.Fatalf("expected empty message on fail-closed, got %q", msg) @@ -694,6 +709,38 @@ func TestResolveAndSubstitute_AutomatedDispatch_InvalidTemplate_FailClosed(t *te } } +// TestResolveAndSubstitute_QueueUserOrigin_InvalidTemplate_FailOpen verifies that a +// queue dispatch ORIGINATING FROM A HUMAN (QueueOrigin == user, or empty for +// backward compatibility) fails OPEN on an invalid template body — the raw text is +// delivered verbatim instead of being dropped (mitto-nvb). Only agent-originated +// queue dispatches (cross-session/MCP) fail closed. +func TestResolveAndSubstitute_QueueUserOrigin_InvalidTemplate_FailOpen(t *testing.T) { + p := promptDispatcher{} + body := "{{ if .Broken }}" // unbalanced action -> "unexpected EOF" + + cases := []struct { + name string + queueOrigin string + }{ + {name: "explicit_user_origin", queueOrigin: session.QueueOriginUser}, + {name: "empty_origin_backward_compat", queueOrigin: ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := newFakePromptDeps() + meta := PromptMeta{SenderID: senderIDQueue, QueueOrigin: tc.queueOrigin} + msg, _, _, err := p.resolveAndSubstitute(d, body, meta) + if err != nil { + t.Fatalf("expected nil error for user-origin queue dispatch with invalid template, got: %v", err) + } + if msg != body { + t.Fatalf("expected raw body byte-for-byte, got %q", msg) + } + }) + } +} + // --- buildAttachmentBlocks tests --- func TestPromptDispatcher_BuildAttachmentBlocks_NoStore(t *testing.T) { @@ -842,8 +889,8 @@ func TestPromptDispatcher_BuildProcessorInput_NoStore_MinimalInput(t *testing.T) if input.IsFirstMessage { t.Fatal("expected IsFirstMessage=false") } - if input.IsPeriodic { - t.Fatal("expected IsPeriodic=false for non-periodic sender") + if input.IsLoop { + t.Fatal("expected IsLoop=false for non-loop sender") } // Store-dependent fields must be empty if input.SessionName != "" || input.ParentSessionID != "" || input.UserDataJSON != "" { @@ -868,7 +915,7 @@ func TestPromptDispatcher_BuildProcessorInput_WithMetadata(t *testing.T) { d.childPrompting["child-1"] = true d.mcpToolNames = []string{"tool_a", "tool_b"} - input := p.buildProcessorInput(d, "test", true, PromptMeta{SenderID: "periodic-runner"}) + input := p.buildProcessorInput(d, "test", true, PromptMeta{SenderID: "loop-runner"}) if input.SessionName != "My Session" { t.Fatalf("expected SessionName='My Session', got %q", input.SessionName) @@ -885,23 +932,23 @@ func TestPromptDispatcher_BuildProcessorInput_WithMetadata(t *testing.T) { if len(input.MCPToolNames) != 2 { t.Fatalf("expected 2 MCP tool names, got %v", input.MCPToolNames) } - if !input.IsPeriodic { - t.Fatal("expected IsPeriodic=true for periodic-runner sender") + if !input.IsLoop { + t.Fatal("expected IsLoop=true for loop-runner sender") } if input.BeadsIssue != "mitto-123" { t.Fatalf("expected BeadsIssue='mitto-123', got %q", input.BeadsIssue) } } -func TestPromptDispatcher_BuildProcessorInput_IsPeriodicForced(t *testing.T) { +func TestPromptDispatcher_BuildProcessorInput_IsLoopForced(t *testing.T) { p := promptDispatcher{} d := newFakePromptDeps() d.hasStore = false - meta := PromptMeta{IsPeriodicForced: true} + meta := PromptMeta{IsLoopForced: true} input := p.buildProcessorInput(d, "msg", false, meta) - if !input.IsPeriodicForced { - t.Fatal("expected IsPeriodicForced=true") + if !input.IsLoopForced { + t.Fatal("expected IsLoopForced=true") } } diff --git a/internal/conversation/queue_dispatcher.go b/internal/conversation/queue_dispatcher.go index 77df75d0..c6040606 100644 --- a/internal/conversation/queue_dispatcher.go +++ b/internal/conversation/queue_dispatcher.go @@ -156,11 +156,12 @@ func (queueDispatcher) send(d queueDeps, queue *session.Queue, msg session.Queue o.OnQueueUpdated(queueLen, "removed", msg.ID) }) meta := PromptMeta{ - SenderID: "queue", - PromptID: msg.ID, - ImageIDs: msg.ImageIDs, - Arguments: msg.Arguments, - PromptName: msg.PromptName, + SenderID: "queue", + PromptID: msg.ID, + ImageIDs: msg.ImageIDs, + Arguments: msg.Arguments, + PromptName: msg.PromptName, + QueueOrigin: msg.Origin, } if err := d.promptWithMeta(msg.Message, meta); err != nil { if lg := d.queueLogger(); lg != nil { diff --git a/internal/conversation/session_info.go b/internal/conversation/session_info.go index 602a9199..a23d7425 100644 --- a/internal/conversation/session_info.go +++ b/internal/conversation/session_info.go @@ -17,8 +17,8 @@ type SessionInfo struct { // yet registered as observers (i.e., connected but haven't sent load_events). HasConnectedClients bool QueueLength int - // NextPeriodicAt is when the next periodic prompt is due (nil = no periodic config). - NextPeriodicAt *time.Time + // NextLoopAt is when the next loop prompt is due (nil = no loop config). + NextLoopAt *time.Time // ResumedAt is when the session was last started/resumed. Used by GC to give // freshly resumed sessions a grace period before considering them idle. ResumedAt time.Time @@ -31,7 +31,7 @@ type SessionInfo struct { LastActivityAt time.Time // LastResponseCompleteAt is when the agent last finished a turn (completed a // response). Unlike LastActivityAt (set at prompt start), this marks the END of - // work, making it the correct signal for the periodic-suspend grace window. + // work, making it the correct signal for the loop-suspend grace window. // Zero if the agent has not completed a response since the session was resumed. LastResponseCompleteAt time.Time } diff --git a/internal/conversation/session_manager.go b/internal/conversation/session_manager.go index 532866a1..31947e53 100644 --- a/internal/conversation/session_manager.go +++ b/internal/conversation/session_manager.go @@ -37,7 +37,7 @@ const ACPStartFailureThreshold = 3 // DefaultMaxMessagesPerSession is the default maximum number of messages to retain per session. // When exceeded, the oldest messages are automatically pruned after each new event is recorded. -// This prevents unbounded session growth (especially for periodic sessions) which can cause +// This prevents unbounded session growth (especially for loop sessions) which can cause // OOM crashes when many large sessions share a single ACP process. // Can be overridden via settings.json or .mitterc with "max_messages_per_session". // Set to 0 in settings to disable automatic pruning. @@ -165,7 +165,7 @@ type SessionManager struct { promptParametersResolver func(name, workingDir string) []config.PromptParameter // onConversationIdle is invoked when a session's agent stops and the session is - // idle. Wired to the periodic runner to drive event-driven on-completion firing. + // idle. Wired to the loop runner to drive event-driven on-completion firing. onConversationIdle func(sessionID string) // resumeSemaphore limits the number of sessions that can simultaneously resume their @@ -374,8 +374,19 @@ func (sm *SessionManager) createAutoChildren(parentBS *BackgroundSession, worksp continue } + // Resolve the child's initial model profile (mitto-9x8), if any. + var childModelConstraint *config.ACPServerConstraint + if child.ModelProfile != "" && sm.mittoConfig != nil { + if profile := sm.mittoConfig.FindModelProfile(child.ModelProfile); profile != nil && profile.Criteria != nil { + childModelConstraint = profile.Criteria + } else if sm.logger != nil { + sm.logger.Warn("Auto-child model profile not found or has no criteria; using ACP default", + "parent_session_id", parentID, "child_title", child.Title, "model_profile", child.ModelProfile) + } + } + // Resume the child session (start ACP process) - childBS, err := sm.ResumeSession(childID, child.Title, parentWorkingDir) + childBS, err := sm.ResumeSessionWithModelConstraint(childID, child.Title, parentWorkingDir, childModelConstraint) if err != nil { if sm.logger != nil { sm.logger.Error("Failed to start auto-child ACP process", @@ -396,6 +407,7 @@ func (sm *SessionManager) createAutoChildren(parentBS *BackgroundSession, worksp "child_session_id", childID, "child_title", child.Title, "child_acp_server", targetWS.ACPServer, + "child_model_profile", child.ModelProfile, "child_is_running", childBS != nil) } } @@ -595,6 +607,16 @@ func (sm *SessionManager) AddWorkspace(ws config.WorkspaceSettings) { // the workspaces will be persisted to disk. func (sm *SessionManager) RemoveWorkspace(uuid string) { sm.wsRegistry.RemoveWorkspace(uuid) + + // Tear down any event-driven MCP tool watchers for this workspace + // (mitto-sys.4): they hold persistent connections that must not outlive + // the workspace. + sm.mu.RLock() + auxMgr := sm.auxiliaryManager + sm.mu.RUnlock() + if auxMgr != nil { + auxMgr.StopMCPWatchers(uuid) + } } // HasWorkspaces returns true if there are any configured workspaces. @@ -690,8 +712,8 @@ func (sm *SessionManager) SetPromptParametersResolver(resolver func(name, workin } // SetOnConversationIdle registers the callback invoked when a session goes idle after -// a turn. It is wired to the periodic runner's OnConversationIdle to drive event-driven -// on-completion periodic firing. +// a turn. It is wired to the loop runner's OnConversationIdle to drive event-driven +// on-completion loop firing. func (sm *SessionManager) SetOnConversationIdle(cb func(sessionID string)) { sm.mu.Lock() defer sm.mu.Unlock() @@ -875,9 +897,9 @@ func (sm *SessionManager) BroadcastSessionRenamed(sessionID string, newName stri } } -// BroadcastPeriodicUpdated broadcasts a periodic_updated event to all connected clients. -// This is called when a session's periodic config changes (e.g., via MCP tools). -func (sm *SessionManager) BroadcastPeriodicUpdated(sessionID string, periodic *session.PeriodicPrompt) { +// BroadcastLoopUpdated broadcasts a loop_updated event to all connected clients. +// This is called when a session's loop config changes (e.g., via MCP tools). +func (sm *SessionManager) BroadcastLoopUpdated(sessionID string, loop *session.LoopPrompt) { sm.mu.RLock() em := sm.eventsManager sm.mu.RUnlock() @@ -886,10 +908,10 @@ func (sm *SessionManager) BroadcastPeriodicUpdated(sessionID string, periodic *s return } - em.Broadcast(WSMsgTypePeriodicUpdated, BuildPeriodicUpdatedData(sessionID, periodic)) + em.Broadcast(WSMsgTypeLoopUpdated, BuildLoopUpdatedData(sessionID, loop)) if sm.logger != nil { - sm.logger.Debug("Broadcast periodic updated", "session_id", sessionID, "clients", em.ClientCount()) + sm.logger.Debug("Broadcast loop updated", "session_id", sessionID, "clients", em.ClientCount()) } } @@ -1594,7 +1616,24 @@ func (sm *SessionManager) GetOrCreateSession(sessionID, workingDir string) (*Bac // on the server side as well. Otherwise, we create a new ACP connection and continue // using the same persisted session ID for recording. func (sm *SessionManager) ResumeSession(sessionID, sessionName, workingDir string) (*BackgroundSession, error) { - // Clear GC-suspended flag — any explicit resume (ensure_resumed, periodic runner, + return sm.resumeSessionWithConstraint(sessionID, sessionName, workingDir, nil) +} + +// ResumeSessionWithModelConstraint resumes an existing persisted session like ResumeSession, +// but additionally applies modelConstraint as a per-session override of the "model" +// auto-selection constraint (mitto-9x8). Used by auto-children to apply a per-child initial +// model profile. Pass nil to preserve the default ACP-server-derived model selection. +func (sm *SessionManager) ResumeSessionWithModelConstraint(sessionID, sessionName, workingDir string, modelConstraint *config.ACPServerConstraint) (*BackgroundSession, error) { + return sm.resumeSessionWithConstraint(sessionID, sessionName, workingDir, modelConstraint) +} + +// resumeSessionWithConstraint resumes an existing persisted session by creating a new ACP +// process. This is used when switching to an old conversation. If the agent supports session +// loading and we have a stored ACP session ID, we attempt to resume the ACP session +// on the server side as well. Otherwise, we create a new ACP connection and continue +// using the same persisted session ID for recording. +func (sm *SessionManager) resumeSessionWithConstraint(sessionID, sessionName, workingDir string, modelConstraint *config.ACPServerConstraint) (*BackgroundSession, error) { + // Clear GC-suspended flag — any explicit resume (ensure_resumed, loop runner, // queue processing) should allow the session to run. This must happen before the // "already running" check to avoid stale flags. if sm.acpProcessManager != nil { @@ -1777,7 +1816,7 @@ func (sm *SessionManager) ResumeSession(sessionID, sessionName, workingDir strin acpServer = rescueWs.ACPServer // Persist the rescued ACP server name so the next resume resolves // directly instead of re-rescuing (and re-emitting the orphaned WARN) - // on every periodic/queue sweep. Best-effort: a failure here does not + // on every loop/queue sweep. Best-effort: a failure here does not // block the resume itself. if store != nil { if err := store.UpdateMetadata(sessionID, func(m *session.Metadata) { @@ -1987,6 +2026,7 @@ func (sm *SessionManager) ResumeSession(sessionID, sessionName, workingDir strin APIPrefix: sm.apiPrefix, WorkspaceUUID: workspaceUUID, MittoConfig: sm.mittoConfig, // Pass config for default flags + ModelConstraintOverride: modelConstraint, // Per-child initial model profile override (mitto-9x8) AvailableACPServers: resumeAvailableServers, // Pre-computed workspace server list GlobalMCPServer: sm.mcpServer, AuxiliaryManager: sm.auxiliaryManager, @@ -2612,10 +2652,10 @@ func (sm *SessionManager) GetSessionInfoByWorkspace() map[string][]SessionInfo { continue } - var nextPeriodic *time.Time + var nextLoop *time.Time if sm.store != nil { - if p, err := sm.store.Periodic(bs.GetSessionID()).Get(); err == nil && p.Enabled { - nextPeriodic = p.NextScheduledAt + if p, err := sm.store.Loop(bs.GetSessionID()).Get(); err == nil && p.Enabled { + nextLoop = p.NextScheduledAt } } @@ -2632,7 +2672,7 @@ func (sm *SessionManager) GetSessionInfoByWorkspace() map[string][]SessionInfo { HasConnectedClients: bs.HasConnectedClients(), IsChild: bs.HasParent(), QueueLength: queueLen, - NextPeriodicAt: nextPeriodic, + NextLoopAt: nextLoop, ResumedAt: bs.StartedAt(), LastObserverRemovedAt: bs.LastObserverRemovedAt(), LastActivityAt: bs.LastActivityAt(), @@ -2660,7 +2700,7 @@ func (sm *SessionManager) CloseIdleSession(sessionID string) { sm.ClearCachedPlanState(sessionID) if bs != nil { - // Use a distinct reason for periodic suspensions so the frontend can show + // Use a distinct reason for loop suspensions so the frontend can show // a friendly "Session suspended" message instead of an error balloon. reason := "gc_idle" if sm.acpProcessManager != nil && sm.acpProcessManager.IsGCSuspended(sessionID) { diff --git a/internal/conversation/title_coordinator.go b/internal/conversation/title_coordinator.go index 64549489..edf4ceb1 100644 --- a/internal/conversation/title_coordinator.go +++ b/internal/conversation/title_coordinator.go @@ -38,7 +38,7 @@ func (titleCoordinator) needsTitle(d titleDeps) bool { // retryIfNeeded triggers async title generation if the session still has no title. // Called after prompt completion to catch failed initial attempts and prompts that // arrived via paths that don't trigger title generation (queue, MCP send_prompt, -// periodic prompts). +// loop prompts). func (c titleCoordinator) retryIfNeeded(d titleDeps, message string) { if !d.sessionHasNoTitle() { return @@ -55,13 +55,13 @@ func (c titleCoordinator) trigger(d titleDeps, message string) { c.retryIfNeeded(d, message) } -// triggerFromPeriodic chooses the best source text for title generation given a -// periodic-style draft. The inline prompt may be empty, whitespace, or the UI +// triggerFromLoop chooses the best source text for title generation given a +// loop-style draft. The inline prompt may be empty, whitespace, or the UI // placeholder "(pending)" — all three are treated as "no inline prompt". When only // promptName is meaningful, it is resolved to full text via the configured resolver; // on failure or when no resolver is configured, the bare prompt name is used as a // fallback. No-op when neither source yields any text. -func (c titleCoordinator) triggerFromPeriodic(d titleDeps, prompt, promptName string) { +func (c titleCoordinator) triggerFromLoop(d titleDeps, prompt, promptName string) { inline := strings.TrimSpace(prompt) if inline != "" && inline != "(pending)" { c.retryIfNeeded(d, inline) @@ -78,7 +78,7 @@ func (c titleCoordinator) triggerFromPeriodic(d titleDeps, prompt, promptName st } if err != nil { if lg := d.titleLogger(); lg != nil { - lg.Warn("Could not resolve periodic prompt name for title generation; falling back to name", + lg.Warn("Could not resolve loop prompt name for title generation; falling back to name", "prompt_name", name, "error", err) } } diff --git a/internal/conversation/title_coordinator_test.go b/internal/conversation/title_coordinator_test.go index 433a6e7d..9904f129 100644 --- a/internal/conversation/title_coordinator_test.go +++ b/internal/conversation/title_coordinator_test.go @@ -89,12 +89,12 @@ func TestTitleCoordinator_Trigger(t *testing.T) { }) } -func TestTitleCoordinator_TriggerFromPeriodic(t *testing.T) { +func TestTitleCoordinator_TriggerFromLoop(t *testing.T) { tc := titleCoordinator{} t.Run("usable inline used; resolver not consulted", func(t *testing.T) { d := &fakeTitleDeps{noTitle: true, configured: true} - tc.triggerFromPeriodic(d, "Real text here", "some-prompt") + tc.triggerFromLoop(d, "Real text here", "some-prompt") if len(d.started) != 1 || d.started[0] != "Real text here" { t.Fatalf("expected \"Real text here\", got %v", d.started) } @@ -105,7 +105,7 @@ func TestTitleCoordinator_TriggerFromPeriodic(t *testing.T) { t.Run("(pending) + resolver returns non-empty → use resolved", func(t *testing.T) { d := &fakeTitleDeps{noTitle: true, configured: true, resolved: "Resolved prompt text"} - tc.triggerFromPeriodic(d, "(pending)", "my-prompt") + tc.triggerFromLoop(d, "(pending)", "my-prompt") if len(d.started) != 1 || d.started[0] != "Resolved prompt text" { t.Fatalf("expected resolved text, got %v", d.started) } @@ -113,7 +113,7 @@ func TestTitleCoordinator_TriggerFromPeriodic(t *testing.T) { t.Run("(pending) + resolver error → use bare name", func(t *testing.T) { d := &fakeTitleDeps{noTitle: true, configured: true, resolveErr: errors.New("lookup failed")} - tc.triggerFromPeriodic(d, "(pending)", "my-prompt") + tc.triggerFromLoop(d, "(pending)", "my-prompt") if len(d.started) != 1 || d.started[0] != "my-prompt" { t.Fatalf("expected bare name \"my-prompt\", got %v", d.started) } @@ -121,7 +121,7 @@ func TestTitleCoordinator_TriggerFromPeriodic(t *testing.T) { t.Run("empty inline + no resolver configured → use bare name", func(t *testing.T) { d := &fakeTitleDeps{noTitle: true, configured: false} - tc.triggerFromPeriodic(d, "", "bare-prompt") + tc.triggerFromLoop(d, "", "bare-prompt") if len(d.started) != 1 || d.started[0] != "bare-prompt" { t.Fatalf("expected bare name, got %v", d.started) } @@ -129,7 +129,7 @@ func TestTitleCoordinator_TriggerFromPeriodic(t *testing.T) { t.Run("both empty → noop", func(t *testing.T) { d := &fakeTitleDeps{noTitle: true} - tc.triggerFromPeriodic(d, "", "") + tc.triggerFromLoop(d, "", "") if len(d.started) != 0 { t.Fatalf("expected no call, got %v", d.started) } @@ -137,7 +137,7 @@ func TestTitleCoordinator_TriggerFromPeriodic(t *testing.T) { t.Run("whitespace-only inline treated as empty → use bare name", func(t *testing.T) { d := &fakeTitleDeps{noTitle: true, configured: false} - tc.triggerFromPeriodic(d, " ", "the-prompt") + tc.triggerFromLoop(d, " ", "the-prompt") if len(d.started) != 1 || d.started[0] != "the-prompt" { t.Fatalf("expected bare name, got %v", d.started) } @@ -145,7 +145,7 @@ func TestTitleCoordinator_TriggerFromPeriodic(t *testing.T) { t.Run("resolver configured but returns empty, no err → fall back to bare name", func(t *testing.T) { d := &fakeTitleDeps{noTitle: true, configured: true, resolved: ""} - tc.triggerFromPeriodic(d, "(pending)", "my-prompt") + tc.triggerFromLoop(d, "(pending)", "my-prompt") if len(d.started) != 1 || d.started[0] != "my-prompt" { t.Fatalf("expected bare name, got %v", d.started) } diff --git a/internal/conversation/ws_events.go b/internal/conversation/ws_events.go index 5b7b44dc..11874206 100644 --- a/internal/conversation/ws_events.go +++ b/internal/conversation/ws_events.go @@ -14,8 +14,8 @@ const ( // WSMsgTypeSessionRenamed notifies that a session was renamed. WSMsgTypeSessionRenamed = "session_renamed" - // WSMsgTypePeriodicUpdated notifies that a session's periodic prompt state changed. - WSMsgTypePeriodicUpdated = "periodic_updated" + // WSMsgTypeLoopUpdated notifies that a session's loop prompt state changed. + WSMsgTypeLoopUpdated = "loop_updated" // WSMsgTypeSessionWaiting notifies that a session's waiting-for-children state changed. WSMsgTypeSessionWaiting = "session_waiting" diff --git a/internal/mcpdiscovery/backoff.go b/internal/mcpdiscovery/backoff.go new file mode 100644 index 00000000..1d83037e --- /dev/null +++ b/internal/mcpdiscovery/backoff.go @@ -0,0 +1,128 @@ +package mcpdiscovery + +import ( + "context" + "math" + "time" +) + +// BackoffPolicy configures a bounded exponential-backoff re-probe schedule +// for configured-but-unreachable MCP servers (docs/devel/mcp-tool-discovery.md, +// Q4, point 3). A server that starts up slowly (e.g. an npx-based stdio +// server, or a network server behind a warming-up proxy) is re-probed with +// increasing delays until it becomes reachable or MaxAttempts is exhausted, +// instead of being latched as permanently unreachable after a single probe. +type BackoffPolicy struct { + // Base is the delay before the first retry (i.e. before the 2nd probe). + Base time.Duration + // Factor is the multiplier applied per attempt (e.g. 2.0 doubles the + // delay each time). + Factor float64 + // Max caps each individual delay. Max<=0 means uncapped growth. + Max time.Duration + // MaxAttempts is the total number of probe attempts, including the + // first. MaxAttempts<=0 means unbounded (the caller's ctx is the only + // bound). + MaxAttempts int +} + +// DefaultBackoffPolicy returns the recommended production policy: 1s base +// delay, doubling each attempt, capped at 30s, up to 10 total attempts +// (spanning roughly Base*(2^MaxAttempts-1) ≈ 8.5 minutes worst case before +// hitting the cap, then linear at Max thereafter). +func DefaultBackoffPolicy() BackoffPolicy { + return BackoffPolicy{Base: time.Second, Factor: 2.0, Max: 30 * time.Second, MaxAttempts: 10} +} + +// nextDelay returns the delay before the probe following the given 0-based +// attempt index: attempt 0 is the delay before the 2nd probe (== Base), +// attempt n is Base*Factor^n, capped at Max when Max>0. It is pure and +// deterministic (no jitter) so callers/tests can assert exact schedules; +// jitter can be layered on top by callers if needed. Zero/negative Base +// falls back to 1s; Factor<1 falls back to 1.0 (no growth, flat retries). +func (p BackoffPolicy) nextDelay(attempt int) time.Duration { + base := p.Base + if base <= 0 { + base = time.Second + } + factor := p.Factor + if factor < 1 { + factor = 1.0 + } + + d := float64(base) * math.Pow(factor, float64(attempt)) + + if p.Max <= 0 { + if math.IsInf(d, 0) || math.IsNaN(d) || d > math.MaxInt64 { + return time.Duration(math.MaxInt64) + } + return time.Duration(d) + } + + if math.IsInf(d, 0) || math.IsNaN(d) || d > float64(p.Max) { + return p.Max + } + return time.Duration(d) +} + +// ProbeFunc probes a single MCP server and reports the outcome. It is the +// caller-supplied bridge to a concrete transport (e.g. a closure over +// DiscoverStdioServer/DiscoverNetworkServer bound to one agents.MCPServer). +type ProbeFunc func(ctx context.Context) ServerToolsResult + +// RetryUntilReachable repeatedly calls probe, applying policy's backoff +// schedule between attempts, until probe reports Reachable=true, ctx is +// cancelled, or policy.MaxAttempts is exhausted. A timeout/connect/list +// failure (Reachable=false) is NEVER treated as a negative result — it only +// schedules another retry; callers must treat a discovered=false return as +// "keep the last-known-good state, do not downgrade." onReachable is invoked +// exactly once, only on a genuine reachable result, and is never invoked on +// exhaustion or ctx cancellation. Returns the last ServerToolsResult probed +// (which is the reachable one iff the bool is true) and whether the server +// was discovered reachable. +func RetryUntilReachable(ctx context.Context, policy BackoffPolicy, probe ProbeFunc, onReachable func(ServerToolsResult)) (ServerToolsResult, bool) { + var res ServerToolsResult + attempt := 0 + + for { + if ctx.Err() != nil { + return res, false + } + + res = probe(ctx) + if res.Reachable { + if onReachable != nil { + onReachable(res) + } + return res, true + } + + attempt++ + if policy.MaxAttempts > 0 && attempt >= policy.MaxAttempts { + return res, false + } + + d := policy.nextDelay(attempt - 1) + timer := time.NewTimer(d) + select { + case <-ctx.Done(): + timer.Stop() + return res, false + case <-timer.C: + } + } +} + +// ScheduleRetries launches a fire-and-forget RetryUntilReachable goroutine +// for every unreachable result in results, using probeFor(server.Name) to +// build each server's ProbeFunc. Reachable results are skipped. All +// goroutines are bound to ctx and exit promptly on cancellation. +func ScheduleRetries(ctx context.Context, results []ServerToolsResult, policy BackoffPolicy, probeFor func(server string) ProbeFunc, onReachable func(ServerToolsResult)) { + for _, res := range results { + if res.Reachable { + continue + } + probe := probeFor(res.Server) + go RetryUntilReachable(ctx, policy, probe, onReachable) + } +} diff --git a/internal/mcpdiscovery/backoff_test.go b/internal/mcpdiscovery/backoff_test.go new file mode 100644 index 00000000..d1a9b563 --- /dev/null +++ b/internal/mcpdiscovery/backoff_test.go @@ -0,0 +1,215 @@ +package mcpdiscovery + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestBackoffPolicy_NextDelay_Schedule(t *testing.T) { + p := BackoffPolicy{Base: time.Second, Factor: 2.0, Max: 30 * time.Second} + + want := map[int]time.Duration{ + 0: time.Second, + 1: 2 * time.Second, + 2: 4 * time.Second, + 3: 8 * time.Second, + 4: 16 * time.Second, + 5: 30 * time.Second, // capped + 6: 30 * time.Second, // capped + } + for attempt, wantDelay := range want { + if got := p.nextDelay(attempt); got != wantDelay { + t.Errorf("nextDelay(%d) = %v, want %v", attempt, got, wantDelay) + } + } +} + +func TestBackoffPolicy_NextDelay_Guards(t *testing.T) { + t.Run("zero base falls back to 1s", func(t *testing.T) { + p := BackoffPolicy{Base: 0, Factor: 2.0, Max: 30 * time.Second} + if got := p.nextDelay(0); got != time.Second { + t.Errorf("nextDelay(0) = %v, want 1s", got) + } + }) + + t.Run("uncapped growth when Max<=0", func(t *testing.T) { + p := BackoffPolicy{Base: time.Second, Factor: 2.0, Max: 0} + got5 := p.nextDelay(5) + got6 := p.nextDelay(6) + if got5 >= got6 { + t.Errorf("expected uncapped growth: nextDelay(5)=%v should be < nextDelay(6)=%v", got5, got6) + } + if got5 != 32*time.Second { + t.Errorf("nextDelay(5) = %v, want 32s (uncapped)", got5) + } + }) +} + +func TestRetryUntilReachable_DiscoveredQuickly(t *testing.T) { + const unreachableCount = 3 + var calls int32 + var onReachableCalls int32 + var gotResult ServerToolsResult + + probe := func(ctx context.Context) ServerToolsResult { + n := atomic.AddInt32(&calls, 1) + if int(n) <= unreachableCount { + return ServerToolsResult{Server: "s", Err: errors.New("connect refused")} + } + return ServerToolsResult{Server: "s", Reachable: true, Tools: []string{"t1"}} + } + onReachable := func(r ServerToolsResult) { + atomic.AddInt32(&onReachableCalls, 1) + gotResult = r + } + + policy := BackoffPolicy{Base: time.Millisecond, Factor: 2.0, Max: 10 * time.Millisecond, MaxAttempts: 20} + start := time.Now() + res, discovered := RetryUntilReachable(context.Background(), policy, probe, onReachable) + elapsed := time.Since(start) + + if !discovered { + t.Fatalf("discovered = false, want true") + } + if !res.Reachable { + t.Fatalf("res.Reachable = false, want true") + } + if atomic.LoadInt32(&onReachableCalls) != 1 { + t.Errorf("onReachable called %d times, want 1", onReachableCalls) + } + if gotResult.Server != "s" || len(gotResult.Tools) != 1 { + t.Errorf("onReachable result = %+v, want Tools=[t1]", gotResult) + } + if got := atomic.LoadInt32(&calls); got != unreachableCount+1 { + t.Errorf("probe called %d times, want %d", got, unreachableCount+1) + } + if elapsed > time.Second { + t.Errorf("elapsed = %v, want a fast discovery (tiny Base), not a flat long wait", elapsed) + } +} + +func TestRetryUntilReachable_NoNegativeOnTimeout(t *testing.T) { + var calls int32 + var onReachableCalls int32 + + probe := func(ctx context.Context) ServerToolsResult { + atomic.AddInt32(&calls, 1) + return ServerToolsResult{Server: "s", Err: context.DeadlineExceeded} + } + onReachable := func(r ServerToolsResult) { + atomic.AddInt32(&onReachableCalls, 1) + } + + policy := BackoffPolicy{Base: time.Millisecond, Factor: 2.0, Max: 5 * time.Millisecond, MaxAttempts: 3} + res, discovered := RetryUntilReachable(context.Background(), policy, probe, onReachable) + + if discovered { + t.Fatalf("discovered = true, want false") + } + if res.Reachable { + t.Fatalf("res.Reachable = true, want false (unreachable/keep-last-known-good)") + } + if atomic.LoadInt32(&onReachableCalls) != 0 { + t.Errorf("onReachable called %d times, want 0", onReachableCalls) + } + if got := atomic.LoadInt32(&calls); got != 3 { + t.Errorf("probe called %d times, want 3 (MaxAttempts)", got) + } +} + +func TestRetryUntilReachable_ContextCancellation(t *testing.T) { + var calls int32 + var onReachableCalls int32 + ctx, cancel := context.WithCancel(context.Background()) + + probe := func(ctx context.Context) ServerToolsResult { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + cancel() // cancel right after the first unreachable probe + } + return ServerToolsResult{Server: "s", Err: errors.New("unreachable")} + } + onReachable := func(r ServerToolsResult) { + atomic.AddInt32(&onReachableCalls, 1) + } + + // Long Base so a real (non-cancelled) run would take a while; cancellation + // must return promptly instead of waiting out the delay. + policy := BackoffPolicy{Base: time.Hour, Factor: 2.0, Max: time.Hour, MaxAttempts: 0} + start := time.Now() + res, discovered := RetryUntilReachable(ctx, policy, probe, onReachable) + elapsed := time.Since(start) + + if discovered { + t.Fatalf("discovered = true, want false") + } + if res.Reachable { + t.Fatalf("res.Reachable = true, want false") + } + if atomic.LoadInt32(&onReachableCalls) != 0 { + t.Errorf("onReachable called %d times, want 0", onReachableCalls) + } + if elapsed > 5*time.Second { + t.Errorf("elapsed = %v, want prompt return on ctx cancellation, not waiting out Base", elapsed) + } +} + +func TestScheduleRetries_OnlyProbesUnreachable(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var probedMu sync.Mutex + probed := map[string]int{} + + var wg sync.WaitGroup + wg.Add(1) // "down" must reach onReachable exactly once + + results := []ServerToolsResult{ + {Server: "up", Reachable: true, Tools: []string{"already"}}, + {Server: "down", Err: errors.New("unreachable")}, + } + + probeFor := func(server string) ProbeFunc { + return func(ctx context.Context) ServerToolsResult { + probedMu.Lock() + probed[server]++ + n := probed[server] + probedMu.Unlock() + if n >= 2 { + return ServerToolsResult{Server: server, Reachable: true, Tools: []string{"recovered"}} + } + return ServerToolsResult{Server: server, Err: errors.New("still down")} + } + } + + var onReachableCalled int32 + onReachable := func(r ServerToolsResult) { + atomic.AddInt32(&onReachableCalled, 1) + wg.Done() + } + + policy := BackoffPolicy{Base: time.Millisecond, Factor: 2.0, Max: 5 * time.Millisecond, MaxAttempts: 10} + ScheduleRetries(ctx, results, policy, probeFor, onReachable) + + waitCh := make(chan struct{}) + go func() { wg.Wait(); close(waitCh) }() + select { + case <-waitCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for ScheduleRetries to discover the unreachable server") + } + + probedMu.Lock() + _, upProbed := probed["up"] + probedMu.Unlock() + if upProbed { + t.Errorf("probeFor(\"up\") was called, want reachable servers to be skipped entirely") + } + if atomic.LoadInt32(&onReachableCalled) != 1 { + t.Errorf("onReachable called %d times, want exactly 1", onReachableCalled) + } +} diff --git a/internal/mcpdiscovery/discovery.go b/internal/mcpdiscovery/discovery.go new file mode 100644 index 00000000..bcd0d14a --- /dev/null +++ b/internal/mcpdiscovery/discovery.go @@ -0,0 +1,323 @@ +// Package mcpdiscovery provides deterministic MCP tool discovery by +// connecting directly to configured MCP servers over the vendored +// modelcontextprotocol/go-sdk client, replacing the LLM-introspection +// fallback for servers that support it. See docs/devel/mcp-tool-discovery.md +// (Q1) for background. This package does not import internal/config or +// internal/web; callers map its results onto config types themselves. +package mcpdiscovery + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/inercia/mitto/internal/agents" +) + +// DefaultTimeout bounds a single server's tools/list probe (transport +// build + Connect + ListTools) when the caller passes timeout <= 0. Chosen +// within the 5-10s range from docs/devel/mcp-tool-discovery.md (Q1): long +// enough for a cold npx-based server, short enough to keep callers responsive. +const DefaultTimeout = 8 * time.Second + +// clientName/clientVersion identify Mitto to the MCP servers it probes. +const ( + clientName = "mitto" + clientVersion = "0.0.0" +) + +// ServerToolsResult is the outcome of probing a single MCP server for its +// tool list. +type ServerToolsResult struct { + // Server is the probed server's name (agents.MCPServer.Name). + Server string + // Tools contains the tool names returned by tools/list. Only meaningful + // when Reachable is true; a reachable server may legitimately have zero + // tools. + Tools []string + // Reachable is true iff Connect and ListTools both succeeded. + Reachable bool + // Err is non-nil on transport, connect, or list failure, or on timeout. + // Callers must treat this as configured-but-unreachable, never as a + // confirmed-empty result. + Err error +} + +// TransportFactory builds a transport for connecting to srv, plus a cleanup +// function the caller must invoke once done with the transport (may be a +// no-op, never nil). It exists as a seam so tests can inject in-memory +// transports instead of spawning real subprocesses. +type TransportFactory func(ctx context.Context, srv agents.MCPServer) (mcp.Transport, func(), error) + +// CommandTransportFactory is the production TransportFactory for stdio MCP +// servers: it builds an *mcp.CommandTransport wrapping srv.Command/srv.Args, +// with the environment inherited from the current process plus srv.Env +// merged in. Its cleanup is a no-op; the ClientSession.Close performed by +// DiscoverStdioServer tears down the subprocess. +func CommandTransportFactory(ctx context.Context, srv agents.MCPServer) (mcp.Transport, func(), error) { + cmd := exec.CommandContext(ctx, srv.Command, srv.Args...) + env := os.Environ() + for k, v := range srv.Env { + env = append(env, k+"="+v) + } + cmd.Env = env + return &mcp.CommandTransport{Command: cmd}, func() {}, nil +} + +// probeServer connects to a single MCP server (any transport, as built by +// factory) and lists its tools. It never panics: any transport/connect/list +// failure or timeout is reported via ServerToolsResult.Err with +// Reachable=false, distinct from a genuine reachable-but-empty tool list +// (Reachable=true, Tools=nil/empty). timeout bounds the whole probe; +// DefaultTimeout is used when timeout <= 0. factory must be non-nil; callers +// (DiscoverStdioServer, DiscoverNetworkServer) apply their own default. +func probeServer(ctx context.Context, srv agents.MCPServer, timeout time.Duration, factory TransportFactory) ServerToolsResult { + if timeout <= 0 { + timeout = DefaultTimeout + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + transport, cleanup, err := factory(ctx, srv) + if cleanup != nil { + defer cleanup() + } + if err != nil { + return ServerToolsResult{Server: srv.Name, Err: fmt.Errorf("mcpdiscovery: build transport for %q: %w", srv.Name, err)} + } + + client := mcp.NewClient(&mcp.Implementation{Name: clientName, Version: clientVersion}, nil) + sess, err := client.Connect(ctx, transport, nil) + if err != nil { + return ServerToolsResult{Server: srv.Name, Err: fmt.Errorf("mcpdiscovery: connect to %q: %w", srv.Name, err)} + } + defer sess.Close() + + res, err := sess.ListTools(ctx, &mcp.ListToolsParams{}) + if err != nil { + return ServerToolsResult{Server: srv.Name, Err: fmt.Errorf("mcpdiscovery: list tools for %q: %w", srv.Name, err)} + } + + names := make([]string, 0, len(res.Tools)) + for _, tool := range res.Tools { + names = append(names, tool.Name) + } + return ServerToolsResult{Server: srv.Name, Tools: names, Reachable: true} +} + +// DiscoverStdioServer connects to a single stdio MCP server and lists its +// tools. See probeServer for behavior details. factory defaults to +// CommandTransportFactory when nil. +func DiscoverStdioServer(ctx context.Context, srv agents.MCPServer, timeout time.Duration, factory TransportFactory) ServerToolsResult { + if factory == nil { + factory = CommandTransportFactory + } + return probeServer(ctx, srv, timeout, factory) +} + +// headerRoundTripper injects a fixed set of HTTP headers onto every outgoing +// request before delegating to base. It lets NetworkTransportFactory attach +// auth headers (agents.MCPServer.Headers) to network MCP probes even though the +// go-sdk transports expose only an *http.Client, not a header field. Per the +// http.RoundTripper contract it must not mutate the caller's request, so it +// clones the request (and its header map) before setting headers. +type headerRoundTripper struct { + base http.RoundTripper + headers map[string]string +} + +func (h headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + base := h.base + if base == nil { + base = http.DefaultTransport + } + if len(h.headers) == 0 { + return base.RoundTrip(req) + } + req = req.Clone(req.Context()) + for k, v := range h.headers { + req.Header.Set(k, v) + } + return base.RoundTrip(req) +} + +// httpClientForHeaders returns an *http.Client that injects headers on every +// request, or nil when there are no headers (so the transport falls back to the +// SDK default client and existing behavior is unchanged). +func httpClientForHeaders(headers map[string]string) *http.Client { + if len(headers) == 0 { + return nil + } + return &http.Client{Transport: headerRoundTripper{base: http.DefaultTransport, headers: headers}} +} + +// NetworkTransportFactory is the production TransportFactory for http/sse MCP +// servers. It builds an *mcp.SSEClientTransport when srv.URL's path ends in +// "/sse" (case-insensitive) — the conventional SSE endpoint suffix — and an +// *mcp.StreamableClientTransport (the modern default) otherwise. Cleanup is a +// no-op; ClientSession.Close (in probeServer) tears down the connection. +// NOTE: srv.Headers (mitto-sys.9) is applied to the network transport via a +// header-injecting *http.Client (httpClientForHeaders), so authed HTTP/SSE +// endpoints can be probed directly. srv.Env is stdio-only and is applied in +// CommandTransportFactory (subprocess env); it is not meaningful for HTTP and +// is not used here. +func NetworkTransportFactory(_ context.Context, srv agents.MCPServer) (mcp.Transport, func(), error) { + if srv.URL == "" { + return nil, nil, fmt.Errorf("mcpdiscovery: server %q has no URL", srv.Name) + } + + isSSE := strings.HasSuffix(strings.ToLower(srv.URL), "/sse") + if parsed, err := url.Parse(srv.URL); err == nil { + isSSE = strings.HasSuffix(strings.ToLower(parsed.Path), "/sse") + } + + client := httpClientForHeaders(srv.Headers) + if isSSE { + return &mcp.SSEClientTransport{Endpoint: srv.URL, HTTPClient: client}, func() {}, nil + } + return &mcp.StreamableClientTransport{Endpoint: srv.URL, HTTPClient: client}, func() {}, nil +} + +// DefaultTransportFactory dispatches to CommandTransportFactory for stdio +// servers (Command != "") and NetworkTransportFactory for network servers +// (URL != ""), erroring when a server has neither. It is the production +// factory for DiscoverServers, which probes mixed-transport server lists. +func DefaultTransportFactory(ctx context.Context, srv agents.MCPServer) (mcp.Transport, func(), error) { + switch { + case srv.Command != "": + return CommandTransportFactory(ctx, srv) + case srv.URL != "": + return NetworkTransportFactory(ctx, srv) + default: + return nil, nil, fmt.Errorf("mcpdiscovery: server %q has neither Command nor URL", srv.Name) + } +} + +// DiscoverNetworkServer connects to a single http/sse MCP server and lists +// its tools. See probeServer for behavior details. factory defaults to +// NetworkTransportFactory when nil. +func DiscoverNetworkServer(ctx context.Context, srv agents.MCPServer, timeout time.Duration, factory TransportFactory) ServerToolsResult { + if factory == nil { + factory = NetworkTransportFactory + } + return probeServer(ctx, srv, timeout, factory) +} + +// DiscoverStdioServers probes every stdio server in servers (srv.Command != +// "" — http/sse-only servers are skipped, see mitto-sys.3) and returns one +// ServerToolsResult per stdio server, in input order. Servers are probed +// sequentially, each isolated: one server's failure or timeout never aborts +// the others. factory defaults to CommandTransportFactory when nil. +func DiscoverStdioServers(ctx context.Context, servers []agents.MCPServer, timeout time.Duration, factory TransportFactory) []ServerToolsResult { + if factory == nil { + factory = CommandTransportFactory + } + var results []ServerToolsResult + for _, srv := range servers { + if srv.Command == "" { + continue + } + results = append(results, DiscoverStdioServer(ctx, srv, timeout, factory)) + } + return results +} + +// DiscoverNetworkServers probes every network server in servers (srv.URL != +// "" && srv.Command == "" — stdio servers are skipped) and returns one +// ServerToolsResult per network server, in input order. Servers are probed +// sequentially, each isolated: one server's failure or timeout never aborts +// the others. factory defaults to NetworkTransportFactory when nil. +func DiscoverNetworkServers(ctx context.Context, servers []agents.MCPServer, timeout time.Duration, factory TransportFactory) []ServerToolsResult { + if factory == nil { + factory = NetworkTransportFactory + } + var results []ServerToolsResult + for _, srv := range servers { + if srv.URL == "" || srv.Command != "" { + continue + } + results = append(results, DiscoverNetworkServer(ctx, srv, timeout, factory)) + } + return results +} + +// DiscoverServers probes every server in servers regardless of transport, +// skipping only servers with neither Command nor URL set. Servers are probed +// sequentially, each isolated: one server's failure or timeout never aborts +// the others. factory defaults to DefaultTransportFactory when nil, which +// dispatches each server to the appropriate stdio/network transport. +func DiscoverServers(ctx context.Context, servers []agents.MCPServer, timeout time.Duration, factory TransportFactory) []ServerToolsResult { + if factory == nil { + factory = DefaultTransportFactory + } + var results []ServerToolsResult + for _, srv := range servers { + if srv.Command == "" && srv.URL == "" { + continue + } + results = append(results, probeServer(ctx, srv, timeout, factory)) + } + return results +} + +// ServerLister abstracts agents.Manager.ListMCPServers so the workspace-level +// discovery bridge can be tested without a real agent or mcp-list.sh script. +// *agents.Manager satisfies it. +type ServerLister interface { + ListMCPServers(ctx context.Context, agentName string, input *agents.MCPListInput) (*agents.MCPListOutput, error) +} + +// Compile-time assertion that the production *agents.Manager satisfies +// ServerLister, so DiscoverWorkspaceStdioTools can be wired to it directly. +var _ ServerLister = (*agents.Manager)(nil) + +// DiscoverWorkspaceStdioTools resolves a workspace's configured MCP servers via +// lister.ListMCPServers(agentName, {Path: workspacePath}) — the same +// deterministic mcp-list.sh discovery the MCP handler uses — then probes the +// stdio ones with DiscoverStdioServers. The returned results cover only stdio +// servers (http/sse are skipped, see mitto-sys.3). An error is returned only +// when the server list itself cannot be obtained; per-server probe failures are +// reported in-band via ServerToolsResult (Reachable=false, Err set), never as a +// top-level error. +func DiscoverWorkspaceStdioTools(ctx context.Context, lister ServerLister, agentName, workspacePath string, timeout time.Duration, factory TransportFactory) ([]ServerToolsResult, error) { + if lister == nil { + return nil, fmt.Errorf("mcpdiscovery: nil server lister") + } + out, err := lister.ListMCPServers(ctx, agentName, &agents.MCPListInput{Path: workspacePath}) + if err != nil { + return nil, fmt.Errorf("mcpdiscovery: list mcp servers for agent %q: %w", agentName, err) + } + if out == nil { + return nil, nil + } + return DiscoverStdioServers(ctx, out.Servers, timeout, factory), nil +} + +// DiscoverWorkspaceTools resolves a workspace's configured MCP servers via +// lister.ListMCPServers(agentName, {Path: workspacePath}) — the same +// deterministic mcp-list.sh discovery the MCP handler uses — then probes ALL +// of them (stdio + http/sse) with DiscoverServers. An error is returned only +// when the server list itself cannot be obtained; per-server probe failures +// are reported in-band via ServerToolsResult (Reachable=false, Err set), +// never as a top-level error. +func DiscoverWorkspaceTools(ctx context.Context, lister ServerLister, agentName, workspacePath string, timeout time.Duration, factory TransportFactory) ([]ServerToolsResult, error) { + if lister == nil { + return nil, fmt.Errorf("mcpdiscovery: nil server lister") + } + out, err := lister.ListMCPServers(ctx, agentName, &agents.MCPListInput{Path: workspacePath}) + if err != nil { + return nil, fmt.Errorf("mcpdiscovery: list mcp servers for agent %q: %w", agentName, err) + } + if out == nil { + return nil, nil + } + return DiscoverServers(ctx, out.Servers, timeout, factory), nil +} diff --git a/internal/mcpdiscovery/discovery_test.go b/internal/mcpdiscovery/discovery_test.go new file mode 100644 index 00000000..db0efe8b --- /dev/null +++ b/internal/mcpdiscovery/discovery_test.go @@ -0,0 +1,480 @@ +package mcpdiscovery + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sort" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/inercia/mitto/internal/agents" +) + +// noopToolHandler is a tool handler that does nothing; only the tool's +// presence (its Name) matters for these tests. +func noopToolHandler(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil +} + +// newMockStdioServer starts an in-memory MCP server exposing toolNames and +// returns a TransportFactory that connects to it, plus a stop func to release +// the server goroutine. The server keeps running until stop is called (or the +// factory's own cleanup calls it after a probe). +func newMockStdioServer(t *testing.T, toolNames ...string) (TransportFactory, func()) { + t.Helper() + serverCtx, stop := context.WithCancel(context.Background()) + + srv := mcp.NewServer(&mcp.Implementation{Name: "mock", Version: "0"}, nil) + for _, name := range toolNames { + mcp.AddTool(srv, &mcp.Tool{Name: name, Description: "mock tool"}, noopToolHandler) + } + + clientT, serverT := mcp.NewInMemoryTransports() + go srv.Run(serverCtx, serverT) + + factory := func(_ context.Context, _ agents.MCPServer) (mcp.Transport, func(), error) { + return clientT, stop, nil + } + return factory, stop +} + +func TestDiscoverStdioServer_ReachableWithTools(t *testing.T) { + factory, stop := newMockStdioServer(t, "jira_create_issue", "jira_search") + defer stop() + + result := DiscoverStdioServer(context.Background(), agents.MCPServer{Name: "jira", Command: "unused"}, time.Second, factory) + + if !result.Reachable { + t.Fatalf("Reachable = false, want true (err=%v)", result.Err) + } + if result.Err != nil { + t.Errorf("Err = %v, want nil", result.Err) + } + if result.Server != "jira" { + t.Errorf("Server = %q, want %q", result.Server, "jira") + } + got := append([]string(nil), result.Tools...) + sort.Strings(got) + want := []string{"jira_create_issue", "jira_search"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("Tools = %v, want %v", result.Tools, want) + } +} + +func TestDiscoverStdioServer_ReachableEmpty(t *testing.T) { + factory, stop := newMockStdioServer(t) // no tools registered + defer stop() + + result := DiscoverStdioServer(context.Background(), agents.MCPServer{Name: "empty", Command: "unused"}, time.Second, factory) + + if !result.Reachable { + t.Fatalf("Reachable = false, want true (err=%v)", result.Err) + } + if len(result.Tools) != 0 { + t.Errorf("Tools = %v, want empty", result.Tools) + } +} + +func TestDiscoverStdioServer_Unreachable_TransportBuildError(t *testing.T) { + factory := func(_ context.Context, _ agents.MCPServer) (mcp.Transport, func(), error) { + return nil, nil, errors.New("boom") + } + + result := DiscoverStdioServer(context.Background(), agents.MCPServer{Name: "broken", Command: "unused"}, time.Second, factory) + + if result.Reachable { + t.Fatalf("Reachable = true, want false") + } + if result.Err == nil { + t.Fatalf("Err = nil, want non-nil") + } + if len(result.Tools) != 0 { + t.Errorf("Tools = %v, want empty", result.Tools) + } +} + +func TestDiscoverStdioServer_Unreachable_ConnectFailure(t *testing.T) { + // Uses the real production factory (nil -> CommandTransportFactory) + // against a command that cannot possibly exist, so Connect fails + // deterministically (exec: file not found) without any timeout wait. + srv := agents.MCPServer{Name: "nope", Command: "mitto-test-nonexistent-binary-xyz"} + + result := DiscoverStdioServer(context.Background(), srv, 2*time.Second, nil) + + if result.Reachable { + t.Fatalf("Reachable = true, want false") + } + if result.Err == nil { + t.Fatalf("Err = nil, want non-nil") + } + if len(result.Tools) != 0 { + t.Errorf("Tools = %v, want empty", result.Tools) + } +} + +func TestDiscoverStdioServers_MixedIsolatesFailuresAndSkipsNonStdio(t *testing.T) { + factory, stop := newMockStdioServer(t, "jira_create_issue") + defer stop() + + servers := []agents.MCPServer{ + {Name: "jira", Command: "unused"}, + {Name: "broken", Command: "mitto-test-nonexistent-binary-xyz"}, + {Name: "http-only", URL: "https://example.com/mcp"}, // not stdio: must be skipped + } + + mixedFactory := func(ctx context.Context, s agents.MCPServer) (mcp.Transport, func(), error) { + if s.Name == "jira" { + return factory(ctx, s) + } + return CommandTransportFactory(ctx, s) + } + + results := DiscoverStdioServers(context.Background(), servers, time.Second, mixedFactory) + + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2 (http-only must be skipped): %+v", len(results), results) + } + + byName := map[string]ServerToolsResult{} + for _, r := range results { + byName[r.Server] = r + } + + jira, ok := byName["jira"] + if !ok || !jira.Reachable || len(jira.Tools) != 1 || jira.Tools[0] != "jira_create_issue" { + t.Errorf("jira result = %+v, want Reachable=true Tools=[jira_create_issue]", jira) + } + + broken, ok := byName["broken"] + if !ok || broken.Reachable || broken.Err == nil { + t.Errorf("broken result = %+v, want Reachable=false with Err set", broken) + } +} + +// stubLister is a mock ServerLister that returns a fixed output/error and +// records the arguments it was called with. +type stubLister struct { + out *agents.MCPListOutput + err error + gotAgent string + gotInput *agents.MCPListInput + callCount int +} + +func (s *stubLister) ListMCPServers(_ context.Context, agentName string, input *agents.MCPListInput) (*agents.MCPListOutput, error) { + s.callCount++ + s.gotAgent = agentName + s.gotInput = input + return s.out, s.err +} + +func TestDiscoverWorkspaceStdioTools_ListsThenProbes(t *testing.T) { + factory, stop := newMockStdioServer(t, "jira_create_issue", "jira_search") + defer stop() + + lister := &stubLister{out: &agents.MCPListOutput{Servers: []agents.MCPServer{ + {Name: "jira", Command: "unused"}, + {Name: "http-only", URL: "https://example.com/mcp"}, // skipped: not stdio + }}} + + results, err := DiscoverWorkspaceStdioTools(context.Background(), lister, "auggie", "/ws/path", time.Second, factory) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if lister.callCount != 1 || lister.gotAgent != "auggie" { + t.Errorf("lister called %d times with agent %q, want 1 with %q", lister.callCount, lister.gotAgent, "auggie") + } + if lister.gotInput == nil || lister.gotInput.Path != "/ws/path" { + t.Errorf("lister input = %+v, want Path=/ws/path", lister.gotInput) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1 (http-only skipped): %+v", len(results), results) + } + if !results[0].Reachable || results[0].Server != "jira" { + t.Errorf("result = %+v, want Reachable=true Server=jira", results[0]) + } +} + +func TestDiscoverWorkspaceStdioTools_ListError(t *testing.T) { + lister := &stubLister{err: errors.New("mcp-list.sh failed")} + + results, err := DiscoverWorkspaceStdioTools(context.Background(), lister, "auggie", "/ws/path", time.Second, nil) + if err == nil { + t.Fatalf("err = nil, want non-nil (list failure must be a top-level error)") + } + if results != nil { + t.Errorf("results = %+v, want nil on list error", results) + } +} + +func TestDiscoverWorkspaceStdioTools_NilLister(t *testing.T) { + if _, err := DiscoverWorkspaceStdioTools(context.Background(), nil, "auggie", "/ws", time.Second, nil); err == nil { + t.Fatalf("err = nil, want non-nil for nil lister") + } +} + +// ============================================================================= +// http/sse transport discovery (mitto-sys.3) +// ============================================================================= + +func TestNetworkTransportFactory_SelectsSSEBySuffix(t *testing.T) { + transport, cleanup, err := NetworkTransportFactory(context.Background(), agents.MCPServer{Name: "s", URL: "https://example.com/sse"}) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + defer cleanup() + + sseT, ok := transport.(*mcp.SSEClientTransport) + if !ok { + t.Fatalf("transport = %T, want *mcp.SSEClientTransport", transport) + } + if sseT.Endpoint != "https://example.com/sse" { + t.Errorf("Endpoint = %q, want %q", sseT.Endpoint, "https://example.com/sse") + } +} + +func TestNetworkTransportFactory_SelectsStreamableByDefault(t *testing.T) { + transport, cleanup, err := NetworkTransportFactory(context.Background(), agents.MCPServer{Name: "s", URL: "https://example.com/mcp"}) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + defer cleanup() + + streamT, ok := transport.(*mcp.StreamableClientTransport) + if !ok { + t.Fatalf("transport = %T, want *mcp.StreamableClientTransport", transport) + } + if streamT.Endpoint != "https://example.com/mcp" { + t.Errorf("Endpoint = %q, want %q", streamT.Endpoint, "https://example.com/mcp") + } +} + +func TestNetworkTransportFactory_EmptyURL(t *testing.T) { + _, _, err := NetworkTransportFactory(context.Background(), agents.MCPServer{Name: "s"}) + if err == nil { + t.Fatalf("err = nil, want non-nil for empty URL") + } +} + +func TestDefaultTransportFactory_Dispatch(t *testing.T) { + t.Run("stdio", func(t *testing.T) { + transport, cleanup, err := DefaultTransportFactory(context.Background(), agents.MCPServer{Name: "s", Command: "echo"}) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + defer cleanup() + if _, ok := transport.(*mcp.CommandTransport); !ok { + t.Fatalf("transport = %T, want *mcp.CommandTransport", transport) + } + }) + + t.Run("network", func(t *testing.T) { + transport, cleanup, err := DefaultTransportFactory(context.Background(), agents.MCPServer{Name: "s", URL: "https://example.com/mcp"}) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + defer cleanup() + switch transport.(type) { + case *mcp.StreamableClientTransport, *mcp.SSEClientTransport: + // ok + default: + t.Fatalf("transport = %T, want a network transport", transport) + } + }) + + t.Run("neither", func(t *testing.T) { + _, _, err := DefaultTransportFactory(context.Background(), agents.MCPServer{Name: "s"}) + if err == nil { + t.Fatalf("err = nil, want non-nil when server has neither Command nor URL") + } + }) +} + +func TestDiscoverNetworkServer_ReachableWithTools(t *testing.T) { + factory, stop := newMockStdioServer(t, "web_search") + defer stop() + + result := DiscoverNetworkServer(context.Background(), agents.MCPServer{Name: "web", URL: "https://example.com/mcp"}, time.Second, factory) + + if !result.Reachable { + t.Fatalf("Reachable = false, want true (err=%v)", result.Err) + } + if len(result.Tools) != 1 || result.Tools[0] != "web_search" { + t.Errorf("Tools = %v, want [web_search]", result.Tools) + } +} + +func TestDiscoverNetworkServer_RealStreamableHTTP(t *testing.T) { + srv := mcp.NewServer(&mcp.Implementation{Name: "mock", Version: "0"}, nil) + mcp.AddTool(srv, &mcp.Tool{Name: "http_tool", Description: "mock http tool"}, noopToolHandler) + + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil) + ts := httptest.NewServer(handler) + defer ts.Close() + + result := DiscoverNetworkServer(context.Background(), agents.MCPServer{Name: "http", URL: ts.URL}, 5*time.Second, nil) + + if !result.Reachable { + t.Fatalf("Reachable = false, want true (err=%v)", result.Err) + } + if len(result.Tools) != 1 || result.Tools[0] != "http_tool" { + t.Errorf("Tools = %v, want [http_tool]", result.Tools) + } +} + +// TestDiscoverNetworkServer_RealSSE exercises a genuine SSE round-trip +// against mcp.NewSSEHandler. SSEHandler computes its session endpoint +// relative to the incoming request's own path (see mcp/sse.go ServeHTTP: +// it builds "?sessionid=..." off req.URL, and POSTs are routed by mux to +// whatever path the handler is mounted on), so mounting it at "/sse" and +// probing ts.URL+"/sse" makes the client hit the real code path for both +// endpoint-suffix detection (NetworkTransportFactory picks SSEClientTransport) +// and the SSE round-trip itself — no need to fall back to an in-memory-only +// test for SSE. +func TestDiscoverNetworkServer_RealSSE(t *testing.T) { + srv := mcp.NewServer(&mcp.Implementation{Name: "mock", Version: "0"}, nil) + mcp.AddTool(srv, &mcp.Tool{Name: "sse_tool", Description: "mock sse tool"}, noopToolHandler) + + handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return srv }, nil) + mux := http.NewServeMux() + mux.Handle("/sse", handler) + ts := httptest.NewServer(mux) + defer ts.Close() + + result := DiscoverNetworkServer(context.Background(), agents.MCPServer{Name: "sse", URL: ts.URL + "/sse"}, 5*time.Second, nil) + + if !result.Reachable { + t.Fatalf("Reachable = false, want true (err=%v)", result.Err) + } + if len(result.Tools) != 1 || result.Tools[0] != "sse_tool" { + t.Errorf("Tools = %v, want [sse_tool]", result.Tools) + } +} + +func TestDiscoverNetworkServer_AuthRequiredGracefulFailure(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + })) + defer ts.Close() + + result := DiscoverNetworkServer(context.Background(), agents.MCPServer{Name: "secured", URL: ts.URL}, 5*time.Second, nil) + + if result.Reachable { + t.Fatalf("Reachable = true, want false") + } + if result.Err == nil { + t.Fatalf("Err = nil, want non-nil") + } + if len(result.Tools) != 0 { + t.Errorf("Tools = %v, want empty", result.Tools) + } +} + +func TestDiscoverNetworkServer_HeaderProtected(t *testing.T) { + srv := mcp.NewServer(&mcp.Implementation{Name: "mock", Version: "0"}, nil) + mcp.AddTool(srv, &mcp.Tool{Name: "secured_tool", Description: "mock http tool"}, noopToolHandler) + mcpHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil) + + const wantAuth = "Bearer test-token" // dummy test token, not a real secret + gated := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != wantAuth { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + mcpHandler.ServeHTTP(w, r) + }) + ts := httptest.NewServer(gated) + defer ts.Close() + + // Without headers -> gate returns 401 -> unreachable. + noHdr := DiscoverNetworkServer(context.Background(), agents.MCPServer{Name: "secured", URL: ts.URL}, 5*time.Second, nil) + if noHdr.Reachable { + t.Fatalf("without headers: Reachable = true, want false (err=%v)", noHdr.Err) + } + + // With correct headers -> gate passes -> reachable, tool listed. + withHdr := DiscoverNetworkServer(context.Background(), agents.MCPServer{ + Name: "secured", + URL: ts.URL, + Headers: map[string]string{"Authorization": wantAuth}, + }, 5*time.Second, nil) + if !withHdr.Reachable { + t.Fatalf("with headers: Reachable = false, want true (err=%v)", withHdr.Err) + } + if len(withHdr.Tools) != 1 || withHdr.Tools[0] != "secured_tool" { + t.Errorf("Tools = %v, want [secured_tool]", withHdr.Tools) + } +} + +func TestDiscoverServers_MixedTransportsSkipsNeither(t *testing.T) { + stdioFactory, stopStdio := newMockStdioServer(t, "stdio_tool") + defer stopStdio() + netFactory, stopNet := newMockStdioServer(t, "net_tool") + defer stopNet() + + servers := []agents.MCPServer{ + {Name: "stdio-srv", Command: "unused"}, + {Name: "net-srv", URL: "https://example.com/mcp"}, + {Name: "neither-srv"}, // no Command, no URL: must be skipped + } + + mixedFactory := func(ctx context.Context, s agents.MCPServer) (mcp.Transport, func(), error) { + if s.Name == "stdio-srv" { + return stdioFactory(ctx, s) + } + return netFactory(ctx, s) + } + + results := DiscoverServers(context.Background(), servers, time.Second, mixedFactory) + + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2 (neither-srv must be skipped): %+v", len(results), results) + } + for _, r := range results { + if !r.Reachable { + t.Errorf("result for %q: Reachable = false, want true (err=%v)", r.Server, r.Err) + } + } +} + +func TestDiscoverWorkspaceTools_ProbesBothStdioAndNetwork(t *testing.T) { + stdioFactory, stopStdio := newMockStdioServer(t, "stdio_tool") + defer stopStdio() + netFactory, stopNet := newMockStdioServer(t, "net_tool") + defer stopNet() + + lister := &stubLister{out: &agents.MCPListOutput{Servers: []agents.MCPServer{ + {Name: "jira", Command: "unused"}, + {Name: "http-only", URL: "https://example.com/mcp"}, + }}} + + mixedFactory := func(ctx context.Context, s agents.MCPServer) (mcp.Transport, func(), error) { + if s.Name == "jira" { + return stdioFactory(ctx, s) + } + return netFactory(ctx, s) + } + + results, err := DiscoverWorkspaceTools(context.Background(), lister, "auggie", "/ws/path", time.Second, mixedFactory) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2 (both stdio and network probed): %+v", len(results), results) + } + + byName := map[string]ServerToolsResult{} + for _, r := range results { + byName[r.Server] = r + } + if jira, ok := byName["jira"]; !ok || !jira.Reachable { + t.Errorf("jira result = %+v, want Reachable=true", jira) + } + if httpOnly, ok := byName["http-only"]; !ok || !httpOnly.Reachable { + t.Errorf("http-only result = %+v, want Reachable=true", httpOnly) + } +} diff --git a/internal/mcpdiscovery/watch.go b/internal/mcpdiscovery/watch.go new file mode 100644 index 00000000..c30e7e26 --- /dev/null +++ b/internal/mcpdiscovery/watch.go @@ -0,0 +1,122 @@ +package mcpdiscovery + +import ( + "context" + "fmt" + "sync" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/inercia/mitto/internal/agents" +) + +// ToolListWatcher holds a persistent MCP client session that has registered +// for notifications/tools/list_changed (mitto-sys.4, event-driven tool +// refresh — the alternative to the bounded-backoff polling primitive in +// backoff.go). Unlike probeServer's one-shot connect/list/close, the session +// is kept open for the watcher's lifetime; callers must Close it when done. +type ToolListWatcher struct { + sess *mcp.ClientSession + cleanup func() + closeOnce sync.Once + closeErr error +} + +// Close closes the underlying persistent session (and runs the transport's +// cleanup, if any). It is idempotent and concurrency-safe: only the first +// call actually closes the session, and its error is the one returned by +// all calls. +func (w *ToolListWatcher) Close() error { + w.closeOnce.Do(func() { + w.closeErr = w.sess.Close() + if w.cleanup != nil { + w.cleanup() + } + }) + return w.closeErr +} + +// WatchServer connects to srv and keeps the session open, invoking onChange +// with a freshly re-listed ServerToolsResult every time the server emits a +// notifications/tools/list_changed notification. onChange may be nil (the +// notification is still handled, just not forwarded). factory defaults to +// DefaultTransportFactory when nil, matching DiscoverServers. +// +// The initial tools/list result is returned directly as the second return +// value, NOT via onChange — onChange only fires for subsequent changes. On +// any transport-build/connect/initial-list failure, any partially-opened +// session is closed, and a nil watcher plus an unreachable ServerToolsResult +// and a non-nil error are returned. +// +// The notification handler runs on the SDK's read-loop goroutine; it derives +// a bounded context (DefaultTimeout) from ctx for its own re-list call, so a +// slow/hanging server cannot block the read loop indefinitely. +func WatchServer(ctx context.Context, srv agents.MCPServer, factory TransportFactory, onChange func(ServerToolsResult)) (*ToolListWatcher, ServerToolsResult, error) { + if factory == nil { + factory = DefaultTransportFactory + } + + transport, cleanup, err := factory(ctx, srv) + if err != nil { + if cleanup != nil { + cleanup() + } + wrapped := fmt.Errorf("mcpdiscovery: build transport for %q: %w", srv.Name, err) + return nil, ServerToolsResult{Server: srv.Name, Err: wrapped}, wrapped + } + + // cleanup (if any) is deferred to Close, NOT run here: the transport/ + // session must stay open for the watcher's lifetime, unlike probeServer's + // one-shot connect/list/close. + watcher := &ToolListWatcher{cleanup: cleanup} + + opts := &mcp.ClientOptions{ + ToolListChangedHandler: func(_ context.Context, _ *mcp.ToolListChangedRequest) { + lctx, cancel := context.WithTimeout(ctx, DefaultTimeout) + defer cancel() + + res, lerr := listToolsResult(lctx, watcher.sess, srv.Name) + if lerr != nil { + res = ServerToolsResult{Server: srv.Name, Err: fmt.Errorf("mcpdiscovery: list tools for %q after change notification: %w", srv.Name, lerr)} + } + if onChange != nil { + onChange(res) + } + }, + } + + client := mcp.NewClient(&mcp.Implementation{Name: clientName, Version: clientVersion}, opts) + sess, err := client.Connect(ctx, transport, nil) + if err != nil { + if cleanup != nil { + cleanup() + } + wrapped := fmt.Errorf("mcpdiscovery: connect to %q: %w", srv.Name, err) + return nil, ServerToolsResult{Server: srv.Name, Err: wrapped}, wrapped + } + watcher.sess = sess + + initial, err := listToolsResult(ctx, sess, srv.Name) + if err != nil { + wrapped := fmt.Errorf("mcpdiscovery: list tools for %q: %w", srv.Name, err) + _ = watcher.Close() + return nil, ServerToolsResult{Server: srv.Name, Err: wrapped}, wrapped + } + + return watcher, initial, nil +} + +// listToolsResult calls ListTools on sess and converts the outcome into a +// ServerToolsResult, mirroring probeServer's ListTools handling. +func listToolsResult(ctx context.Context, sess *mcp.ClientSession, serverName string) (ServerToolsResult, error) { + res, err := sess.ListTools(ctx, &mcp.ListToolsParams{}) + if err != nil { + return ServerToolsResult{}, err + } + + names := make([]string, 0, len(res.Tools)) + for _, tool := range res.Tools { + names = append(names, tool.Name) + } + return ServerToolsResult{Server: serverName, Tools: names, Reachable: true}, nil +} diff --git a/internal/mcpdiscovery/watch_test.go b/internal/mcpdiscovery/watch_test.go new file mode 100644 index 00000000..7b08a413 --- /dev/null +++ b/internal/mcpdiscovery/watch_test.go @@ -0,0 +1,142 @@ +package mcpdiscovery + +import ( + "context" + "sort" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/inercia/mitto/internal/agents" +) + +// newWatchableServer starts an in-memory MCP server exposing toolNames and +// returns the server (so tests can AddTool/RemoveTools on it), a +// TransportFactory that connects to it, and a stop func. +func newWatchableServer(t *testing.T, toolNames ...string) (*mcp.Server, TransportFactory, func()) { + t.Helper() + serverCtx, stop := context.WithCancel(context.Background()) + + srv := mcp.NewServer(&mcp.Implementation{Name: "mock", Version: "0"}, nil) + for _, name := range toolNames { + mcp.AddTool(srv, &mcp.Tool{Name: name, Description: "mock tool"}, noopToolHandler) + } + + clientT, serverT := mcp.NewInMemoryTransports() + go srv.Run(serverCtx, serverT) + + factory := func(_ context.Context, _ agents.MCPServer) (mcp.Transport, func(), error) { + return clientT, stop, nil + } + return srv, factory, stop +} + +func sortedNames(names []string) []string { + got := append([]string(nil), names...) + sort.Strings(got) + return got +} + +func TestWatchServer_InitialList(t *testing.T) { + _, factory, stop := newWatchableServer(t, "jira_search") + defer stop() + + watcher, initial, err := WatchServer(context.Background(), agents.MCPServer{Name: "jira"}, factory, func(ServerToolsResult) { + t.Error("onChange must not fire for the initial list") + }) + if err != nil { + t.Fatalf("WatchServer error = %v", err) + } + defer watcher.Close() + + if !initial.Reachable { + t.Fatalf("initial.Reachable = false, want true (err=%v)", initial.Err) + } + if got := sortedNames(initial.Tools); len(got) != 1 || got[0] != "jira_search" { + t.Errorf("initial.Tools = %v, want [jira_search]", got) + } +} + +func TestWatchServer_ChangeEventFiresCallback(t *testing.T) { + srv, factory, stop := newWatchableServer(t, "jira_search") + defer stop() + + changes := make(chan ServerToolsResult, 4) + watcher, initial, err := WatchServer(context.Background(), agents.MCPServer{Name: "jira"}, factory, func(res ServerToolsResult) { + changes <- res + }) + if err != nil { + t.Fatalf("WatchServer error = %v", err) + } + defer watcher.Close() + if !initial.Reachable { + t.Fatalf("initial.Reachable = false, want true") + } + + mcp.AddTool(srv, &mcp.Tool{Name: "jira_create_issue", Description: "mock tool"}, noopToolHandler) + + select { + case res := <-changes: + if !res.Reachable { + t.Fatalf("onChange result.Reachable = false, want true (err=%v)", res.Err) + } + got := sortedNames(res.Tools) + want := []string{"jira_create_issue", "jira_search"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("onChange Tools = %v, want %v", got, want) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for onChange after AddTool") + } + + srv.RemoveTools("jira_search") + + select { + case res := <-changes: + if !res.Reachable { + t.Fatalf("onChange result.Reachable = false, want true (err=%v)", res.Err) + } + for _, name := range res.Tools { + if name == "jira_search" { + t.Errorf("onChange Tools = %v, want jira_search removed", res.Tools) + } + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for onChange after RemoveTools") + } +} + +func TestWatchServer_CloseIsCleanAndIdempotent(t *testing.T) { + _, factory, stop := newWatchableServer(t, "jira_search") + defer stop() + + watcher, _, err := WatchServer(context.Background(), agents.MCPServer{Name: "jira"}, factory, nil) + if err != nil { + t.Fatalf("WatchServer error = %v", err) + } + + if err := watcher.Close(); err != nil { + t.Errorf("first Close() error = %v, want nil", err) + } + if err := watcher.Close(); err != nil { + t.Errorf("second Close() error = %v, want nil (idempotent)", err) + } +} + +func TestWatchServer_NilOnChangeDoesNotPanic(t *testing.T) { + srv, factory, stop := newWatchableServer(t, "jira_search") + defer stop() + + watcher, _, err := WatchServer(context.Background(), agents.MCPServer{Name: "jira"}, factory, nil) + if err != nil { + t.Fatalf("WatchServer error = %v", err) + } + defer watcher.Close() + + mcp.AddTool(srv, &mcp.Tool{Name: "jira_create_issue", Description: "mock tool"}, noopToolHandler) + + // Give the notification a moment to arrive and be handled; the only + // assertion here is that nothing panics (a panic would fail the test). + time.Sleep(200 * time.Millisecond) +} diff --git a/internal/mcpserver/prompts.go b/internal/mcpserver/prompts.go index 9a1c0646..e0575357 100644 --- a/internal/mcpserver/prompts.go +++ b/internal/mcpserver/prompts.go @@ -177,7 +177,7 @@ func (s *Server) handlePromptList(ctx context.Context, req *mcp.CallToolRequest, Icon: p.Icon, Source: string(p.Source), Enabled: p.Enabled, - Periodic: p.Periodic, + Loop: p.Loop, Parameters: p.Parameters, }) } @@ -217,7 +217,7 @@ func (s *Server) handlePromptGet(ctx context.Context, req *mcp.CallToolRequest, Icon: p.Icon, Source: string(p.Source), Enabled: p.Enabled, - Periodic: p.Periodic, + Loop: p.Loop, Parameters: p.Parameters, }, }, nil diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 452fcdec..2d9c6321 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -67,7 +67,7 @@ type Server struct { config *config.Config promptsCache *config.PromptsCache sessionManager SessionManager - periodicRunner PeriodicRunner // Optional — for triggering periodic runs via MCP + loopRunner LoopRunner // Optional — for triggering loop runs via MCP running bool shutdown bool @@ -163,8 +163,8 @@ type SessionManager interface { GetWorkspaceByUUID(uuid string) *config.WorkspaceSettings // BroadcastSessionRenamed broadcasts a session_renamed event to all connected clients. BroadcastSessionRenamed(sessionID string, newName string) - // BroadcastPeriodicUpdated broadcasts a periodic_updated event to all connected clients. - BroadcastPeriodicUpdated(sessionID string, periodic *session.PeriodicPrompt) + // BroadcastLoopUpdated broadcasts a loop_updated event to all connected clients. + BroadcastLoopUpdated(sessionID string, loop *session.LoopPrompt) // GetUserDataSchema returns the user data schema for a workspace. GetUserDataSchema(workingDir string) *config.UserDataSchema // GetWorkspacePrompts returns prompts defined in the workspace's .mittorc file. @@ -179,11 +179,11 @@ type SessionManager interface { InvalidateWorkspaceRC(workingDir string) } -// PeriodicRunner interface for triggering immediate periodic prompt delivery. -type PeriodicRunner interface { +// LoopRunner interface for triggering immediate loop prompt delivery. +type LoopRunner interface { TriggerNow(sessionID string, resetTimer bool) error // BootstrapOnCompletion delivers the very first run of a fresh onCompletion - // periodic conversation (IterationCount==0, LastSentAt==nil). No-op otherwise. + // loop conversation (IterationCount==0, LastSentAt==nil). No-op otherwise. BootstrapOnCompletion(sessionID string) } @@ -207,11 +207,11 @@ type BackgroundSession interface { WaitForResponseComplete(timeout time.Duration) bool // TriggerTitleGeneration triggers async title generation if the session has no title yet. // Used by MCP tools and API handlers to generate titles for sessions that received - // prompts via paths that don't normally trigger title generation (e.g., periodic config). + // prompts via paths that don't normally trigger title generation (e.g., loop config). TriggerTitleGeneration(message string) - // TriggerTitleGenerationFromPeriodic picks the best source text (prompt text or prompt - // name) for title generation when a periodic config is saved. - TriggerTitleGenerationFromPeriodic(prompt, promptName string) + // TriggerTitleGenerationFromLoop picks the best source text (prompt text or prompt + // name) for title generation when a loop config is saved. + TriggerTitleGenerationFromLoop(prompt, promptName string) // RequestSelfDestruct marks the conversation for deletion once the current turn // completes. Used by the mitto_conversation_delete tool when an agent requests // deletion of its own conversation. @@ -219,6 +219,9 @@ type BackgroundSession interface { // LastQueuedSendError returns the most recent queued-send failure message and its // timestamp. Used by the parent wait loop to surface dispatch failures as status=failed. LastQueuedSendError() (string, time.Time) + // RecordChildWait accumulates a completed blocking wait duration for + // mitto_children_tasks_wait calls made from this session. In-memory only. + RecordChildWait(d time.Duration) } // Config holds the configuration for the MCP server. @@ -493,21 +496,21 @@ func (s *Server) UpdateDependencies(deps Dependencies) { } } -// periodicDelayFloor returns the configured global floor for the on-completion periodic +// loopDelayFloor returns the configured global floor for the on-completion loop // delay. Falls back to the package default when no config is available. -func (s *Server) periodicDelayFloor() int { +func (s *Server) loopDelayFloor() int { if s.config != nil { - return s.config.Conversations.GetMinPeriodicCompletionDelaySeconds() + return s.config.Conversations.GetMinLoopCompletionDelaySeconds() } - return config.DefaultMinPeriodicCompletionDelaySeconds + return config.DefaultMinLoopCompletionDelaySeconds } -// SetPeriodicRunner sets the periodic runner for triggering periodic runs via MCP tools. -// It may be called after NewServer since the periodic runner is created after the MCP server. -func (s *Server) SetPeriodicRunner(runner PeriodicRunner) { +// SetLoopRunner sets the loop runner for triggering loop runs via MCP tools. +// It may be called after NewServer since the loop runner is created after the MCP server. +func (s *Server) SetLoopRunner(runner LoopRunner) { s.mu.Lock() defer s.mu.Unlock() - s.periodicRunner = runner + s.loopRunner = runner } // RegisterSession registers a session with the MCP server. @@ -932,9 +935,9 @@ func (s *Server) buildConversationDetails(meta session.Metadata, sessionFolder s details.IsPrompting = lockInfo.Status == session.LockStatusProcessing } - // Check if conversation has an active periodic prompt - if p, err := store.Periodic(meta.SessionID).Get(); err == nil && p != nil { - details.IsPeriodic = p.Enabled + // Check if conversation has an active loop prompt + if p, err := store.Loop(meta.SessionID).Get(); err == nil && p != nil { + details.IsLoop = p.Enabled } // Load message queue @@ -1178,16 +1181,16 @@ func (s *Server) registerSessionScopedTools(mcpSrv *mcp.Server) { "Requires 'initial_prompt' or 'prompt_name' to be set. " + "Optionally specify a 'workspace' UUID to create the conversation in a different workspace (requires user confirmation). " + "Optionally provide 'beads_issue' to link the new conversation to a beads issue ID (e.g. 'mitto-123'). " + - "Optionally configure the conversation as periodic by providing 'periodic_prompt', 'periodic_frequency_value', and 'periodic_frequency_unit'. " + - "This is equivalent to configuring periodic via 'mitto_conversation_update' after creation, but done in one step. " + - "For periodic with days, optionally specify 'periodic_frequency_at' (HH:MM in UTC). " + - "Set 'periodic_enabled' to false to create the periodic configuration in a paused state. " + - "Set 'periodic_fresh_context' to true to start each run with a clean agent context (no history injection, new ACP session). " + - "Set 'periodic_max_iterations' to limit the number of scheduled runs (0 = unlimited). " + - "Set 'periodic_trigger' to 'onCompletion' to fire the next run after the agent stops responding (event-driven), or 'onTasks' to fire when beads/tasks in the workspace change (event-driven), instead of on a fixed 'schedule'; neither onCompletion nor onTasks requires a frequency. " + - "For 'onCompletion', set 'periodic_completion_delay_seconds' to the wait after the agent stops (clamped to the global floor). " + - "For 'onTasks', optionally set 'periodic_condition' to a CEL expression gating which task changes fire the run (empty = fire on ANY beads/task change); 'periodic_condition_preset' records an optional UI preset id compiled into the condition. " + - "Set 'periodic_max_duration_seconds' to auto-stop the conversation after a wall-clock cap since iterating started (0 = unlimited). " + + "Optionally configure the conversation as a loop by providing 'loop_prompt', 'loop_frequency_value', and 'loop_frequency_unit'. " + + "This is equivalent to configuring the loop via 'mitto_conversation_update' after creation, but done in one step. " + + "For a daily loop, optionally specify 'loop_frequency_at' (HH:MM in UTC). " + + "Set 'loop_enabled' to false to create the loop configuration in a paused state. " + + "Set 'loop_fresh_context' to true to start each run with a clean agent context (no history injection, new ACP session). " + + "Set 'loop_max_iterations' to limit the number of scheduled runs (0 = unlimited). " + + "Set 'loop_trigger' to 'onCompletion' to fire the next run after the agent stops responding (event-driven), or 'onTasks' to fire when beads/tasks in the workspace change (event-driven), instead of on a fixed 'schedule'; neither onCompletion nor onTasks requires a frequency. " + + "For 'onCompletion', set 'loop_completion_delay_seconds' to the wait after the agent stops (clamped to the global floor). " + + "For 'onTasks', optionally set 'loop_condition' to a CEL expression gating which task changes fire the run (empty = fire on ANY beads/task change); 'loop_condition_preset' records an optional UI preset id compiled into the condition. " + + "Set 'loop_max_duration_seconds' to auto-stop the conversation after a wall-clock cap since iterating started (0 = unlimited). " + "Cannot be used together with 'acp_server'. " + "Requires 'Can start conversation' flag to be enabled in Advanced Settings (disabled by default for security). " + "Note: Conversations created by this tool cannot spawn further conversations (to prevent infinite recursion). " + @@ -1206,17 +1209,17 @@ func (s *Server) registerSessionScopedTools(mcpSrv *mcp.Server) { selfIDNote, }, s.handleGetConversation) - // mitto_conversation_run_periodic_now - Trigger immediate periodic run + // mitto_conversation_run_loop_now - Trigger immediate loop run mcp.AddTool(mcpSrv, &mcp.Tool{ - Name: "mitto_conversation_run_periodic_now", - Description: "Trigger an immediate run of a periodic conversation's configured prompt, bypassing the normal schedule. " + - "The conversation must have periodic prompts configured and enabled. " + + Name: "mitto_conversation_run_loop_now", + Description: "Trigger an immediate run of a loop conversation's configured prompt, bypassing the normal schedule. " + + "The conversation must have loop prompts configured and enabled. " + "Use 'reset_timer' to control whether the countdown for the next scheduled run resets (default: true). " + "When reset_timer is true, the next run is scheduled from now (as if a normal run just occurred). " + "When reset_timer is false, the existing next-run schedule is preserved unchanged. " + "Use 'mitto_conversation_list' first to find available conversation IDs. " + selfIDNote, - }, s.handleRunPeriodicNow) + }, s.handleRunLoopNow) // mitto_conversation_archive - Archive or unarchive a conversation mcp.AddTool(mcpSrv, &mcp.Tool{ @@ -1245,23 +1248,23 @@ func (s *Server) registerSessionScopedTools(mcpSrv *mcp.Server) { Name: "mitto_conversation_update", Description: "Update properties of a conversation. " + "Supports partial updates — only specified fields are changed, others are left untouched. " + - "To update YOUR OWN conversation (e.g. a periodic conversation disabling its own periodicity), " + + "To update YOUR OWN conversation (e.g. a loop conversation disabling its own loop), " + "pass \"self\" (or your own conversation ID) as conversation_id. " + "Updatable properties: 'name' (conversation title), 'user_data' (workspace-defined metadata attributes), " + "'beads_issue' (linked beads issue ID, e.g. \"mitto-123\"; empty string clears it), " + - "'periodic' (periodic prompt configuration). " + + "'loop' (loop prompt configuration). " + "User data is validated against the workspace's schema defined in .mittorc. " + "Set 'user_data_merge' to true (default) to merge with existing attributes, or false to replace all. " + - "Periodic configuration: provide 'periodic_prompt', 'periodic_frequency_value', and 'periodic_frequency_unit' " + - "to configure or update periodic prompts. Use 'periodic_frequency_at' (HH:MM UTC) for daily schedules. " + - "Set 'periodic_enabled' to false to pause periodic execution without deleting the configuration. " + - "To disable periodic entirely, set 'periodic_enabled' to false. " + - "Set 'periodic_fresh_context' to true to start each run with a clean agent context (no history injection, new ACP session). " + - "Set 'periodic_max_iterations' to limit the number of scheduled runs (0 = unlimited). " + - "Set 'periodic_trigger' to 'onCompletion' (event-driven: fire after the agent stops), 'onTasks' (event-driven: fire when beads/tasks in the workspace change), or 'schedule' (frequency-based, default); neither onCompletion nor onTasks requires a frequency. " + - "For 'onCompletion', set 'periodic_completion_delay_seconds' to the wait after the agent stops (clamped to the global floor). " + - "For 'onTasks', optionally set 'periodic_condition' to a CEL expression gating which task changes fire the run (empty = fire on ANY beads/task change); 'periodic_condition_preset' records an optional UI preset id compiled into the condition. " + - "Set 'periodic_max_duration_seconds' to auto-stop the conversation after a wall-clock cap since iterating started (0 = unlimited). " + + "Loop configuration: provide 'loop_prompt', 'loop_frequency_value', and 'loop_frequency_unit' " + + "to configure or update loop prompts. Use 'loop_frequency_at' (HH:MM UTC) for daily schedules. " + + "Set 'loop_enabled' to false to pause loop execution without deleting the configuration. " + + "To disable loop entirely, set 'loop_enabled' to false. " + + "Set 'loop_fresh_context' to true to start each run with a clean agent context (no history injection, new ACP session). " + + "Set 'loop_max_iterations' to limit the number of scheduled runs (0 = unlimited). " + + "Set 'loop_trigger' to 'onCompletion' (event-driven: fire after the agent stops), 'onTasks' (event-driven: fire when beads/tasks in the workspace change), or 'schedule' (frequency-based, default); neither onCompletion nor onTasks requires a frequency. " + + "For 'onCompletion', set 'loop_completion_delay_seconds' to the wait after the agent stops (clamped to the global floor). " + + "For 'onTasks', optionally set 'loop_condition' to a CEL expression gating which task changes fire the run (empty = fire on ANY beads/task change); 'loop_condition_preset' records an optional UI preset id compiled into the condition. " + + "Set 'loop_max_duration_seconds' to auto-stop the conversation after a wall-clock cap since iterating started (0 = unlimited). " + selfIDNote, }, s.handleConversationUpdate) @@ -1842,9 +1845,9 @@ func (s *Server) createListConversationsHandler(sm SessionManager) mcp.ToolHandl } } - // Check if conversation has an active periodic prompt. - if p, err := store.Periodic(meta.SessionID).Get(); err == nil && p != nil { - info.IsPeriodic = p.Enabled + // Check if conversation has an active loop prompt. + if p, err := store.Loop(meta.SessionID).Get(); err == nil && p != nil { + info.IsLoop = p.Enabled } // Apply is_running filter after runtime status is resolved. @@ -2109,8 +2112,8 @@ func (s *Server) handleSendPromptToConversation(ctx context.Context, req *mcp.Ca // Get the queue for the target conversation queue := store.Queue(input.ConversationID) - // Add the prompt to the queue - msg, err := queue.Add(input.Prompt, nil, nil, realSessionID, scheduledTime, 0, input.Arguments, input.PromptName) + // Add the prompt to the queue (agent origin: cross-session MCP dispatch, fail-closed on broken templates) + msg, err := queue.AddWithOrigin(input.Prompt, nil, nil, realSessionID, scheduledTime, 0, input.Arguments, input.PromptName, session.QueueOriginAgent) if err != nil { return nil, SendPromptOutput{ Success: false, @@ -2129,7 +2132,7 @@ func (s *Server) handleSendPromptToConversation(ctx context.Context, req *mcp.Ca "scheduled", scheduledTime != nil) // Try to process the queued message immediately if agent is idle. - // Skip for scheduled messages — the periodic runner will deliver them when due. + // Skip for scheduled messages — the loop runner will deliver them when due. if scheduledTime == nil { if s.sessionManager != nil { bs := s.sessionManager.GetSession(input.ConversationID) @@ -2721,33 +2724,33 @@ type ConversationStartInput struct { ACPServer string `json:"acp_server,omitempty"` // Optional ACP server name (defaults to parent's server) BeadsIssue string `json:"beads_issue,omitempty"` // Optional: link the new conversation to a beads issue ID (e.g. "mitto-123") Workspace string `json:"workspace,omitempty"` // Optional workspace UUID for cross-workspace operations - // Periodic configuration (optional) - creates the conversation as periodic - PeriodicPrompt string `json:"periodic_prompt,omitempty"` // The prompt to send periodically - PeriodicFrequencyValue int `json:"periodic_frequency_value,omitempty"` // Number of units between sends - PeriodicFrequencyUnit string `json:"periodic_frequency_unit,omitempty"` // Time unit: "minutes", "hours", or "days" - PeriodicFrequencyAt string `json:"periodic_frequency_at,omitempty"` // Time of day HH:MM (UTC), only for "days" - PeriodicEnabled *bool `json:"periodic_enabled,omitempty"` // Whether periodic is active (defaults to true) - PeriodicFreshContext *bool `json:"periodic_fresh_context,omitempty"` // Start each run with a fresh agent context (default false) - PeriodicMaxIterations *int `json:"periodic_max_iterations,omitempty"` // Maximum number of scheduled runs (0 = unlimited) + // Loop configuration (optional) - creates the conversation as a loop + LoopPrompt string `json:"loop_prompt,omitempty"` // The prompt to send in the loop + LoopFrequencyValue int `json:"loop_frequency_value,omitempty"` // Number of units between sends + LoopFrequencyUnit string `json:"loop_frequency_unit,omitempty"` // Time unit: "minutes", "hours", or "days" + LoopFrequencyAt string `json:"loop_frequency_at,omitempty"` // Time of day HH:MM (UTC), only for "days" + LoopEnabled *bool `json:"loop_enabled,omitempty"` // Whether loop is active (defaults to true) + LoopFreshContext *bool `json:"loop_fresh_context,omitempty"` // Start each run with a fresh agent context (default false) + LoopMaxIterations *int `json:"loop_max_iterations,omitempty"` // Maximum number of scheduled runs (0 = unlimited) // On-completion / on-tasks trigger configuration (optional) - PeriodicTrigger string `json:"periodic_trigger,omitempty"` // "schedule" (default), "onCompletion", or "onTasks" - PeriodicCompletionDelaySeconds *int `json:"periodic_completion_delay_seconds,omitempty"` // Wait (s) after agent stops, onCompletion only; clamped to floor - PeriodicMaxDurationSeconds *int `json:"periodic_max_duration_seconds,omitempty"` // Wall-clock cap (s) since iterating started (0 = unlimited) - // PeriodicCondition is a CEL expression gating onTasks firing (only meaningful when - // periodic_trigger is "onTasks"). Empty means fire on ANY beads/task change. - PeriodicCondition string `json:"periodic_condition,omitempty"` - // PeriodicConditionPreset is an optional UI preset id that was compiled into periodic_condition. - PeriodicConditionPreset string `json:"periodic_condition_preset,omitempty"` + LoopTrigger string `json:"loop_trigger,omitempty"` // "schedule" (default), "onCompletion", or "onTasks" + LoopCompletionDelaySeconds *int `json:"loop_completion_delay_seconds,omitempty"` // Wait (s) after agent stops, onCompletion only; clamped to floor + LoopMaxDurationSeconds *int `json:"loop_max_duration_seconds,omitempty"` // Wall-clock cap (s) since iterating started (0 = unlimited) + // LoopCondition is a CEL expression gating onTasks firing (only meaningful when + // loop_trigger is "onTasks"). Empty means fire on ANY beads/task change. + LoopCondition string `json:"loop_condition,omitempty"` + // LoopConditionPreset is an optional UI preset id that was compiled into loop_condition. + LoopConditionPreset string `json:"loop_condition_preset,omitempty"` } // ConversationStartOutput is the output for mitto_conversation_new tool. // Embeds ConversationDetails for the newly created conversation. type ConversationStartOutput struct { ConversationDetails // Embedded conversation details - QueuePosition int `json:"queue_position,omitempty"` // Queue position if initial prompt was provided - PeriodicConfigured bool `json:"periodic_configured,omitempty"` // Whether periodic was configured - PeriodicNextRun string `json:"periodic_next_run,omitempty"` // Next scheduled run (RFC3339) - Reused bool `json:"reused,omitempty"` // True when routed to an existing singleton conversation instead of creating a new one + QueuePosition int `json:"queue_position,omitempty"` // Queue position if initial prompt was provided + LoopConfigured bool `json:"loop_configured,omitempty"` // Whether loop was configured + LoopNextRun string `json:"loop_next_run,omitempty"` // Next scheduled run (RFC3339) + Reused bool `json:"reused,omitempty"` // True when routed to an existing singleton conversation instead of creating a new one Error string `json:"error,omitempty"` } @@ -3059,30 +3062,30 @@ func (s *Server) handleConversationStart(ctx context.Context, req *mcp.CallToolR ) } - // If periodic configuration provided, set it up - var periodicConfigured bool - var periodicNextRun string - if input.PeriodicPrompt != "" { + // If loop configuration provided, set it up + var loopConfigured bool + var loopNextRun string + if input.LoopPrompt != "" { // Resolve the trigger (default schedule). onCompletion and onTasks are // event-driven and do not require a frequency. - trigger := session.PeriodicTrigger(input.PeriodicTrigger) + trigger := session.LoopTrigger(input.LoopTrigger) switch trigger { case "", session.TriggerSchedule, session.TriggerOnCompletion, session.TriggerOnTasks: // valid default: - return nil, ConversationStartOutput{}, fmt.Errorf("periodic_trigger must be 'schedule', 'onCompletion', or 'onTasks'") + return nil, ConversationStartOutput{}, fmt.Errorf("loop_trigger must be 'schedule', 'onCompletion', or 'onTasks'") } skipFrequency := trigger == session.TriggerOnCompletion || trigger == session.TriggerOnTasks var freq session.Frequency if !skipFrequency { // Schedule trigger: frequency is required. - if input.PeriodicFrequencyValue < 1 { - return nil, ConversationStartOutput{}, fmt.Errorf("periodic_frequency_value must be >= 1 when periodic_prompt is provided") + if input.LoopFrequencyValue < 1 { + return nil, ConversationStartOutput{}, fmt.Errorf("loop_frequency_value must be >= 1 when loop_prompt is provided") } var freqUnit session.FrequencyUnit - switch input.PeriodicFrequencyUnit { + switch input.LoopFrequencyUnit { case "minutes": freqUnit = session.FrequencyMinutes case "hours": @@ -3090,46 +3093,46 @@ func (s *Server) handleConversationStart(ctx context.Context, req *mcp.CallToolR case "days": freqUnit = session.FrequencyDays default: - return nil, ConversationStartOutput{}, fmt.Errorf("periodic_frequency_unit must be 'minutes', 'hours', or 'days'") + return nil, ConversationStartOutput{}, fmt.Errorf("loop_frequency_unit must be 'minutes', 'hours', or 'days'") } freq = session.Frequency{ - Value: input.PeriodicFrequencyValue, + Value: input.LoopFrequencyValue, Unit: freqUnit, - At: input.PeriodicFrequencyAt, + At: input.LoopFrequencyAt, } if err := freq.Validate(); err != nil { - return nil, ConversationStartOutput{}, fmt.Errorf("invalid periodic frequency: %v", err) + return nil, ConversationStartOutput{}, fmt.Errorf("invalid loop frequency: %v", err) } } enabled := true - if input.PeriodicEnabled != nil { - enabled = *input.PeriodicEnabled + if input.LoopEnabled != nil { + enabled = *input.LoopEnabled } freshContext := false - if input.PeriodicFreshContext != nil { - freshContext = *input.PeriodicFreshContext + if input.LoopFreshContext != nil { + freshContext = *input.LoopFreshContext } maxIterations := 0 - if input.PeriodicMaxIterations != nil { - maxIterations = *input.PeriodicMaxIterations + if input.LoopMaxIterations != nil { + maxIterations = *input.LoopMaxIterations } delaySeconds := 0 - if input.PeriodicCompletionDelaySeconds != nil { - delaySeconds = *input.PeriodicCompletionDelaySeconds + if input.LoopCompletionDelaySeconds != nil { + delaySeconds = *input.LoopCompletionDelaySeconds } maxDurationSeconds := 0 - if input.PeriodicMaxDurationSeconds != nil { - maxDurationSeconds = *input.PeriodicMaxDurationSeconds + if input.LoopMaxDurationSeconds != nil { + maxDurationSeconds = *input.LoopMaxDurationSeconds } - periodic := &session.PeriodicPrompt{ - Prompt: input.PeriodicPrompt, + loop := &session.LoopPrompt{ + Prompt: input.LoopPrompt, Frequency: freq, Enabled: enabled, FreshContext: freshContext, @@ -3137,34 +3140,34 @@ func (s *Server) handleConversationStart(ctx context.Context, req *mcp.CallToolR Trigger: trigger, DelaySeconds: delaySeconds, MaxDurationSeconds: maxDurationSeconds, - Condition: input.PeriodicCondition, - ConditionPreset: input.PeriodicConditionPreset, + Condition: input.LoopCondition, + ConditionPreset: input.LoopConditionPreset, } // Clamp the on-completion delay to the global floor (no-op for schedule). - periodic.ClampDelay(s.periodicDelayFloor()) + loop.ClampDelay(s.loopDelayFloor()) - periodicStore := store.Periodic(newSessionID) - if err := periodicStore.Set(periodic); err != nil { - s.logger.Error("Failed to set periodic on new conversation", + loopStore := store.Loop(newSessionID) + if err := loopStore.Set(loop); err != nil { + s.logger.Error("Failed to set loop on new conversation", "session_id", newSessionID, "error", err) // Don't fail the whole creation - just log the error } else { - periodicConfigured = true - updated, err := periodicStore.Get() + loopConfigured = true + updated, err := loopStore.Get() if err == nil && updated.NextScheduledAt != nil { - periodicNextRun = updated.NextScheduledAt.Format("2006-01-02T15:04:05Z07:00") + loopNextRun = updated.NextScheduledAt.Format("2006-01-02T15:04:05Z07:00") } - s.logger.Info("Periodic prompt configured on new conversation", + s.logger.Info("Loop prompt configured on new conversation", "session_id", newSessionID, - "periodic_prompt", input.PeriodicPrompt, - "frequency_value", input.PeriodicFrequencyValue, - "frequency_unit", input.PeriodicFrequencyUnit, + "loop_prompt", input.LoopPrompt, + "frequency_value", input.LoopFrequencyValue, + "frequency_unit", input.LoopFrequencyUnit, "enabled", enabled) // Kick off the very first run for a fresh onCompletion conversation. s.mu.RLock() - runner := s.periodicRunner + runner := s.loopRunner s.mu.RUnlock() if runner != nil { runner.BootstrapOnCompletion(newSessionID) @@ -3172,18 +3175,18 @@ func (s *Server) handleConversationStart(ctx context.Context, req *mcp.CallToolR } } - // If no explicit title was provided and periodic was configured, trigger title - // generation from the periodic prompt text so the conversation has a name right away. - // ConversationStartInput has no PeriodicPromptName field, so prompt name is passed as "". - if input.Title == "" && periodicConfigured && bs != nil { - bs.TriggerTitleGenerationFromPeriodic(input.PeriodicPrompt, "") + // If no explicit title was provided and loop was configured, trigger title + // generation from the loop prompt text so the conversation has a name right away. + // ConversationStartInput has no LoopPromptName field, so prompt name is passed as "". + if input.Title == "" && loopConfigured && bs != nil { + bs.TriggerTitleGenerationFromLoop(input.LoopPrompt, "") } // Build unified conversation details output := ConversationStartOutput{ ConversationDetails: s.buildConversationDetails(createdMeta, store.SessionDir(newSessionID)), - PeriodicConfigured: periodicConfigured, - PeriodicNextRun: periodicNextRun, + LoopConfigured: loopConfigured, + LoopNextRun: loopNextRun, } // Update runtime status to reflect the running ACP session if bs != nil { @@ -3217,7 +3220,7 @@ func (s *Server) handleConversationStart(ctx context.Context, req *mcp.CallToolR } queue := store.Queue(newSessionID) - _, err := queue.Add(initialPromptText, nil, nil, realSessionID, scheduledTime, 0, input.Arguments, "") + _, err := queue.AddWithOrigin(initialPromptText, nil, nil, realSessionID, scheduledTime, 0, input.Arguments, "", session.QueueOriginAgent) if err != nil { s.logger.Warn("Failed to queue initial prompt", "session_id", newSessionID, @@ -3271,7 +3274,7 @@ func (s *Server) reuseSingletonConversation(store *session.Store, existingID, in return output, nil } - if _, addErr := queue.Add(initialPromptText, nil, nil, clientID, nil, 0, arguments, ""); addErr != nil { + if _, addErr := queue.AddWithOrigin(initialPromptText, nil, nil, clientID, nil, 0, arguments, "", session.QueueOriginAgent); addErr != nil { s.logger.Warn("Failed to re-seed reused singleton conversation", "session_id", existingID, "error", addErr) return output, nil @@ -3381,35 +3384,35 @@ func (s *Server) handleGetConversation(ctx context.Context, req *mcp.CallToolReq return nil, output, nil } -// RunPeriodicNowInput is the input for mitto_conversation_run_periodic_now tool. -type RunPeriodicNowInput struct { +// RunLoopNowInput is the input for mitto_conversation_run_loop_now tool. +type RunLoopNowInput struct { SelfID string `json:"self_id"` // YOUR session ID (the caller) ConversationID string `json:"conversation_id"` // Target conversation to trigger ResetTimer *bool `json:"reset_timer,omitempty"` // Whether to reset the countdown timer (default: true) } -// RunPeriodicNowOutput is the output for mitto_conversation_run_periodic_now tool. -type RunPeriodicNowOutput struct { +// RunLoopNowOutput is the output for mitto_conversation_run_loop_now tool. +type RunLoopNowOutput struct { Success bool `json:"success"` Message string `json:"message,omitempty"` Error string `json:"error,omitempty"` } -func (s *Server) handleRunPeriodicNow(ctx context.Context, req *mcp.CallToolRequest, input RunPeriodicNowInput) (*mcp.CallToolResult, RunPeriodicNowOutput, error) { +func (s *Server) handleRunLoopNow(ctx context.Context, req *mcp.CallToolRequest, input RunLoopNowInput) (*mcp.CallToolResult, RunLoopNowOutput, error) { // Validate self_id if input.SelfID == "" { - return nil, RunPeriodicNowOutput{Error: "self_id is required"}, nil + return nil, RunLoopNowOutput{Error: "self_id is required"}, nil } // Validate conversation_id if input.ConversationID == "" { - return nil, RunPeriodicNowOutput{Error: "conversation_id is required"}, nil + return nil, RunLoopNowOutput{Error: "conversation_id is required"}, nil } // Resolve the self_id to a real session ID realSessionID := s.resolveSelfIDWithMCP(input.SelfID, req) if realSessionID == "" { - return nil, RunPeriodicNowOutput{ + return nil, RunLoopNowOutput{ Error: fmt.Sprintf("session not found: the self_id '%s' could not be resolved", input.SelfID), }, nil } @@ -3417,16 +3420,16 @@ func (s *Server) handleRunPeriodicNow(ctx context.Context, req *mcp.CallToolRequ // Check if source session is registered (must be running to use this tool) reg := s.getSession(realSessionID) if reg == nil { - return nil, RunPeriodicNowOutput{Error: fmt.Sprintf("session not found or not running: %s", realSessionID)}, nil + return nil, RunLoopNowOutput{Error: fmt.Sprintf("session not found or not running: %s", realSessionID)}, nil } - // Check if periodic runner is available + // Check if loop runner is available s.mu.RLock() - runner := s.periodicRunner + runner := s.loopRunner s.mu.RUnlock() if runner == nil { - return nil, RunPeriodicNowOutput{Error: "periodic runner not available"}, nil + return nil, RunLoopNowOutput{Error: "loop runner not available"}, nil } // Determine reset_timer (default: true — same as normal scheduled runs) @@ -3437,22 +3440,22 @@ func (s *Server) handleRunPeriodicNow(ctx context.Context, req *mcp.CallToolRequ // Trigger immediate delivery if err := runner.TriggerNow(input.ConversationID, resetTimer); err != nil { - return nil, RunPeriodicNowOutput{Error: fmt.Sprintf("failed to trigger periodic run: %v", err)}, nil + return nil, RunLoopNowOutput{Error: fmt.Sprintf("failed to trigger loop run: %v", err)}, nil } - msg := "Periodic prompt triggered successfully" + msg := "Loop prompt triggered successfully" if !resetTimer { msg += " (countdown timer preserved)" } else { msg += " (countdown timer reset)" } - s.logger.Info("Periodic prompt triggered via MCP", + s.logger.Info("Loop prompt triggered via MCP", "source_session", realSessionID, "target_conversation", input.ConversationID, "reset_timer", resetTimer) - return nil, RunPeriodicNowOutput{Success: true, Message: msg}, nil + return nil, RunLoopNowOutput{Success: true, Message: msg}, nil } // ArchiveConversationInput is the input for mitto_conversation_archive tool. @@ -3813,8 +3816,8 @@ func (s *Server) handleConversationUpdate(ctx context.Context, req *mcp.CallTool } // Self-targeting: agents may pass "self" to update their OWN conversation - // (e.g. a periodic conversation disabling its own periodicity). Unlike delete, - // an update only touches metadata/periodic config and is safe to perform + // (e.g. a loop conversation disabling its own loop). Unlike delete, + // an update only touches metadata/loop config and is safe to perform // synchronously, so we simply resolve the alias to the caller's real ID. This // keeps the tool consistent with mitto_conversation_delete, which also accepts "self". if input.ConversationID == "self" { @@ -3949,22 +3952,22 @@ func (s *Server) handleConversationUpdate(ctx context.Context, req *mcp.CallTool "merge", merge) } - // Update periodic configuration if any periodic fields provided - if input.PeriodicPrompt != nil || input.PeriodicFrequencyValue != nil || input.PeriodicFrequencyUnit != nil || input.PeriodicEnabled != nil || input.PeriodicFreshContext != nil || input.PeriodicMaxIterations != nil || - input.PeriodicTrigger != nil || input.PeriodicCompletionDelaySeconds != nil || input.PeriodicMaxDurationSeconds != nil || - input.PeriodicCondition != nil || input.PeriodicConditionPreset != nil { - periodicStore := store.Periodic(input.ConversationID) + // Update loop configuration if any loop fields provided + if input.LoopPrompt != nil || input.LoopFrequencyValue != nil || input.LoopFrequencyUnit != nil || input.LoopEnabled != nil || input.LoopFreshContext != nil || input.LoopMaxIterations != nil || + input.LoopTrigger != nil || input.LoopCompletionDelaySeconds != nil || input.LoopMaxDurationSeconds != nil || + input.LoopCondition != nil || input.LoopConditionPreset != nil { + loopStore := store.Loop(input.ConversationID) - // Check if this is an update to existing periodic config or a new setup - existing, existErr := periodicStore.Get() + // Check if this is an update to existing loop config or a new setup + existing, existErr := loopStore.Get() isNew := existErr != nil || existing == nil if isNew { // Resolve the trigger (default schedule). onCompletion and onTasks are // event-driven and do not require a frequency. trigger := session.TriggerSchedule - if input.PeriodicTrigger != nil { - trigger = session.PeriodicTrigger(*input.PeriodicTrigger) + if input.LoopTrigger != nil { + trigger = session.LoopTrigger(*input.LoopTrigger) } switch trigger { case "", session.TriggerSchedule, session.TriggerOnCompletion, session.TriggerOnTasks: @@ -3972,37 +3975,37 @@ func (s *Server) handleConversationUpdate(ctx context.Context, req *mcp.CallTool default: return nil, ConversationUpdateOutput{ Success: false, - Error: "periodic_trigger must be 'schedule', 'onCompletion', or 'onTasks'", + Error: "loop_trigger must be 'schedule', 'onCompletion', or 'onTasks'", }, nil } skipFrequency := trigger == session.TriggerOnCompletion || trigger == session.TriggerOnTasks - // Creating new periodic config — require the prompt always. - if input.PeriodicPrompt == nil || *input.PeriodicPrompt == "" { + // Creating new loop config — require the prompt always. + if input.LoopPrompt == nil || *input.LoopPrompt == "" { return nil, ConversationUpdateOutput{ Success: false, - Error: "periodic_prompt is required when creating new periodic configuration", + Error: "loop_prompt is required when creating new loop configuration", }, nil } var freq session.Frequency if !skipFrequency { // Schedule trigger: frequency is mandatory. - if input.PeriodicFrequencyValue == nil || *input.PeriodicFrequencyValue < 1 { + if input.LoopFrequencyValue == nil || *input.LoopFrequencyValue < 1 { return nil, ConversationUpdateOutput{ Success: false, - Error: "periodic_frequency_value (>= 1) is required when creating new periodic configuration", + Error: "loop_frequency_value (>= 1) is required when creating new loop configuration", }, nil } - if input.PeriodicFrequencyUnit == nil || *input.PeriodicFrequencyUnit == "" { + if input.LoopFrequencyUnit == nil || *input.LoopFrequencyUnit == "" { return nil, ConversationUpdateOutput{ Success: false, - Error: "periodic_frequency_unit is required when creating new periodic configuration", + Error: "loop_frequency_unit is required when creating new loop configuration", }, nil } var freqUnit session.FrequencyUnit - switch *input.PeriodicFrequencyUnit { + switch *input.LoopFrequencyUnit { case "minutes": freqUnit = session.FrequencyMinutes case "hours": @@ -4012,52 +4015,52 @@ func (s *Server) handleConversationUpdate(ctx context.Context, req *mcp.CallTool default: return nil, ConversationUpdateOutput{ Success: false, - Error: "periodic_frequency_unit must be 'minutes', 'hours', or 'days'", + Error: "loop_frequency_unit must be 'minutes', 'hours', or 'days'", }, nil } freq = session.Frequency{ - Value: *input.PeriodicFrequencyValue, + Value: *input.LoopFrequencyValue, Unit: freqUnit, } - if input.PeriodicFrequencyAt != nil { - freq.At = *input.PeriodicFrequencyAt + if input.LoopFrequencyAt != nil { + freq.At = *input.LoopFrequencyAt } if err := freq.Validate(); err != nil { return nil, ConversationUpdateOutput{ Success: false, - Error: fmt.Sprintf("invalid periodic frequency: %v", err), + Error: fmt.Sprintf("invalid loop frequency: %v", err), }, nil } } enabled := true - if input.PeriodicEnabled != nil { - enabled = *input.PeriodicEnabled + if input.LoopEnabled != nil { + enabled = *input.LoopEnabled } freshContext := false - if input.PeriodicFreshContext != nil { - freshContext = *input.PeriodicFreshContext + if input.LoopFreshContext != nil { + freshContext = *input.LoopFreshContext } maxIterations := 0 - if input.PeriodicMaxIterations != nil { - maxIterations = *input.PeriodicMaxIterations + if input.LoopMaxIterations != nil { + maxIterations = *input.LoopMaxIterations } delaySeconds := 0 - if input.PeriodicCompletionDelaySeconds != nil { - delaySeconds = *input.PeriodicCompletionDelaySeconds + if input.LoopCompletionDelaySeconds != nil { + delaySeconds = *input.LoopCompletionDelaySeconds } maxDurationSeconds := 0 - if input.PeriodicMaxDurationSeconds != nil { - maxDurationSeconds = *input.PeriodicMaxDurationSeconds + if input.LoopMaxDurationSeconds != nil { + maxDurationSeconds = *input.LoopMaxDurationSeconds } - periodic := &session.PeriodicPrompt{ - Prompt: *input.PeriodicPrompt, + loop := &session.LoopPrompt{ + Prompt: *input.LoopPrompt, Frequency: freq, Enabled: enabled, FreshContext: freshContext, @@ -4066,39 +4069,39 @@ func (s *Server) handleConversationUpdate(ctx context.Context, req *mcp.CallTool DelaySeconds: delaySeconds, MaxDurationSeconds: maxDurationSeconds, } - if input.PeriodicCondition != nil { - periodic.Condition = *input.PeriodicCondition + if input.LoopCondition != nil { + loop.Condition = *input.LoopCondition } - if input.PeriodicConditionPreset != nil { - periodic.ConditionPreset = *input.PeriodicConditionPreset + if input.LoopConditionPreset != nil { + loop.ConditionPreset = *input.LoopConditionPreset } // Clamp the on-completion delay to the global floor (no-op for schedule). - periodic.ClampDelay(s.periodicDelayFloor()) + loop.ClampDelay(s.loopDelayFloor()) - if err := periodicStore.Set(periodic); err != nil { + if err := loopStore.Set(loop); err != nil { return nil, ConversationUpdateOutput{ Success: false, - Error: fmt.Sprintf("failed to set periodic: %v", err), + Error: fmt.Sprintf("failed to set loop: %v", err), }, nil } } else { - // Updating existing periodic config — use partial update + // Updating existing loop config — use partial update var prompt *string var freq *session.Frequency var enabled *bool - if input.PeriodicPrompt != nil { - prompt = input.PeriodicPrompt + if input.LoopPrompt != nil { + prompt = input.LoopPrompt } - if input.PeriodicFrequencyValue != nil || input.PeriodicFrequencyUnit != nil || input.PeriodicFrequencyAt != nil { + if input.LoopFrequencyValue != nil || input.LoopFrequencyUnit != nil || input.LoopFrequencyAt != nil { // Build frequency from existing + overrides f := existing.Frequency - if input.PeriodicFrequencyValue != nil { - f.Value = *input.PeriodicFrequencyValue + if input.LoopFrequencyValue != nil { + f.Value = *input.LoopFrequencyValue } - if input.PeriodicFrequencyUnit != nil { - switch *input.PeriodicFrequencyUnit { + if input.LoopFrequencyUnit != nil { + switch *input.LoopFrequencyUnit { case "minutes": f.Unit = session.FrequencyMinutes case "hours": @@ -4108,32 +4111,32 @@ func (s *Server) handleConversationUpdate(ctx context.Context, req *mcp.CallTool default: return nil, ConversationUpdateOutput{ Success: false, - Error: "periodic_frequency_unit must be 'minutes', 'hours', or 'days'", + Error: "loop_frequency_unit must be 'minutes', 'hours', or 'days'", }, nil } } - if input.PeriodicFrequencyAt != nil { - f.At = *input.PeriodicFrequencyAt + if input.LoopFrequencyAt != nil { + f.At = *input.LoopFrequencyAt } freq = &f } - if input.PeriodicEnabled != nil { - enabled = input.PeriodicEnabled + if input.LoopEnabled != nil { + enabled = input.LoopEnabled } // On-completion fields (partial). Convert the trigger string to the typed pointer. - var trigger *session.PeriodicTrigger - if input.PeriodicTrigger != nil { - t := session.PeriodicTrigger(*input.PeriodicTrigger) + var trigger *session.LoopTrigger + if input.LoopTrigger != nil { + t := session.LoopTrigger(*input.LoopTrigger) trigger = &t } - delaySeconds := input.PeriodicCompletionDelaySeconds + delaySeconds := input.LoopCompletionDelaySeconds // Clamp the on-completion delay to the global floor on write. The effective // trigger is the patched value when provided, otherwise the stored one. if delaySeconds != nil { - floor := s.periodicDelayFloor() + floor := s.loopDelayFloor() if *delaySeconds < floor { effTrigger := existing.Trigger if trigger != nil { @@ -4146,60 +4149,60 @@ func (s *Server) handleConversationUpdate(ctx context.Context, req *mcp.CallTool } } - if err := periodicStore.Update(prompt, nil, freq, enabled, input.PeriodicFreshContext, input.PeriodicMaxIterations, trigger, delaySeconds, input.PeriodicMaxDurationSeconds, nil, input.PeriodicCondition, input.PeriodicConditionPreset, nil); err != nil { + if err := loopStore.Update(prompt, nil, freq, enabled, input.LoopFreshContext, input.LoopMaxIterations, trigger, delaySeconds, input.LoopMaxDurationSeconds, nil, input.LoopCondition, input.LoopConditionPreset, nil); err != nil { return nil, ConversationUpdateOutput{ Success: false, - Error: fmt.Sprintf("failed to update periodic: %v", err), + Error: fmt.Sprintf("failed to update loop: %v", err), }, nil } - // Agent self-disabled periodic — record it as a resumable "Paused by the agent" + // Agent self-disabled loop — record it as a resumable "Paused by the agent" // (amber) reason so the header pill is unambiguous. Re-enabling clears it. - if input.PeriodicEnabled != nil && !*input.PeriodicEnabled { - if err := periodicStore.MarkStopped(session.StoppedReasonDisabledByAgent); err != nil { + if input.LoopEnabled != nil && !*input.LoopEnabled { + if err := loopStore.MarkStopped(session.StoppedReasonDisabledByAgent); err != nil { s.logger.Warn("Failed to record disabledByAgent reason", "error", err) } } } - updated = append(updated, "periodic") + updated = append(updated, "loop") - // Broadcast the periodic state change so all clients refresh live (parity with REST paths). + // Broadcast the loop state change so all clients refresh live (parity with REST paths). if sm != nil { - if p, getErr := periodicStore.Get(); getErr == nil { - sm.BroadcastPeriodicUpdated(input.ConversationID, p) + if p, getErr := loopStore.Get(); getErr == nil { + sm.BroadcastLoopUpdated(input.ConversationID, p) } } // Kick off the very first run for a fresh onCompletion conversation. s.mu.RLock() - runner := s.periodicRunner + runner := s.loopRunner s.mu.RUnlock() if runner != nil { runner.BootstrapOnCompletion(input.ConversationID) } - // If the session has no title and a periodic prompt was set, trigger title generation. + // If the session has no title and a loop prompt was set, trigger title generation. if input.Name == nil && meta.Name == "" && sm != nil { if bs := sm.GetSession(input.ConversationID); bs != nil { var pPrompt, pName string - if input.PeriodicPrompt != nil { - pPrompt = *input.PeriodicPrompt + if input.LoopPrompt != nil { + pPrompt = *input.LoopPrompt } - // ConversationUpdateInput has no PeriodicPromptName field; read prompt name - // from the stored periodic config so the resolver can be used when inline + // ConversationUpdateInput has no LoopPromptName field; read prompt name + // from the stored loop config so the resolver can be used when inline // prompt is empty or the UI placeholder "(pending)". - if p, getErr := periodicStore.Get(); getErr == nil && p != nil { + if p, getErr := loopStore.Get(); getErr == nil && p != nil { if pPrompt == "" { pPrompt = p.Prompt } pName = p.PromptName } - bs.TriggerTitleGenerationFromPeriodic(pPrompt, pName) + bs.TriggerTitleGenerationFromLoop(pPrompt, pName) } } - s.logger.Info("Periodic configuration updated via MCP", + s.logger.Info("Loop configuration updated via MCP", "source_session", realSessionID, "target_conversation", input.ConversationID, "is_new", isNew) @@ -4209,7 +4212,7 @@ func (s *Server) handleConversationUpdate(ctx context.Context, req *mcp.CallTool if len(updated) == 0 { return nil, ConversationUpdateOutput{ Success: false, - Error: "no properties to update: specify at least one of 'name', 'beads_issue', 'user_data', or periodic fields", + Error: "no properties to update: specify at least one of 'name', 'beads_issue', 'user_data', or loop fields", }, nil } @@ -4233,23 +4236,23 @@ func (s *Server) handleConversationUpdate(ctx context.Context, req *mcp.CallTool } } - // Read back current periodic config - if p, err := store.Periodic(input.ConversationID).Get(); err == nil && p != nil { - output.PeriodicPrompt = p.Prompt - output.PeriodicFrequencyValue = p.Frequency.Value - output.PeriodicFrequencyUnit = string(p.Frequency.Unit) - output.PeriodicFrequencyAt = p.Frequency.At - output.PeriodicEnabled = p.Enabled - output.PeriodicFreshContext = p.FreshContext - output.PeriodicMaxIterations = p.MaxIterations - output.PeriodicIterationCount = p.IterationCount - output.PeriodicTrigger = string(p.EffectiveTrigger()) - output.PeriodicCompletionDelaySeconds = p.DelaySeconds - output.PeriodicMaxDurationSeconds = p.MaxDurationSeconds - output.PeriodicCondition = p.Condition - output.PeriodicConditionPreset = p.ConditionPreset + // Read back current loop config + if p, err := store.Loop(input.ConversationID).Get(); err == nil && p != nil { + output.LoopPrompt = p.Prompt + output.LoopFrequencyValue = p.Frequency.Value + output.LoopFrequencyUnit = string(p.Frequency.Unit) + output.LoopFrequencyAt = p.Frequency.At + output.LoopEnabled = p.Enabled + output.LoopFreshContext = p.FreshContext + output.LoopMaxIterations = p.MaxIterations + output.LoopIterationCount = p.IterationCount + output.LoopTrigger = string(p.EffectiveTrigger()) + output.LoopCompletionDelaySeconds = p.DelaySeconds + output.LoopMaxDurationSeconds = p.MaxDurationSeconds + output.LoopCondition = p.Condition + output.LoopConditionPreset = p.ConditionPreset if p.NextScheduledAt != nil { - output.PeriodicNextRun = p.NextScheduledAt.Format("2006-01-02T15:04:05Z07:00") + output.LoopNextRun = p.NextScheduledAt.Format("2006-01-02T15:04:05Z07:00") } } @@ -4737,7 +4740,7 @@ func (s *Server) handleChildrenTasksWait(ctx context.Context, req *mcp.CallToolR continue } - msg, err := queue.Add(promptText, nil, nil, realSessionID, nil, 0, nil, "") + msg, err := queue.AddWithOrigin(promptText, nil, nil, realSessionID, nil, 0, nil, "", session.QueueOriginAgent) if err != nil { s.logger.Warn("Failed to enqueue prompt to child", "parent_session", realSessionID, @@ -4783,6 +4786,17 @@ func (s *Server) handleChildrenTasksWait(ctx context.Context, req *mcp.CallToolR "not_running_children", len(notRunningChildren), "timeout", timeout) + // Record how long this call actually blocked, on the parent session, for the + // "child wait" statistics surfaced in the conversation properties panel. + waitStart := time.Now() + defer func() { + if s.sessionManager != nil { + if parentBS := s.sessionManager.GetSession(realSessionID); parentBS != nil { + parentBS.RecordChildWait(time.Since(waitStart)) + } + } + }() + var timedOut bool // childIdlePollInterval is how often we check if pending children are still responsive. diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 0c0b9614..46969165 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -983,7 +983,7 @@ func (m *mockSessionManager) DeleteChildSessions(parentID string) func (m *mockSessionManager) GetWorkspaces() []config.WorkspaceSettings { return nil } func (m *mockSessionManager) GetWorkspaceByUUID(uuid string) *config.WorkspaceSettings { return nil } func (m *mockSessionManager) BroadcastSessionRenamed(sessionID string, newName string) {} -func (m *mockSessionManager) BroadcastPeriodicUpdated(string, *session.PeriodicPrompt) {} +func (m *mockSessionManager) BroadcastLoopUpdated(string, *session.LoopPrompt) {} func (m *mockSessionManager) GetUserDataSchema(workingDir string) *config.UserDataSchema { return nil } func (m *mockSessionManager) GetWorkspacePrompts(workingDir string) []config.WebPrompt { return nil } func (m *mockSessionManager) GetWorkspacePromptsDirs(workingDir string) []string { return nil } @@ -3188,7 +3188,7 @@ func (m *mockSessionManagerForWorkspaces) GetWorkspaceByUUID(uuid string) *confi } func (m *mockSessionManagerForWorkspaces) BroadcastSessionRenamed(sessionID string, newName string) { } -func (m *mockSessionManagerForWorkspaces) BroadcastPeriodicUpdated(string, *session.PeriodicPrompt) { +func (m *mockSessionManagerForWorkspaces) BroadcastLoopUpdated(string, *session.LoopPrompt) { } func (m *mockSessionManagerForWorkspaces) GetUserDataSchema(workingDir string) *config.UserDataSchema { return nil @@ -3540,7 +3540,7 @@ func (m *mockSessionManagerForWorkspaceUpdate) GetWorkspaceByUUID(uuid string) * return nil } func (m *mockSessionManagerForWorkspaceUpdate) BroadcastSessionRenamed(string, string) {} -func (m *mockSessionManagerForWorkspaceUpdate) BroadcastPeriodicUpdated(string, *session.PeriodicPrompt) { +func (m *mockSessionManagerForWorkspaceUpdate) BroadcastLoopUpdated(string, *session.LoopPrompt) { } func (m *mockSessionManagerForWorkspaceUpdate) GetUserDataSchema(string) *config.UserDataSchema { return nil @@ -3842,12 +3842,13 @@ func (m *mockBackgroundSessionForWait) TryProcessQueuedMessage() bool { m.tryProcessCalledCount.Add(1) return false } -func (m *mockBackgroundSessionForWait) TriggerTitleGeneration(string) {} -func (m *mockBackgroundSessionForWait) TriggerTitleGenerationFromPeriodic(string, string) {} -func (m *mockBackgroundSessionForWait) RequestSelfDestruct() { m.selfDestructCalled.Store(true) } +func (m *mockBackgroundSessionForWait) TriggerTitleGeneration(string) {} +func (m *mockBackgroundSessionForWait) TriggerTitleGenerationFromLoop(string, string) {} +func (m *mockBackgroundSessionForWait) RequestSelfDestruct() { m.selfDestructCalled.Store(true) } func (m *mockBackgroundSessionForWait) LastQueuedSendError() (string, time.Time) { return "", time.Time{} } +func (m *mockBackgroundSessionForWait) RecordChildWait(time.Duration) {} func (m *mockBackgroundSessionForWait) WaitForResponseComplete(timeout time.Duration) bool { if !m.prompting.Load() { return true @@ -3888,19 +3889,19 @@ func (m *mockSessionManagerForWait) BroadcastSessionCreated(string, string, stri } func (m *mockSessionManagerForWait) BroadcastSessionArchived(string, bool, ...session.ArchiveReason) { } -func (m *mockSessionManagerForWait) BroadcastSessionDeleted(string) {} -func (m *mockSessionManagerForWait) BroadcastWaitingForChildren(string, bool) {} -func (m *mockSessionManagerForWait) DeleteChildSessions(string) {} -func (m *mockSessionManagerForWait) GetWorkspaces() []config.WorkspaceSettings { return nil } -func (m *mockSessionManagerForWait) GetWorkspaceByUUID(string) *config.WorkspaceSettings { return nil } -func (m *mockSessionManagerForWait) BroadcastSessionRenamed(string, string) {} -func (m *mockSessionManagerForWait) BroadcastPeriodicUpdated(string, *session.PeriodicPrompt) {} -func (m *mockSessionManagerForWait) GetUserDataSchema(string) *config.UserDataSchema { return nil } -func (m *mockSessionManagerForWait) GetWorkspacePrompts(string) []config.WebPrompt { return nil } -func (m *mockSessionManagerForWait) GetWorkspacePromptsDirs(string) []string { return nil } -func (m *mockSessionManagerForWait) GetWorkspaceRCLastModified(string) time.Time { return time.Time{} } -func (m *mockSessionManagerForWait) GetWorkspace(string) *config.WorkspaceSettings { return nil } -func (m *mockSessionManagerForWait) InvalidateWorkspaceRC(string) {} +func (m *mockSessionManagerForWait) BroadcastSessionDeleted(string) {} +func (m *mockSessionManagerForWait) BroadcastWaitingForChildren(string, bool) {} +func (m *mockSessionManagerForWait) DeleteChildSessions(string) {} +func (m *mockSessionManagerForWait) GetWorkspaces() []config.WorkspaceSettings { return nil } +func (m *mockSessionManagerForWait) GetWorkspaceByUUID(string) *config.WorkspaceSettings { return nil } +func (m *mockSessionManagerForWait) BroadcastSessionRenamed(string, string) {} +func (m *mockSessionManagerForWait) BroadcastLoopUpdated(string, *session.LoopPrompt) {} +func (m *mockSessionManagerForWait) GetUserDataSchema(string) *config.UserDataSchema { return nil } +func (m *mockSessionManagerForWait) GetWorkspacePrompts(string) []config.WebPrompt { return nil } +func (m *mockSessionManagerForWait) GetWorkspacePromptsDirs(string) []string { return nil } +func (m *mockSessionManagerForWait) GetWorkspaceRCLastModified(string) time.Time { return time.Time{} } +func (m *mockSessionManagerForWait) GetWorkspace(string) *config.WorkspaceSettings { return nil } +func (m *mockSessionManagerForWait) InvalidateWorkspaceRC(string) {} // setupServerForWait creates a server with a SessionManager mock for wait tool tests. func setupServerForWait(t *testing.T, targetID string, targetBS BackgroundSession) (*Server, string) { @@ -4802,11 +4803,11 @@ func (m *mockSessionManagerForChildren) GetWorkspaces() []config.WorkspaceSettin func (m *mockSessionManagerForChildren) GetWorkspaceByUUID(string) *config.WorkspaceSettings { return nil } -func (m *mockSessionManagerForChildren) BroadcastSessionRenamed(string, string) {} -func (m *mockSessionManagerForChildren) BroadcastPeriodicUpdated(string, *session.PeriodicPrompt) {} -func (m *mockSessionManagerForChildren) GetUserDataSchema(string) *config.UserDataSchema { return nil } -func (m *mockSessionManagerForChildren) GetWorkspacePrompts(string) []config.WebPrompt { return nil } -func (m *mockSessionManagerForChildren) GetWorkspacePromptsDirs(string) []string { return nil } +func (m *mockSessionManagerForChildren) BroadcastSessionRenamed(string, string) {} +func (m *mockSessionManagerForChildren) BroadcastLoopUpdated(string, *session.LoopPrompt) {} +func (m *mockSessionManagerForChildren) GetUserDataSchema(string) *config.UserDataSchema { return nil } +func (m *mockSessionManagerForChildren) GetWorkspacePrompts(string) []config.WebPrompt { return nil } +func (m *mockSessionManagerForChildren) GetWorkspacePromptsDirs(string) []string { return nil } func (m *mockSessionManagerForChildren) GetWorkspaceRCLastModified(string) time.Time { return time.Time{} } @@ -4997,7 +4998,7 @@ func (m *mockSessionManagerForChildrenMutable) GetWorkspaceByUUID(string) *confi return nil } func (m *mockSessionManagerForChildrenMutable) BroadcastSessionRenamed(string, string) {} -func (m *mockSessionManagerForChildrenMutable) BroadcastPeriodicUpdated(string, *session.PeriodicPrompt) { +func (m *mockSessionManagerForChildrenMutable) BroadcastLoopUpdated(string, *session.LoopPrompt) { } func (m *mockSessionManagerForChildrenMutable) GetUserDataSchema(string) *config.UserDataSchema { return nil @@ -5556,18 +5557,19 @@ type mockBackgroundSessionForAutoResume struct { tryProcessCalled atomic.Bool } -func (m *mockBackgroundSessionForAutoResume) IsPrompting() bool { return false } -func (m *mockBackgroundSessionForAutoResume) HasQueuedDeliveryInProgress() bool { return false } -func (m *mockBackgroundSessionForAutoResume) GetQueueConfig() *config.QueueConfig { return nil } -func (m *mockBackgroundSessionForAutoResume) GetEventCount() int { return 0 } -func (m *mockBackgroundSessionForAutoResume) GetMaxAssignedSeq() int64 { return 0 } -func (m *mockBackgroundSessionForAutoResume) WaitForResponseComplete(time.Duration) bool { return true } -func (m *mockBackgroundSessionForAutoResume) TriggerTitleGeneration(string) {} -func (m *mockBackgroundSessionForAutoResume) TriggerTitleGenerationFromPeriodic(string, string) {} -func (m *mockBackgroundSessionForAutoResume) RequestSelfDestruct() {} +func (m *mockBackgroundSessionForAutoResume) IsPrompting() bool { return false } +func (m *mockBackgroundSessionForAutoResume) HasQueuedDeliveryInProgress() bool { return false } +func (m *mockBackgroundSessionForAutoResume) GetQueueConfig() *config.QueueConfig { return nil } +func (m *mockBackgroundSessionForAutoResume) GetEventCount() int { return 0 } +func (m *mockBackgroundSessionForAutoResume) GetMaxAssignedSeq() int64 { return 0 } +func (m *mockBackgroundSessionForAutoResume) WaitForResponseComplete(time.Duration) bool { return true } +func (m *mockBackgroundSessionForAutoResume) TriggerTitleGeneration(string) {} +func (m *mockBackgroundSessionForAutoResume) TriggerTitleGenerationFromLoop(string, string) {} +func (m *mockBackgroundSessionForAutoResume) RequestSelfDestruct() {} func (m *mockBackgroundSessionForAutoResume) LastQueuedSendError() (string, time.Time) { return "", time.Time{} } +func (m *mockBackgroundSessionForAutoResume) RecordChildWait(time.Duration) {} func (m *mockBackgroundSessionForAutoResume) TryProcessQueuedMessage() bool { m.tryProcessCalled.Store(true) return false @@ -5644,7 +5646,7 @@ func (m *mockSessionManagerForAutoResume) GetWorkspaceByUUID(string) *config.Wor return nil } func (m *mockSessionManagerForAutoResume) BroadcastSessionRenamed(string, string) {} -func (m *mockSessionManagerForAutoResume) BroadcastPeriodicUpdated(string, *session.PeriodicPrompt) { +func (m *mockSessionManagerForAutoResume) BroadcastLoopUpdated(string, *session.LoopPrompt) { } func (m *mockSessionManagerForAutoResume) GetUserDataSchema(string) *config.UserDataSchema { return nil @@ -7124,7 +7126,7 @@ func (m *mockSessionManagerCrossWorkspace) GetWorkspaceByUUID(uuid string) *conf return m.workspaces[uuid] } func (m *mockSessionManagerCrossWorkspace) BroadcastSessionRenamed(string, string) {} -func (m *mockSessionManagerCrossWorkspace) BroadcastPeriodicUpdated(string, *session.PeriodicPrompt) { +func (m *mockSessionManagerCrossWorkspace) BroadcastLoopUpdated(string, *session.LoopPrompt) { } func (m *mockSessionManagerCrossWorkspace) GetUserDataSchema(string) *config.UserDataSchema { return nil @@ -8912,11 +8914,11 @@ func TestPromptUpdate_EnableDisableOnly(t *testing.T) { } // ============================================================================= -// handleRunPeriodicNow Tests +// handleRunLoopNow Tests // ============================================================================= -// mockPeriodicRunner is a mock implementation of PeriodicRunner for testing. -type mockPeriodicRunner struct { +// mockLoopRunner is a mock implementation of LoopRunner for testing. +type mockLoopRunner struct { mu sync.Mutex calls []triggerNowCall triggerErr error // if set, TriggerNow returns this error @@ -8928,21 +8930,21 @@ type triggerNowCall struct { resetTimer bool } -func (m *mockPeriodicRunner) TriggerNow(sessionID string, resetTimer bool) error { +func (m *mockLoopRunner) TriggerNow(sessionID string, resetTimer bool) error { m.mu.Lock() defer m.mu.Unlock() m.calls = append(m.calls, triggerNowCall{sessionID: sessionID, resetTimer: resetTimer}) return m.triggerErr } -func (m *mockPeriodicRunner) BootstrapOnCompletion(sessionID string) { +func (m *mockLoopRunner) BootstrapOnCompletion(sessionID string) { m.mu.Lock() defer m.mu.Unlock() m.bootstrapCalls = append(m.bootstrapCalls, sessionID) } -// setupRunPeriodicNowServer creates a server with a registered session and a mock runner. -func setupRunPeriodicNowServer(t *testing.T) (*Server, string, *mockPeriodicRunner) { +// setupRunLoopNowServer creates a server with a registered session and a mock runner. +func setupRunLoopNowServer(t *testing.T) (*Server, string, *mockLoopRunner) { t.Helper() tmpDir := t.TempDir() @@ -8972,24 +8974,24 @@ func setupRunPeriodicNowServer(t *testing.T) (*Server, string, *mockPeriodicRunn t.Fatalf("RegisterSession failed: %v", err) } - mock := &mockPeriodicRunner{} - srv.SetPeriodicRunner(mock) + mock := &mockLoopRunner{} + srv.SetLoopRunner(mock) return srv, sessionID, mock } -func TestHandleRunPeriodicNow_ResetTimerTrue(t *testing.T) { - srv, sessionID, mock := setupRunPeriodicNowServer(t) +func TestHandleRunLoopNow_ResetTimerTrue(t *testing.T) { + srv, sessionID, mock := setupRunLoopNowServer(t) ctx := context.Background() resetTimer := true - _, out, err := srv.handleRunPeriodicNow(ctx, nil, RunPeriodicNowInput{ + _, out, err := srv.handleRunLoopNow(ctx, nil, RunLoopNowInput{ SelfID: sessionID, ConversationID: sessionID, ResetTimer: &resetTimer, }) if err != nil { - t.Fatalf("handleRunPeriodicNow returned error: %v", err) + t.Fatalf("handleRunLoopNow returned error: %v", err) } if !out.Success { t.Errorf("Expected success=true, got error: %s", out.Error) @@ -9005,18 +9007,18 @@ func TestHandleRunPeriodicNow_ResetTimerTrue(t *testing.T) { } } -func TestHandleRunPeriodicNow_ResetTimerFalse(t *testing.T) { - srv, sessionID, mock := setupRunPeriodicNowServer(t) +func TestHandleRunLoopNow_ResetTimerFalse(t *testing.T) { + srv, sessionID, mock := setupRunLoopNowServer(t) ctx := context.Background() resetTimer := false - _, out, err := srv.handleRunPeriodicNow(ctx, nil, RunPeriodicNowInput{ + _, out, err := srv.handleRunLoopNow(ctx, nil, RunLoopNowInput{ SelfID: sessionID, ConversationID: sessionID, ResetTimer: &resetTimer, }) if err != nil { - t.Fatalf("handleRunPeriodicNow returned error: %v", err) + t.Fatalf("handleRunLoopNow returned error: %v", err) } if !out.Success { t.Errorf("Expected success=true, got error: %s", out.Error) @@ -9035,17 +9037,17 @@ func TestHandleRunPeriodicNow_ResetTimerFalse(t *testing.T) { } } -func TestHandleRunPeriodicNow_DefaultsResetTimerToTrue(t *testing.T) { - srv, sessionID, mock := setupRunPeriodicNowServer(t) +func TestHandleRunLoopNow_DefaultsResetTimerToTrue(t *testing.T) { + srv, sessionID, mock := setupRunLoopNowServer(t) ctx := context.Background() // Do NOT set ResetTimer — should default to true - _, out, err := srv.handleRunPeriodicNow(ctx, nil, RunPeriodicNowInput{ + _, out, err := srv.handleRunLoopNow(ctx, nil, RunLoopNowInput{ SelfID: sessionID, ConversationID: sessionID, }) if err != nil { - t.Fatalf("handleRunPeriodicNow returned error: %v", err) + t.Fatalf("handleRunLoopNow returned error: %v", err) } if !out.Success { t.Errorf("Expected success=true, got error: %s", out.Error) @@ -9061,53 +9063,53 @@ func TestHandleRunPeriodicNow_DefaultsResetTimerToTrue(t *testing.T) { } } -func TestHandleRunPeriodicNow_NoPeriodicRunner(t *testing.T) { - srv, sessionID, _ := setupRunPeriodicNowServer(t) +func TestHandleRunLoopNow_NoLoopRunner(t *testing.T) { + srv, sessionID, _ := setupRunLoopNowServer(t) // Remove the runner to simulate it not being available - srv.SetPeriodicRunner(nil) + srv.SetLoopRunner(nil) ctx := context.Background() - _, out, err := srv.handleRunPeriodicNow(ctx, nil, RunPeriodicNowInput{ + _, out, err := srv.handleRunLoopNow(ctx, nil, RunLoopNowInput{ SelfID: sessionID, ConversationID: sessionID, }) if err != nil { - t.Fatalf("handleRunPeriodicNow returned error: %v", err) + t.Fatalf("handleRunLoopNow returned error: %v", err) } if out.Success { - t.Error("Expected failure when periodic runner is nil") + t.Error("Expected failure when loop runner is nil") } if out.Error == "" { - t.Error("Expected non-empty error message when periodic runner is nil") + t.Error("Expected non-empty error message when loop runner is nil") } } -func TestHandleRunPeriodicNow_MissingSelfID(t *testing.T) { - srv, _, _ := setupRunPeriodicNowServer(t) +func TestHandleRunLoopNow_MissingSelfID(t *testing.T) { + srv, _, _ := setupRunLoopNowServer(t) ctx := context.Background() - _, out, err := srv.handleRunPeriodicNow(ctx, nil, RunPeriodicNowInput{ + _, out, err := srv.handleRunLoopNow(ctx, nil, RunLoopNowInput{ SelfID: "", ConversationID: "some-session", }) if err != nil { - t.Fatalf("handleRunPeriodicNow returned error: %v", err) + t.Fatalf("handleRunLoopNow returned error: %v", err) } if out.Success { t.Error("Expected failure with empty self_id") } } -func TestHandleRunPeriodicNow_MissingConversationID(t *testing.T) { - srv, sessionID, _ := setupRunPeriodicNowServer(t) +func TestHandleRunLoopNow_MissingConversationID(t *testing.T) { + srv, sessionID, _ := setupRunLoopNowServer(t) ctx := context.Background() - _, out, err := srv.handleRunPeriodicNow(ctx, nil, RunPeriodicNowInput{ + _, out, err := srv.handleRunLoopNow(ctx, nil, RunLoopNowInput{ SelfID: sessionID, ConversationID: "", }) if err != nil { - t.Fatalf("handleRunPeriodicNow returned error: %v", err) + t.Fatalf("handleRunLoopNow returned error: %v", err) } if out.Success { t.Error("Expected failure with empty conversation_id") @@ -9485,11 +9487,11 @@ func TestSendPrompt_InvalidTemplate_Rejected(t *testing.T) { } } -// TestConversationUpdate_OnCompletionPeriodic verifies the MCP _update tool can create an -// on-completion periodic conversation (no frequency required) with a completion delay and +// TestConversationUpdate_OnCompletionLoop verifies the MCP _update tool can create an +// on-completion loop conversation (no frequency required) with a completion delay and // max-duration cap, and that a partial update clamps the delay to the floor without clobbering // the other on-completion fields. -func TestConversationUpdate_OnCompletionPeriodic(t *testing.T) { +func TestConversationUpdate_OnCompletionLoop(t *testing.T) { store, srv, parentID := setupConversationStartServer(t) ctx := context.Background() @@ -9498,14 +9500,14 @@ func TestConversationUpdate_OnCompletionPeriodic(t *testing.T) { delay := 30 maxDur := 3600 - // Create a new on-completion periodic config via MCP (isNew path, no frequency). + // Create a new on-completion loop config via MCP (isNew path, no frequency). _, out, err := srv.handleConversationUpdate(ctx, nil, ConversationUpdateInput{ - SelfID: parentID, - ConversationID: parentID, - PeriodicPrompt: &prompt, - PeriodicTrigger: &trigger, - PeriodicCompletionDelaySeconds: &delay, - PeriodicMaxDurationSeconds: &maxDur, + SelfID: parentID, + ConversationID: parentID, + LoopPrompt: &prompt, + LoopTrigger: &trigger, + LoopCompletionDelaySeconds: &delay, + LoopMaxDurationSeconds: &maxDur, }) if err != nil { t.Fatalf("handleConversationUpdate error: %v", err) @@ -9513,20 +9515,20 @@ func TestConversationUpdate_OnCompletionPeriodic(t *testing.T) { if !out.Success { t.Fatalf("update not successful: %s", out.Error) } - if out.PeriodicTrigger != string(session.TriggerOnCompletion) { - t.Errorf("output PeriodicTrigger = %q, want %q", out.PeriodicTrigger, session.TriggerOnCompletion) + if out.LoopTrigger != string(session.TriggerOnCompletion) { + t.Errorf("output LoopTrigger = %q, want %q", out.LoopTrigger, session.TriggerOnCompletion) } - if out.PeriodicCompletionDelaySeconds != 30 { - t.Errorf("output PeriodicCompletionDelaySeconds = %d, want 30", out.PeriodicCompletionDelaySeconds) + if out.LoopCompletionDelaySeconds != 30 { + t.Errorf("output LoopCompletionDelaySeconds = %d, want 30", out.LoopCompletionDelaySeconds) } - if out.PeriodicMaxDurationSeconds != 3600 { - t.Errorf("output PeriodicMaxDurationSeconds = %d, want 3600", out.PeriodicMaxDurationSeconds) + if out.LoopMaxDurationSeconds != 3600 { + t.Errorf("output LoopMaxDurationSeconds = %d, want 3600", out.LoopMaxDurationSeconds) } // Verify the stored config persisted the on-completion fields (no frequency needed). - stored, err := store.Periodic(parentID).Get() + stored, err := store.Loop(parentID).Get() if err != nil { - t.Fatalf("Get periodic: %v", err) + t.Fatalf("Get loop: %v", err) } if !stored.IsOnCompletion() { t.Errorf("stored trigger = %q, want onCompletion", stored.Trigger) @@ -9538,9 +9540,9 @@ func TestConversationUpdate_OnCompletionPeriodic(t *testing.T) { // Partial update: lower the delay below the floor → clamped; max-duration preserved. below := 1 _, out2, err := srv.handleConversationUpdate(ctx, nil, ConversationUpdateInput{ - SelfID: parentID, - ConversationID: parentID, - PeriodicCompletionDelaySeconds: &below, + SelfID: parentID, + ConversationID: parentID, + LoopCompletionDelaySeconds: &below, }) if err != nil { t.Fatalf("handleConversationUpdate (patch) error: %v", err) @@ -9548,40 +9550,40 @@ func TestConversationUpdate_OnCompletionPeriodic(t *testing.T) { if !out2.Success { t.Fatalf("patch not successful: %s", out2.Error) } - if out2.PeriodicCompletionDelaySeconds != srv.periodicDelayFloor() { - t.Errorf("patched delay = %d, want clamped to floor %d", out2.PeriodicCompletionDelaySeconds, srv.periodicDelayFloor()) + if out2.LoopCompletionDelaySeconds != srv.loopDelayFloor() { + t.Errorf("patched delay = %d, want clamped to floor %d", out2.LoopCompletionDelaySeconds, srv.loopDelayFloor()) } - if out2.PeriodicMaxDurationSeconds != 3600 { - t.Errorf("patched maxDur = %d, want preserved 3600", out2.PeriodicMaxDurationSeconds) + if out2.LoopMaxDurationSeconds != 3600 { + t.Errorf("patched maxDur = %d, want preserved 3600", out2.LoopMaxDurationSeconds) } } -// TestConversationStart_OnTasksPeriodic verifies that mitto_conversation_new accepts -// periodic_trigger:"onTasks" (no frequency required) and persists periodic_condition -// (+ periodic_condition_preset) on the new conversation. -func TestConversationStart_OnTasksPeriodic(t *testing.T) { +// TestConversationStart_OnTasksLoop verifies that mitto_conversation_new accepts +// loop_trigger:"onTasks" (no frequency required) and persists loop_condition +// (+ loop_condition_preset) on the new conversation. +func TestConversationStart_OnTasksLoop(t *testing.T) { store, srv, parentID := setupConversationStartServer(t) ctx := context.Background() cond := `Changes.Touched.exists(i, i.type == "bug")` _, output, err := srv.handleConversationStart(ctx, nil, ConversationStartInput{ - SelfID: parentID, - Title: "onTasks child", - PeriodicPrompt: "review beads changes", - PeriodicTrigger: string(session.TriggerOnTasks), - PeriodicCondition: cond, - PeriodicConditionPreset: "bug-touched", + SelfID: parentID, + Title: "onTasks child", + LoopPrompt: "review beads changes", + LoopTrigger: string(session.TriggerOnTasks), + LoopCondition: cond, + LoopConditionPreset: "bug-touched", }) if err != nil { t.Fatalf("handleConversationStart error: %v", err) } - if !output.PeriodicConfigured { - t.Fatalf("expected periodic to be configured: %s", output.Error) + if !output.LoopConfigured { + t.Fatalf("expected loop to be configured: %s", output.Error) } - stored, err := store.Periodic(output.SessionID).Get() + stored, err := store.Loop(output.SessionID).Get() if err != nil { - t.Fatalf("Get periodic: %v", err) + t.Fatalf("Get loop: %v", err) } if !stored.IsOnTasks() { t.Errorf("stored trigger = %q, want onTasks", stored.Trigger) @@ -9594,11 +9596,11 @@ func TestConversationStart_OnTasksPeriodic(t *testing.T) { } } -// TestConversationUpdate_OnTasksPeriodic verifies that mitto_conversation_update can -// create an onTasks periodic conversation (no frequency required) with a CEL condition, +// TestConversationUpdate_OnTasksLoop verifies that mitto_conversation_update can +// create an onTasks loop conversation (no frequency required) with a CEL condition, // and that a subsequent partial update can change just the condition_preset without // clobbering the condition or trigger. -func TestConversationUpdate_OnTasksPeriodic(t *testing.T) { +func TestConversationUpdate_OnTasksLoop(t *testing.T) { store, srv, parentID := setupConversationStartServer(t) ctx := context.Background() @@ -9607,11 +9609,11 @@ func TestConversationUpdate_OnTasksPeriodic(t *testing.T) { cond := `Tasks.Open > Prev.Open` _, out, err := srv.handleConversationUpdate(ctx, nil, ConversationUpdateInput{ - SelfID: parentID, - ConversationID: parentID, - PeriodicPrompt: &prompt, - PeriodicTrigger: &trigger, - PeriodicCondition: &cond, + SelfID: parentID, + ConversationID: parentID, + LoopPrompt: &prompt, + LoopTrigger: &trigger, + LoopCondition: &cond, }) if err != nil { t.Fatalf("handleConversationUpdate error: %v", err) @@ -9619,16 +9621,16 @@ func TestConversationUpdate_OnTasksPeriodic(t *testing.T) { if !out.Success { t.Fatalf("update not successful: %s", out.Error) } - if out.PeriodicTrigger != string(session.TriggerOnTasks) { - t.Errorf("output PeriodicTrigger = %q, want %q", out.PeriodicTrigger, session.TriggerOnTasks) + if out.LoopTrigger != string(session.TriggerOnTasks) { + t.Errorf("output LoopTrigger = %q, want %q", out.LoopTrigger, session.TriggerOnTasks) } - if out.PeriodicCondition != cond { - t.Errorf("output PeriodicCondition = %q, want %q", out.PeriodicCondition, cond) + if out.LoopCondition != cond { + t.Errorf("output LoopCondition = %q, want %q", out.LoopCondition, cond) } - stored, err := store.Periodic(parentID).Get() + stored, err := store.Loop(parentID).Get() if err != nil { - t.Fatalf("Get periodic: %v", err) + t.Fatalf("Get loop: %v", err) } if !stored.IsOnTasks() { t.Errorf("stored trigger = %q, want onTasks", stored.Trigger) @@ -9640,9 +9642,9 @@ func TestConversationUpdate_OnTasksPeriodic(t *testing.T) { // Partial update: change only the condition preset; condition/trigger must be preserved. preset := "bug-only" _, out2, err := srv.handleConversationUpdate(ctx, nil, ConversationUpdateInput{ - SelfID: parentID, - ConversationID: parentID, - PeriodicConditionPreset: &preset, + SelfID: parentID, + ConversationID: parentID, + LoopConditionPreset: &preset, }) if err != nil { t.Fatalf("handleConversationUpdate (patch) error: %v", err) @@ -9650,14 +9652,14 @@ func TestConversationUpdate_OnTasksPeriodic(t *testing.T) { if !out2.Success { t.Fatalf("patch not successful: %s", out2.Error) } - if out2.PeriodicConditionPreset != preset { - t.Errorf("patched preset = %q, want %q", out2.PeriodicConditionPreset, preset) + if out2.LoopConditionPreset != preset { + t.Errorf("patched preset = %q, want %q", out2.LoopConditionPreset, preset) } - if out2.PeriodicCondition != cond { - t.Errorf("patched condition should be preserved = %q, want %q", out2.PeriodicCondition, cond) + if out2.LoopCondition != cond { + t.Errorf("patched condition should be preserved = %q, want %q", out2.LoopCondition, cond) } - if out2.PeriodicTrigger != string(session.TriggerOnTasks) { - t.Errorf("patched trigger should be preserved = %q, want %q", out2.PeriodicTrigger, session.TriggerOnTasks) + if out2.LoopTrigger != string(session.TriggerOnTasks) { + t.Errorf("patched trigger should be preserved = %q, want %q", out2.LoopTrigger, session.TriggerOnTasks) } } @@ -9680,11 +9682,11 @@ func TestConversationUpdate_OnTasksInvalidConditionRejected(t *testing.T) { cond := `this is not valid CEL` _, out, err := srv.handleConversationUpdate(ctx, nil, ConversationUpdateInput{ - SelfID: parentID, - ConversationID: parentID, - PeriodicPrompt: &prompt, - PeriodicTrigger: &trigger, - PeriodicCondition: &cond, + SelfID: parentID, + ConversationID: parentID, + LoopPrompt: &prompt, + LoopTrigger: &trigger, + LoopCondition: &cond, }) if err != nil { t.Fatalf("handleConversationUpdate unexpected transport error: %v", err) @@ -9698,25 +9700,25 @@ func TestConversationUpdate_OnTasksInvalidConditionRejected(t *testing.T) { } // TestConversationUpdate_SelfAlias verifies that a conversation can update itself by -// passing "self" as the conversation_id — the case where a periodic conversation -// disables its own periodicity. The "self" alias must resolve to the caller's real +// passing "self" as the conversation_id — the case where a loop conversation +// disables its own loop. The "self" alias must resolve to the caller's real // session ID, mirroring mitto_conversation_delete's self-targeting support. func TestConversationUpdate_SelfAlias(t *testing.T) { store, srv, sessionID := setupConversationStartServer(t) ctx := context.Background() - // Seed an enabled scheduled periodic config on the calling session. + // Seed an enabled scheduled loop config on the calling session. prompt := "keep going" freqValue := 1 freqUnit := "hours" enabled := true _, out, err := srv.handleConversationUpdate(ctx, nil, ConversationUpdateInput{ - SelfID: sessionID, - ConversationID: sessionID, - PeriodicPrompt: &prompt, - PeriodicFrequencyValue: &freqValue, - PeriodicFrequencyUnit: &freqUnit, - PeriodicEnabled: &enabled, + SelfID: sessionID, + ConversationID: sessionID, + LoopPrompt: &prompt, + LoopFrequencyValue: &freqValue, + LoopFrequencyUnit: &freqUnit, + LoopEnabled: &enabled, }) if err != nil { t.Fatalf("handleConversationUpdate (seed) error: %v", err) @@ -9725,12 +9727,12 @@ func TestConversationUpdate_SelfAlias(t *testing.T) { t.Fatalf("seed update not successful: %s", out.Error) } - // Disable periodicity using the "self" alias instead of the real ID. + // Disable the loop using the "self" alias instead of the real ID. disabled := false _, selfOut, err := srv.handleConversationUpdate(ctx, nil, ConversationUpdateInput{ - SelfID: sessionID, - ConversationID: "self", - PeriodicEnabled: &disabled, + SelfID: sessionID, + ConversationID: "self", + LoopEnabled: &disabled, }) if err != nil { t.Fatalf("handleConversationUpdate (self) error: %v", err) @@ -9743,13 +9745,13 @@ func TestConversationUpdate_SelfAlias(t *testing.T) { t.Errorf("output ConversationID = %q, want resolved real ID %q", selfOut.ConversationID, sessionID) } - // Verify the stored periodic config is now disabled. - stored, err := store.Periodic(sessionID).Get() + // Verify the stored loop config is now disabled. + stored, err := store.Loop(sessionID).Get() if err != nil { - t.Fatalf("Get periodic: %v", err) + t.Fatalf("Get loop: %v", err) } if stored.Enabled { - t.Error("expected periodic config to be disabled after self update, but it is still enabled") + t.Error("expected loop config to be disabled after self update, but it is still enabled") } } diff --git a/internal/mcpserver/types.go b/internal/mcpserver/types.go index d25815ae..d7ca90d6 100644 --- a/internal/mcpserver/types.go +++ b/internal/mcpserver/types.go @@ -50,7 +50,7 @@ type ConversationInfo struct { LockStatus string `json:"lock_status,omitempty"` LockClientType string `json:"lock_client_type,omitempty"` LastSeq int64 `json:"last_seq,omitempty"` - IsPeriodic bool `json:"is_periodic"` + IsLoop bool `json:"is_loop"` ChildOrigin string `json:"child_origin,omitempty"` BeadsIssue string `json:"beads_issue,omitempty"` // Linked beads issue ID (e.g. "mitto-123"), empty if none } @@ -86,7 +86,7 @@ type ConversationDetails struct { // Parent/child relationship ParentSessionID string `json:"parent_session_id,omitempty"` // Parent session if this is a child conversation ChildOrigin string `json:"child_origin,omitempty"` // How this child was created: "auto", "mcp", or "human" (empty for top-level) - IsPeriodic bool `json:"is_periodic"` // Whether the conversation has an active periodic prompt + IsLoop bool `json:"is_loop"` // Whether the conversation has an active loop prompt // Available ACP servers that can be used when creating new conversations from this session AvailableACPServers []AvailableACPServer `json:"available_acp_servers,omitempty"` @@ -352,29 +352,29 @@ type ConversationUpdateInput struct { UserData []UserDataAttributeUpdate `json:"user_data,omitempty"` // User data attributes to set UserDataMerge *bool `json:"user_data_merge,omitempty"` // If true (default), merge with existing; if false, replace all - // Periodic configuration — optional, only applied if any periodic field is non-nil - PeriodicPrompt *string `json:"periodic_prompt,omitempty"` // The prompt to send periodically - PeriodicFrequencyValue *int `json:"periodic_frequency_value,omitempty"` // Number of units between sends - PeriodicFrequencyUnit *string `json:"periodic_frequency_unit,omitempty"` // Time unit: "minutes", "hours", or "days" - PeriodicFrequencyAt *string `json:"periodic_frequency_at,omitempty"` // Time of day HH:MM (UTC), only for "days" - PeriodicEnabled *bool `json:"periodic_enabled,omitempty"` // Whether periodic is active (defaults to true) - PeriodicFreshContext *bool `json:"periodic_fresh_context,omitempty"` // Start each run with a fresh agent context (default false) - PeriodicMaxIterations *int `json:"periodic_max_iterations,omitempty"` // Maximum number of scheduled runs (0 = unlimited) - // PeriodicTrigger selects how the prompt fires: "schedule" (frequency-based, default), + // Loop configuration — optional, only applied if any loop field is non-nil + LoopPrompt *string `json:"loop_prompt,omitempty"` // The prompt to send in the loop + LoopFrequencyValue *int `json:"loop_frequency_value,omitempty"` // Number of units between sends + LoopFrequencyUnit *string `json:"loop_frequency_unit,omitempty"` // Time unit: "minutes", "hours", or "days" + LoopFrequencyAt *string `json:"loop_frequency_at,omitempty"` // Time of day HH:MM (UTC), only for "days" + LoopEnabled *bool `json:"loop_enabled,omitempty"` // Whether the loop is active (defaults to true) + LoopFreshContext *bool `json:"loop_fresh_context,omitempty"` // Start each run with a fresh agent context (default false) + LoopMaxIterations *int `json:"loop_max_iterations,omitempty"` // Maximum number of scheduled runs (0 = unlimited) + // LoopTrigger selects how the prompt fires: "schedule" (frequency-based, default), // "onCompletion" (event-driven: fire after the agent stops responding + the completion delay), // or "onTasks" (event-driven: fire when beads/tasks in the workspace change, optionally - // gated by periodic_condition). - PeriodicTrigger *string `json:"periodic_trigger,omitempty"` - // PeriodicCompletionDelaySeconds is the wait (seconds) after the agent stops before the next + // gated by loop_condition). + LoopTrigger *string `json:"loop_trigger,omitempty"` + // LoopCompletionDelaySeconds is the wait (seconds) after the agent stops before the next // run; only meaningful for the onCompletion trigger. Clamped to the global floor on write. - PeriodicCompletionDelaySeconds *int `json:"periodic_completion_delay_seconds,omitempty"` - // PeriodicMaxDurationSeconds is the wall-clock cap (seconds) since iterating started (0 = unlimited). - PeriodicMaxDurationSeconds *int `json:"periodic_max_duration_seconds,omitempty"` - // PeriodicCondition is a CEL expression gating onTasks firing (only meaningful when - // periodic_trigger is "onTasks"). Empty means fire on ANY beads/task change. - PeriodicCondition *string `json:"periodic_condition,omitempty"` - // PeriodicConditionPreset is an optional UI preset id that was compiled into periodic_condition. - PeriodicConditionPreset *string `json:"periodic_condition_preset,omitempty"` + LoopCompletionDelaySeconds *int `json:"loop_completion_delay_seconds,omitempty"` + // LoopMaxDurationSeconds is the wall-clock cap (seconds) since iterating started (0 = unlimited). + LoopMaxDurationSeconds *int `json:"loop_max_duration_seconds,omitempty"` + // LoopCondition is a CEL expression gating onTasks firing (only meaningful when + // loop_trigger is "onTasks"). Empty means fire on ANY beads/task change. + LoopCondition *string `json:"loop_condition,omitempty"` + // LoopConditionPreset is an optional UI preset id that was compiled into loop_condition. + LoopConditionPreset *string `json:"loop_condition_preset,omitempty"` } // UserDataAttributeUpdate represents a single user data attribute to set. @@ -391,24 +391,24 @@ type ConversationUpdateOutput struct { Name string `json:"name,omitempty"` // Current name after update BeadsIssue string `json:"beads_issue,omitempty"` // Current linked beads issue ID after update UserData []UserDataAttributeUpdate `json:"user_data,omitempty"` // Current user data after update - // Periodic configuration (returned when periodic is configured) - PeriodicPrompt string `json:"periodic_prompt,omitempty"` - PeriodicFrequencyValue int `json:"periodic_frequency_value,omitempty"` - PeriodicFrequencyUnit string `json:"periodic_frequency_unit,omitempty"` - PeriodicFrequencyAt string `json:"periodic_frequency_at,omitempty"` - PeriodicEnabled bool `json:"periodic_enabled"` - PeriodicFreshContext bool `json:"periodic_fresh_context,omitempty"` - PeriodicMaxIterations int `json:"periodic_max_iterations,omitempty"` - PeriodicIterationCount int `json:"periodic_iteration_count,omitempty"` - PeriodicNextRun string `json:"periodic_next_run,omitempty"` // RFC3339 format + // Loop configuration (returned when the loop is configured) + LoopPrompt string `json:"loop_prompt,omitempty"` + LoopFrequencyValue int `json:"loop_frequency_value,omitempty"` + LoopFrequencyUnit string `json:"loop_frequency_unit,omitempty"` + LoopFrequencyAt string `json:"loop_frequency_at,omitempty"` + LoopEnabled bool `json:"loop_enabled"` + LoopFreshContext bool `json:"loop_fresh_context,omitempty"` + LoopMaxIterations int `json:"loop_max_iterations,omitempty"` + LoopIterationCount int `json:"loop_iteration_count,omitempty"` + LoopNextRun string `json:"loop_next_run,omitempty"` // RFC3339 format // On-completion trigger fields (returned when configured) - PeriodicTrigger string `json:"periodic_trigger,omitempty"` - PeriodicCompletionDelaySeconds int `json:"periodic_completion_delay_seconds,omitempty"` - PeriodicMaxDurationSeconds int `json:"periodic_max_duration_seconds,omitempty"` + LoopTrigger string `json:"loop_trigger,omitempty"` + LoopCompletionDelaySeconds int `json:"loop_completion_delay_seconds,omitempty"` + LoopMaxDurationSeconds int `json:"loop_max_duration_seconds,omitempty"` // onTasks trigger fields (returned when configured) - PeriodicCondition string `json:"periodic_condition,omitempty"` - PeriodicConditionPreset string `json:"periodic_condition_preset,omitempty"` - Error string `json:"error,omitempty"` + LoopCondition string `json:"loop_condition,omitempty"` + LoopConditionPreset string `json:"loop_condition_preset,omitempty"` + Error string `json:"error,omitempty"` } // UITextboxInput is the input for the mitto_ui_textbox tool. @@ -887,7 +887,7 @@ type PromptInfo struct { Icon string `json:"icon,omitempty"` Source string `json:"source,omitempty"` // "file", "settings", "workspace", "builtin" Enabled *bool `json:"enabled,omitempty"` // nil = enabled (default true) - Periodic *config.PromptPeriodic `json:"periodic,omitempty"` // non-nil = prompt starts a periodic conversation + Loop *config.PromptLoop `json:"loop,omitempty"` // non-nil = prompt starts a loop conversation Parameters []config.PromptParameter `json:"parameters,omitempty"` // Declared typed input parameters (omitted when empty) } @@ -916,7 +916,7 @@ type PromptDetail struct { Icon string `json:"icon,omitempty"` Source string `json:"source,omitempty"` // "file", "settings", "workspace", "builtin" Enabled *bool `json:"enabled,omitempty"` // nil = enabled (default true) - Periodic *config.PromptPeriodic `json:"periodic,omitempty"` // non-nil = prompt starts a periodic conversation + Loop *config.PromptLoop `json:"loop,omitempty"` // non-nil = prompt starts a loop conversation Parameters []config.PromptParameter `json:"parameters,omitempty"` // Declared typed input parameters (omitted when empty) } diff --git a/internal/processors/executor.go b/internal/processors/executor.go index e0b8e701..5fab21ed 100644 --- a/internal/processors/executor.go +++ b/internal/processors/executor.go @@ -172,7 +172,7 @@ func (e *Executor) prepareInput(proc *Processor, input *ProcessorInput) ([]byte, WorkspaceUUID string `json:"workspace_uuid,omitempty"` AvailableACPServers []AvailableACPServer `json:"available_acp_servers,omitempty"` ChildSessions []ChildSession `json:"child_sessions,omitempty"` - IsPeriodic bool `json:"is_periodic,omitempty"` + IsLoop bool `json:"is_loop,omitempty"` }{ Message: input.Message, IsFirstMessage: input.IsFirstMessage, @@ -185,7 +185,7 @@ func (e *Executor) prepareInput(proc *Processor, input *ProcessorInput) ([]byte, WorkspaceUUID: input.WorkspaceUUID, AvailableACPServers: input.AvailableACPServers, ChildSessions: input.ChildSessions, - IsPeriodic: input.IsPeriodic, + IsLoop: input.IsLoop, } return json.Marshal(msgInput) } diff --git a/internal/processors/hook.go b/internal/processors/hook.go index ccfc7049..e5d55e2d 100644 --- a/internal/processors/hook.go +++ b/internal/processors/hook.go @@ -178,14 +178,14 @@ func BuildCELContext(input *ProcessorInput) *config.PromptEnabledContext { ctx.Session.Name = input.SessionName ctx.Session.IsChild = input.ParentSessionID != "" ctx.Session.ParentID = input.ParentSessionID - ctx.Session.IsPeriodic = input.IsPeriodic - ctx.Session.IsPeriodicForced = input.IsPeriodicForced + ctx.Session.IsLoop = input.IsLoop + ctx.Session.IsLoopForced = input.IsLoopForced ctx.Session.BeadsIssue = input.BeadsIssue // Iteration context for the {{ .Iteration.* }} template namespace. ctx.Iteration.Number = input.IterationNumber ctx.Iteration.Max = input.MaxIterations - ctx.Iteration.IsPeriodic = input.IsPeriodic + ctx.Iteration.IsLoop = input.IsLoop ctx.Iteration.IsFirst = input.IterationNumber == 0 ctx.Iteration.IsLast = input.MaxIterations > 0 && input.IterationNumber == input.MaxIterations-1 ctx.Iteration.IsUninterrupted = input.IterationUninterrupted @@ -266,11 +266,12 @@ func BuildCELContext(input *ProcessorInput) *config.PromptEnabledContext { ctx.Session.ModelName = input.ModelName // Tools context. Processors evaluate at message-processing time, where the - // tool list is treated as known (the cache is warmed on connect). Mark it - // Available so tool-pattern functions use name-based matching rather than the - // warm-up fail-open path used by the prompt menus. - ctx.Tools.Available = true - ctx.Tools.Names = input.MCPToolNames + // tool list is treated as known (the cache is warmed on connect) but no + // real per-server identity is available. NewProcessorToolsContext marks a + // catch-all server Reachable so tool-pattern functions always use + // name-based matching (fail-closed) rather than the per-server warm-up + // fail-open grace used by the prompt menus (mitto-sys.1). + ctx.Tools = config.NewProcessorToolsContext(input.MCPToolNames) // Permissions context - resolve flags with defaults ctx.Permissions.CanDoIntrospection = session.GetFlagValue(input.AdvancedSettings, session.FlagCanDoIntrospection) diff --git a/internal/processors/input.go b/internal/processors/input.go index 9c7748e9..50e29e98 100644 --- a/internal/processors/input.go +++ b/internal/processors/input.go @@ -45,22 +45,22 @@ type ProcessorInput struct { // Used for Tools.* CEL context in enabledWhen expressions. // May be empty if tools haven't been fetched yet. MCPToolNames []string `json:"-"` - // IsPeriodic indicates whether this prompt was triggered by the periodic runner. - // Used for @mitto:periodic variable substitution. - IsPeriodic bool `json:"is_periodic,omitempty"` - // IsPeriodicForced indicates whether this periodic prompt was triggered manually + // IsLoop indicates whether this prompt was triggered by the loop runner. + // Used for @mitto:loop variable substitution. + IsLoop bool `json:"is_loop,omitempty"` + // IsLoopForced indicates whether this loop prompt was triggered manually // via "run now" (as opposed to the normal scheduled delivery). - // Used for @mitto:periodic_forced variable substitution. - IsPeriodicForced bool `json:"is_periodic_forced,omitempty"` - // IterationNumber is the 0-based index of the current periodic run. + // Used for @mitto:loop_forced variable substitution. + IsLoopForced bool `json:"is_loop_forced,omitempty"` + // IterationNumber is the 0-based index of the current loop run. // Used for the {{ .Iteration.* }} template namespace. Excluded from JSON // (json:"-") so raw iteration values are never sent to external command processors. IterationNumber int `json:"-"` - // MaxIterations is the configured maximum number of periodic runs (0 = unlimited). + // MaxIterations is the configured maximum number of loop runs (0 = unlimited). // Used for the {{ .Iteration.* }} template namespace. Excluded from JSON (json:"-"). MaxIterations int `json:"-"` // IterationUninterrupted feeds {{ .Iteration.IsUninterrupted }}. True only on a - // scheduled, non-forced periodic run directly following another such run (no user + // scheduled, non-forced loop run directly following another such run (no user // interjection, no forced run, no FreshContext, same process lifetime). Excluded from // JSON (json:"-") — never sent to external command processors. IterationUninterrupted bool `json:"-"` diff --git a/internal/processors/processors_test.go b/internal/processors/processors_test.go index 8fa29c55..f0724bf1 100644 --- a/internal/processors/processors_test.go +++ b/internal/processors/processors_test.go @@ -14,15 +14,15 @@ import ( "github.com/inercia/mitto/internal/config" ) -func TestBuildCELContext_ArgsAndPeriodicForced(t *testing.T) { +func TestBuildCELContext_ArgsAndLoopForced(t *testing.T) { input := &ProcessorInput{ - SessionID: "sess-1", - IsPeriodicForced: true, - Arguments: map[string]string{"BRANCH": "main"}, + SessionID: "sess-1", + IsLoopForced: true, + Arguments: map[string]string{"BRANCH": "main"}, } ctx := BuildCELContext(input) - if !ctx.Session.IsPeriodicForced { - t.Error("expected ctx.Session.IsPeriodicForced=true") + if !ctx.Session.IsLoopForced { + t.Error("expected ctx.Session.IsLoopForced=true") } if ctx.Args == nil || ctx.Args["BRANCH"] != "main" { t.Fatalf("expected ctx.Args populated from input.Arguments, got %#v", ctx.Args) @@ -33,8 +33,8 @@ func TestBuildCELContext_ArgsAndPeriodicForced(t *testing.T) { if empty.Args != nil { t.Errorf("expected nil Args when input.Arguments is nil, got %#v", empty.Args) } - if empty.Session.IsPeriodicForced { - t.Error("expected IsPeriodicForced=false by default") + if empty.Session.IsLoopForced { + t.Error("expected IsLoopForced=false by default") } } @@ -3248,15 +3248,15 @@ func TestApplyAfter_OriginFilter(t *testing.T) { os.WriteFile(scriptPath, []byte("#!/bin/sh\necho done"), 0755) proc := &Processor{ - Name: "no-periodic", - When: WhenConfig{On: PhaseAgentResponded, Match: MatchAll, StopReasons: []string{"end_turn"}, ExcludeOrigins: []string{"periodic-runner"}}, + Name: "no-loop", + When: WhenConfig{On: PhaseAgentResponded, Match: MatchAll, StopReasons: []string{"end_turn"}, ExcludeOrigins: []string{"loop-runner"}}, Command: scriptPath, Output: OutputDiscard, } m := makeAfterManager([]*Processor{proc}) - // Should skip for periodic-runner - result := m.ApplyAfter(context.Background(), makeAfterInput("periodic-runner", "end_turn")) + // Should skip for loop-runner + result := m.ApplyAfter(context.Background(), makeAfterInput("loop-runner", "end_turn")) if len(result.Errors) != 0 { t.Errorf("expected no errors for excluded origin, got %v", result.Errors) } @@ -4718,11 +4718,11 @@ func buildProcessorYAML(cadence *CadenceConfig) string { } // TestBuildCELContext_Iteration verifies that BuildCELContext correctly populates -// the ctx.Iteration.* fields from ProcessorInput.IterationNumber / MaxIterations / IsPeriodic. +// the ctx.Iteration.* fields from ProcessorInput.IterationNumber / MaxIterations / IsLoop. func TestBuildCELContext_Iteration(t *testing.T) { cases := []struct { name string - isPeriodic bool + isLoop bool iterationNum int maxIterations int iterationUninterrupted bool @@ -4730,19 +4730,19 @@ func TestBuildCELContext_Iteration(t *testing.T) { wantIsLast bool wantIsUninterrupted bool }{ - // (1) First run of a 3-run periodic sequence. + // (1) First run of a 3-run loop sequence. { name: "first-of-three", - isPeriodic: true, + isLoop: true, iterationNum: 0, maxIterations: 3, wantIsFirst: true, wantIsLast: false, }, - // (2) Last run of a 3-run periodic sequence. + // (2) Last run of a 3-run loop sequence. { name: "last-of-three", - isPeriodic: true, + isLoop: true, iterationNum: 2, maxIterations: 3, wantIsFirst: false, @@ -4751,7 +4751,7 @@ func TestBuildCELContext_Iteration(t *testing.T) { // (3) Unlimited sequence (Max=0) — IsLast must always be false. { name: "unlimited", - isPeriodic: true, + isLoop: true, iterationNum: 5, maxIterations: 0, wantIsFirst: false, @@ -4760,7 +4760,7 @@ func TestBuildCELContext_Iteration(t *testing.T) { // (4) Uninterrupted continuation (mitto-5xjn). { name: "uninterrupted", - isPeriodic: true, + isLoop: true, iterationNum: 3, maxIterations: 0, iterationUninterrupted: true, @@ -4771,7 +4771,7 @@ func TestBuildCELContext_Iteration(t *testing.T) { // (5) Interrupted (user prompt between runs) — IsUninterrupted must be false. { name: "interrupted", - isPeriodic: true, + isLoop: true, iterationNum: 3, maxIterations: 0, iterationUninterrupted: false, @@ -4785,7 +4785,7 @@ func TestBuildCELContext_Iteration(t *testing.T) { t.Run(tc.name, func(t *testing.T) { input := &ProcessorInput{ SessionID: "sess-iter", - IsPeriodic: tc.isPeriodic, + IsLoop: tc.isLoop, IterationNumber: tc.iterationNum, MaxIterations: tc.maxIterations, IterationUninterrupted: tc.iterationUninterrupted, @@ -4798,8 +4798,8 @@ func TestBuildCELContext_Iteration(t *testing.T) { if ctx.Iteration.Max != tc.maxIterations { t.Errorf("Max: got %d, want %d", ctx.Iteration.Max, tc.maxIterations) } - if ctx.Iteration.IsPeriodic != tc.isPeriodic { - t.Errorf("IsPeriodic: got %v, want %v", ctx.Iteration.IsPeriodic, tc.isPeriodic) + if ctx.Iteration.IsLoop != tc.isLoop { + t.Errorf("IsLoop: got %v, want %v", ctx.Iteration.IsLoop, tc.isLoop) } if ctx.Iteration.IsFirst != tc.wantIsFirst { t.Errorf("IsFirst: got %v, want %v", ctx.Iteration.IsFirst, tc.wantIsFirst) diff --git a/internal/processors/types.go b/internal/processors/types.go index 407a2c08..f1067e9a 100644 --- a/internal/processors/types.go +++ b/internal/processors/types.go @@ -391,7 +391,7 @@ type AfterProcessorInput struct { WorkspaceUUID string `json:"-"` // WorkingDir is the session's working directory (used for WorkingDirSession processors). WorkingDir string `json:"workingDir,omitempty"` - // Origin is the source of the prompt: "user", "queue", or "periodic-runner". + // Origin is the source of the prompt: "user", "queue", or "loop-runner". Origin string `json:"origin"` // StopReason is the ACP stop reason string (e.g. "end_turn", "max_tokens"). // These match the ACP SDK StopReason constants (snake_case). diff --git a/internal/processors/variables.go b/internal/processors/variables.go index a82c4c76..5da9569c 100644 --- a/internal/processors/variables.go +++ b/internal/processors/variables.go @@ -25,8 +25,8 @@ import ( // - @mitto:children — Child sessions, comma-separated with names and ACP servers // - @mitto:mcp_children_count — Number of MCP-created child sessions (integer as string) // - @mitto:mcp_children — MCP-created child sessions only, comma-separated -// - @mitto:periodic — "true" if this prompt is from the periodic runner, "false" otherwise -// - @mitto:periodic_forced — "true" if this is a manually-triggered periodic run, "false" otherwise +// - @mitto:loop — "true" if this prompt is from the loop runner, "false" otherwise +// - @mitto:loop_forced — "true" if this is a manually-triggered loop run, "false" otherwise // - @mitto:user_data_schema — JSON representation of workspace user data schema // - @mitto:user_data — JSON representation of current session user data // @@ -81,8 +81,8 @@ func SubstituteVariables(message string, input *ProcessorInput) string { "@mitto:mcp_children_count": formatMCPChildrenCount(input.ChildSessions), "@mitto:mcp_children": formatMCPChildren(input.ChildSessions), "@mitto:children": formatChildSessions(input.ChildSessions), - "@mitto:periodic": strconv.FormatBool(input.IsPeriodic), - "@mitto:periodic_forced": strconv.FormatBool(input.IsPeriodicForced), + "@mitto:loop": strconv.FormatBool(input.IsLoop), + "@mitto:loop_forced": strconv.FormatBool(input.IsLoopForced), "@mitto:user_data_schema": input.UserDataSchemaJSON, "@mitto:user_data": input.UserDataJSON, } diff --git a/internal/processors/variables_test.go b/internal/processors/variables_test.go index f87a0c70..70c4bb14 100644 --- a/internal/processors/variables_test.go +++ b/internal/processors/variables_test.go @@ -451,7 +451,7 @@ func TestFormatAvailableACPServers(t *testing.T) { } } -func TestSubstituteVariables_Periodic(t *testing.T) { +func TestSubstituteVariables_Loop(t *testing.T) { tests := []struct { name string message string @@ -459,22 +459,22 @@ func TestSubstituteVariables_Periodic(t *testing.T) { expected string }{ { - name: "periodic true", - message: "periodic: @mitto:periodic", - input: &ProcessorInput{IsPeriodic: true}, - expected: "periodic: true", + name: "loop true", + message: "loop: @mitto:loop", + input: &ProcessorInput{IsLoop: true}, + expected: "loop: true", }, { - name: "periodic false", - message: "periodic: @mitto:periodic", - input: &ProcessorInput{IsPeriodic: false}, - expected: "periodic: false", + name: "loop false", + message: "loop: @mitto:loop", + input: &ProcessorInput{IsLoop: false}, + expected: "loop: false", }, { - name: "periodic default (false)", - message: "periodic: @mitto:periodic", + name: "loop default (false)", + message: "loop: @mitto:loop", input: &ProcessorInput{}, - expected: "periodic: false", + expected: "loop: false", }, } @@ -488,7 +488,7 @@ func TestSubstituteVariables_Periodic(t *testing.T) { } } -func TestSubstituteVariables_PeriodicForced(t *testing.T) { +func TestSubstituteVariables_LoopForced(t *testing.T) { tests := []struct { name string message string @@ -496,27 +496,27 @@ func TestSubstituteVariables_PeriodicForced(t *testing.T) { expected string }{ { - name: "periodic_forced true", - message: "forced: @mitto:periodic_forced", - input: &ProcessorInput{IsPeriodicForced: true}, + name: "loop_forced true", + message: "forced: @mitto:loop_forced", + input: &ProcessorInput{IsLoopForced: true}, expected: "forced: true", }, { - name: "periodic_forced false", - message: "forced: @mitto:periodic_forced", - input: &ProcessorInput{IsPeriodicForced: false}, + name: "loop_forced false", + message: "forced: @mitto:loop_forced", + input: &ProcessorInput{IsLoopForced: false}, expected: "forced: false", }, { - name: "periodic_forced default (false)", - message: "forced: @mitto:periodic_forced", + name: "loop_forced default (false)", + message: "forced: @mitto:loop_forced", input: &ProcessorInput{}, expected: "forced: false", }, { - name: "periodic_forced does not affect periodic", - message: "p: @mitto:periodic, f: @mitto:periodic_forced", - input: &ProcessorInput{IsPeriodic: true, IsPeriodicForced: true}, + name: "loop_forced does not affect loop", + message: "p: @mitto:loop, f: @mitto:loop_forced", + input: &ProcessorInput{IsLoop: true, IsLoopForced: true}, expected: "p: true, f: true", }, } diff --git a/internal/session/periodic.go b/internal/session/loop.go similarity index 79% rename from internal/session/periodic.go rename to internal/session/loop.go index 51043d3e..04f12ee6 100644 --- a/internal/session/periodic.go +++ b/internal/session/loop.go @@ -15,10 +15,10 @@ import ( ) const ( - periodicFileName = "periodic.json" + loopFileName = "loop.json" ) -// StoppedReason is the reason a periodic conversation was automatically stopped. +// StoppedReason is the reason a loop conversation was automatically stopped. // These values are part of the frontend contract — do not change. type StoppedReason string @@ -33,7 +33,7 @@ const ( // StoppedReasonPromptUnresolved is set when the prompt name cannot be resolved after // MaxPromptResolveFailures consecutive failures. StoppedReasonPromptUnresolved StoppedReason = "promptUnresolved" - // StoppedReasonResumeFailures is set when ACP resume fails MaxPeriodicResumeFailures + // StoppedReasonResumeFailures is set when ACP resume fails MaxLoopResumeFailures // consecutive times and the session is auto-archived. StoppedReasonResumeFailures StoppedReason = "resumeFailures" @@ -45,7 +45,7 @@ const ( StoppedReasonDisabledByAgent StoppedReason = "disabledByAgent" // StoppedReasonArchived is set when the conversation is archived (manual or auto), - // which authoritatively stops the periodic loop. + // which authoritatively stops the loop. StoppedReasonArchived StoppedReason = "archived" // StoppedReasonNoProgress is set when the onTasks trigger's circuit breaker fires @@ -56,8 +56,8 @@ const ( ) var ( - // ErrPeriodicNotFound is returned when no periodic prompt is configured. - ErrPeriodicNotFound = errors.New("periodic prompt not found") + // ErrLoopNotFound is returned when no loop prompt is configured. + ErrLoopNotFound = errors.New("loop prompt not found") // ErrInvalidFrequency is returned when the frequency configuration is invalid. ErrInvalidFrequency = errors.New("invalid frequency configuration") // ErrPromptEmpty is returned when the prompt text is empty. @@ -72,17 +72,17 @@ var ( ErrInvalidMaxDuration = errors.New("invalid max_duration_seconds: must be >= 0") ) -// PeriodicTrigger defines how/when a periodic prompt is fired. -type PeriodicTrigger string +// LoopTrigger defines how/when a loop prompt is fired. +type LoopTrigger string const ( // TriggerSchedule is the default trigger: fire based on Frequency. - TriggerSchedule PeriodicTrigger = "schedule" + TriggerSchedule LoopTrigger = "schedule" // TriggerOnCompletion fires after the agent stops responding (event-driven). - TriggerOnCompletion PeriodicTrigger = "onCompletion" + TriggerOnCompletion LoopTrigger = "onCompletion" // TriggerOnTasks fires when beads/tasks in the workspace change, optionally // gated by a CEL Condition (event-driven). - TriggerOnTasks PeriodicTrigger = "onTasks" + TriggerOnTasks LoopTrigger = "onTasks" ) // ConditionValidator is an optional package-level seam that compile-validates a @@ -91,7 +91,7 @@ const ( // When nil, Condition compile-validation is skipped in Validate(). var ConditionValidator func(string) error -// FrequencyUnit represents the time unit for periodic scheduling. +// FrequencyUnit represents the time unit for loop scheduling. type FrequencyUnit string const ( @@ -161,8 +161,8 @@ func (f *Frequency) Duration() time.Duration { } } -// PeriodicPrompt represents a scheduled recurring prompt for a session. -type PeriodicPrompt struct { +// LoopPrompt represents a scheduled recurring prompt for a session. +type LoopPrompt struct { // Prompt is the message text to send. Prompt string `json:"prompt"` // PromptName is the name of a workspace prompt to resolve at execution time. @@ -175,7 +175,7 @@ type PeriodicPrompt struct { Arguments map[string]string `json:"arguments,omitempty"` // Frequency defines how often the prompt should be sent. Frequency Frequency `json:"frequency"` - // Enabled indicates whether the periodic prompt is active. + // Enabled indicates whether the loop prompt is active. Enabled bool `json:"enabled"` // FreshContext indicates whether each scheduled run should start with a clean // agent context (no history injection, new ACP session). Default is false. @@ -184,17 +184,17 @@ type PeriodicPrompt struct { MaxIterations int `json:"max_iterations,omitempty"` // IterationCount is the number of scheduled runs delivered so far. IterationCount int `json:"iteration_count"` - // CreatedAt is when the periodic prompt was created. + // CreatedAt is when the loop prompt was created. CreatedAt time.Time `json:"created_at"` - // UpdatedAt is when the periodic prompt was last modified. + // UpdatedAt is when the loop prompt was last modified. UpdatedAt time.Time `json:"updated_at"` // LastSentAt is when the prompt was last delivered (nil if never sent). LastSentAt *time.Time `json:"last_sent_at,omitempty"` // NextScheduledAt is the computed next delivery time (nil if not scheduled). NextScheduledAt *time.Time `json:"next_scheduled_at,omitempty"` - // Trigger controls how this periodic prompt is fired. + // Trigger controls how this loop prompt is fired. // Empty or "schedule" means frequency-based; "onCompletion" means event-driven. - Trigger PeriodicTrigger `json:"trigger,omitempty"` + Trigger LoopTrigger `json:"trigger,omitempty"` // DelaySeconds is the number of seconds to wait after the agent stops responding // before the next run. Only meaningful when Trigger is onCompletion. DelaySeconds int `json:"delay_seconds,omitempty"` @@ -203,7 +203,7 @@ type PeriodicPrompt struct { // FirstRunAt is the elapsed-time anchor: set on the first RecordSent call. // Used by ReachedMaxDuration to compute how long iterating has been running. FirstRunAt *time.Time `json:"first_run_at,omitempty"` - // StoppedReason records why the periodic loop was automatically stopped. + // StoppedReason records why the loop was automatically stopped. // Empty when still running or not yet stopped. StoppedReason StoppedReason `json:"stopped_reason,omitempty"` // StoppedAt is the timestamp when the loop was auto-stopped (nil when still running). @@ -220,26 +220,26 @@ type PeriodicPrompt struct { // ReachedMaxIterations returns true if the prompt has been delivered the maximum number of scheduled times. // Returns false when MaxIterations is 0 (unlimited). -func (p *PeriodicPrompt) ReachedMaxIterations() bool { +func (p *LoopPrompt) ReachedMaxIterations() bool { return p.MaxIterations > 0 && p.IterationCount >= p.MaxIterations } // EffectiveTrigger returns the resolved trigger type. // When Trigger is empty, TriggerSchedule (the default) is returned. -func (p *PeriodicPrompt) EffectiveTrigger() PeriodicTrigger { +func (p *LoopPrompt) EffectiveTrigger() LoopTrigger { if p.Trigger == "" { return TriggerSchedule } return p.Trigger } -// IsOnCompletion returns true when this periodic prompt uses the onCompletion trigger. -func (p *PeriodicPrompt) IsOnCompletion() bool { +// IsOnCompletion returns true when this loop prompt uses the onCompletion trigger. +func (p *LoopPrompt) IsOnCompletion() bool { return p.EffectiveTrigger() == TriggerOnCompletion } -// IsOnTasks returns true when this periodic prompt uses the onTasks trigger. -func (p *PeriodicPrompt) IsOnTasks() bool { +// IsOnTasks returns true when this loop prompt uses the onTasks trigger. +func (p *LoopPrompt) IsOnTasks() bool { return p.EffectiveTrigger() == TriggerOnTasks } @@ -254,7 +254,7 @@ const promptPreviewMaxRunes = 80 // Otherwise returns the first line, trimmed, truncated to 80 runes with a // trailing "…" appended when the original first line exceeded that length. // Named-prompt-only configs (PromptName set, Prompt empty) also return "". -func (p *PeriodicPrompt) PromptPreview() string { +func (p *LoopPrompt) PromptPreview() string { body := strings.TrimSpace(p.Prompt) if body == "" || body == pendingPlaceholder { return "" @@ -274,7 +274,7 @@ func (p *PeriodicPrompt) PromptPreview() string { // ReachedMaxDuration returns true if the elapsed time since the first run exceeds MaxDurationSeconds. // Returns false when MaxDurationSeconds is 0 (unlimited) or FirstRunAt is nil (not yet started). -func (p *PeriodicPrompt) ReachedMaxDuration(now time.Time) bool { +func (p *LoopPrompt) ReachedMaxDuration(now time.Time) bool { if p.MaxDurationSeconds <= 0 || p.FirstRunAt == nil { return false } @@ -284,7 +284,7 @@ func (p *PeriodicPrompt) ReachedMaxDuration(now time.Time) bool { // ClampDelay ensures DelaySeconds is at least floorSeconds. // Only applies when the trigger is onCompletion; schedule prompts are not clamped. // The floor value is injected by the caller — this method does NOT hardcode any policy minimum. -func (p *PeriodicPrompt) ClampDelay(floorSeconds int) { +func (p *LoopPrompt) ClampDelay(floorSeconds int) { if !p.IsOnCompletion() { return } @@ -293,8 +293,8 @@ func (p *PeriodicPrompt) ClampDelay(floorSeconds int) { } } -// Validate checks if the periodic prompt configuration is valid. -func (p *PeriodicPrompt) Validate() error { +// Validate checks if the loop prompt configuration is valid. +func (p *LoopPrompt) Validate() error { if p.Prompt == "" && p.PromptName == "" { return ErrPromptEmpty } @@ -326,44 +326,44 @@ func (p *PeriodicPrompt) Validate() error { return nil } -// PeriodicStore manages the periodic prompt for a single session. +// LoopStore manages the loop prompt for a single session. // It is safe for concurrent use. -type PeriodicStore struct { +type LoopStore struct { sessionDir string mu sync.RWMutex } -// NewPeriodicStore creates a new PeriodicStore for the given session directory. -func NewPeriodicStore(sessionDir string) *PeriodicStore { - return &PeriodicStore{ +// NewLoopStore creates a new LoopStore for the given session directory. +func NewLoopStore(sessionDir string) *LoopStore { + return &LoopStore{ sessionDir: sessionDir, } } -// periodicPath returns the path to the periodic.json file. -func (ps *PeriodicStore) periodicPath() string { - return filepath.Join(ps.sessionDir, periodicFileName) +// loopPath returns the path to the loop.json file. +func (ps *LoopStore) loopPath() string { + return filepath.Join(ps.sessionDir, loopFileName) } -// Get retrieves the current periodic prompt configuration. -// Returns ErrPeriodicNotFound if no periodic prompt is configured. -func (ps *PeriodicStore) Get() (*PeriodicPrompt, error) { +// Get retrieves the current loop prompt configuration. +// Returns ErrLoopNotFound if no loop prompt is configured. +func (ps *LoopStore) Get() (*LoopPrompt, error) { ps.mu.RLock() defer ps.mu.RUnlock() - var p PeriodicPrompt - err := fileutil.ReadJSON(ps.periodicPath(), &p) + var p LoopPrompt + err := fileutil.ReadJSON(ps.loopPath(), &p) if err != nil { if os.IsNotExist(err) { - return nil, ErrPeriodicNotFound + return nil, ErrLoopNotFound } - return nil, fmt.Errorf("failed to read periodic file: %w", err) + return nil, fmt.Errorf("failed to read loop file: %w", err) } return &p, nil } -// Set creates or replaces the periodic prompt configuration. -func (ps *PeriodicStore) Set(p *PeriodicPrompt) error { +// Set creates or replaces the loop prompt configuration. +func (ps *LoopStore) Set(p *LoopPrompt) error { if err := p.Validate(); err != nil { return err } @@ -392,16 +392,16 @@ func (ps *PeriodicStore) Set(p *PeriodicPrompt) error { p.UpdatedAt = now p.NextScheduledAt = ps.computeNextScheduledTime(p) - if err := fileutil.WriteJSONAtomic(ps.periodicPath(), p, 0644); err != nil { - return fmt.Errorf("failed to write periodic file: %w", err) + if err := fileutil.WriteJSONAtomic(ps.loopPath(), p, 0644); err != nil { + return fmt.Errorf("failed to write loop file: %w", err) } return nil } -// Update applies a partial update to the periodic prompt. +// Update applies a partial update to the loop prompt. // Only non-nil fields in the update are applied. // IterationCount is never modified by Update — it is managed exclusively by RecordSent. -func (ps *PeriodicStore) Update(prompt *string, promptName *string, frequency *Frequency, enabled *bool, freshContext *bool, maxIterations *int, trigger *PeriodicTrigger, delaySeconds *int, maxDurationSeconds *int, arguments *map[string]string, condition *string, conditionPreset *string, cooldownSeconds *int) error { +func (ps *LoopStore) Update(prompt *string, promptName *string, frequency *Frequency, enabled *bool, freshContext *bool, maxIterations *int, trigger *LoopTrigger, delaySeconds *int, maxDurationSeconds *int, arguments *map[string]string, condition *string, conditionPreset *string, cooldownSeconds *int) error { ps.mu.Lock() defer ps.mu.Unlock() @@ -462,30 +462,30 @@ func (ps *PeriodicStore) Update(prompt *string, promptName *string, frequency *F existing.UpdatedAt = time.Now().UTC() existing.NextScheduledAt = ps.computeNextScheduledTime(existing) - if err := fileutil.WriteJSONAtomic(ps.periodicPath(), existing, 0644); err != nil { - return fmt.Errorf("failed to write periodic file: %w", err) + if err := fileutil.WriteJSONAtomic(ps.loopPath(), existing, 0644); err != nil { + return fmt.Errorf("failed to write loop file: %w", err) } return nil } -// Delete removes the periodic prompt configuration. -func (ps *PeriodicStore) Delete() error { +// Delete removes the loop prompt configuration. +func (ps *LoopStore) Delete() error { ps.mu.Lock() defer ps.mu.Unlock() - err := os.Remove(ps.periodicPath()) + err := os.Remove(ps.loopPath()) if err != nil { if os.IsNotExist(err) { - return ErrPeriodicNotFound + return ErrLoopNotFound } - return fmt.Errorf("failed to delete periodic file: %w", err) + return fmt.Errorf("failed to delete loop file: %w", err) } return nil } // ResetCounters resets the iteration and elapsed-time anchors so the loop starts // fresh: IterationCount is set to 0, FirstRunAt is cleared (elapsed time = 0), and -// LastSentAt is cleared (never-sent). This is used when restoring a periodic +// LastSentAt is cleared (never-sent). This is used when restoring a loop // conversation that was auto-stopped after reaching its max-iterations or // max-duration cap. Clearing LastSentAt makes the conversation look brand-new so // that the restore behaves like the initial run: an onCompletion loop bootstraps @@ -493,7 +493,7 @@ func (ps *PeriodicStore) Delete() error { // gap, not a pre-first-run delay) rather than waiting out the configured delay. It // does not change Enabled or the prompt configuration; re-enabling is handled // separately by Update. -func (ps *PeriodicStore) ResetCounters() error { +func (ps *LoopStore) ResetCounters() error { ps.mu.Lock() defer ps.mu.Unlock() @@ -507,14 +507,14 @@ func (ps *PeriodicStore) ResetCounters() error { existing.LastSentAt = nil existing.UpdatedAt = time.Now().UTC() - if err := fileutil.WriteJSONAtomic(ps.periodicPath(), existing, 0644); err != nil { - return fmt.Errorf("failed to write periodic file: %w", err) + if err := fileutil.WriteJSONAtomic(ps.loopPath(), existing, 0644); err != nil { + return fmt.Errorf("failed to write loop file: %w", err) } return nil } // RecordSent updates the last_sent_at timestamp, increments iteration_count, and computes next_scheduled_at. -func (ps *PeriodicStore) RecordSent() error { +func (ps *LoopStore) RecordSent() error { ps.mu.Lock() defer ps.mu.Unlock() @@ -533,8 +533,8 @@ func (ps *PeriodicStore) RecordSent() error { existing.UpdatedAt = now existing.NextScheduledAt = ps.computeNextScheduledTime(existing) - if err := fileutil.WriteJSONAtomic(ps.periodicPath(), existing, 0644); err != nil { - return fmt.Errorf("failed to write periodic file: %w", err) + if err := fileutil.WriteJSONAtomic(ps.loopPath(), existing, 0644); err != nil { + return fmt.Errorf("failed to write loop file: %w", err) } return nil } @@ -544,7 +544,7 @@ func (ps *PeriodicStore) RecordSent() error { // failure so the runner does not re-fire the same prompt on every poll tick. // It is a no-op (returns nil) for disabled configs and for onCompletion/onTasks // triggers, whose next run is event-driven (NextScheduledAt is always nil). -func (ps *PeriodicStore) DeferNextSchedule(delay time.Duration) error { +func (ps *LoopStore) DeferNextSchedule(delay time.Duration) error { ps.mu.Lock() defer ps.mu.Unlock() @@ -561,17 +561,17 @@ func (ps *PeriodicStore) DeferNextSchedule(delay time.Duration) error { existing.NextScheduledAt = &next existing.UpdatedAt = now - if err := fileutil.WriteJSONAtomic(ps.periodicPath(), existing, 0644); err != nil { - return fmt.Errorf("failed to write periodic file: %w", err) + if err := fileutil.WriteJSONAtomic(ps.loopPath(), existing, 0644); err != nil { + return fmt.Errorf("failed to write loop file: %w", err) } return nil } -// MarkStopped disables the periodic prompt and records the reason it was stopped. +// MarkStopped disables the loop prompt and records the reason it was stopped. // It sets Enabled=false, StoppedReason=reason, StoppedAt=now (UTC), // NextScheduledAt=nil, and UpdatedAt=now. -// Returns ErrPeriodicNotFound if no periodic config exists. -func (ps *PeriodicStore) MarkStopped(reason StoppedReason) error { +// Returns ErrLoopNotFound if no loop config exists. +func (ps *LoopStore) MarkStopped(reason StoppedReason) error { ps.mu.Lock() defer ps.mu.Unlock() @@ -587,21 +587,21 @@ func (ps *PeriodicStore) MarkStopped(reason StoppedReason) error { existing.NextScheduledAt = nil existing.UpdatedAt = now - if err := fileutil.WriteJSONAtomic(ps.periodicPath(), existing, 0644); err != nil { - return fmt.Errorf("failed to write periodic file: %w", err) + if err := fileutil.WriteJSONAtomic(ps.loopPath(), existing, 0644); err != nil { + return fmt.Errorf("failed to write loop file: %w", err) } return nil } -// getUnlocked reads the periodic file without locking (caller must hold lock). -func (ps *PeriodicStore) getUnlocked() (*PeriodicPrompt, error) { - var p PeriodicPrompt - err := fileutil.ReadJSON(ps.periodicPath(), &p) +// getUnlocked reads the loop file without locking (caller must hold lock). +func (ps *LoopStore) getUnlocked() (*LoopPrompt, error) { + var p LoopPrompt + err := fileutil.ReadJSON(ps.loopPath(), &p) if err != nil { if os.IsNotExist(err) { - return nil, ErrPeriodicNotFound + return nil, ErrLoopNotFound } - return nil, fmt.Errorf("failed to read periodic file: %w", err) + return nil, fmt.Errorf("failed to read loop file: %w", err) } return &p, nil } @@ -609,7 +609,7 @@ func (ps *PeriodicStore) getUnlocked() (*PeriodicPrompt, error) { // computeNextScheduledTime calculates when the next prompt should be sent. // Returns nil for onCompletion/onTasks triggers — their next run is armed by the // event-driven firing path, not a frequency-based schedule. -func (ps *PeriodicStore) computeNextScheduledTime(p *PeriodicPrompt) *time.Time { +func (ps *LoopStore) computeNextScheduledTime(p *LoopPrompt) *time.Time { if !p.Enabled { return nil } @@ -651,7 +651,7 @@ func (ps *PeriodicStore) computeNextScheduledTime(p *PeriodicPrompt) *time.Time } // nextTimeAt computes the next occurrence of a specific time (HH:MM UTC). -func (ps *PeriodicStore) nextTimeAt(from time.Time, at string, days int) time.Time { +func (ps *LoopStore) nextTimeAt(from time.Time, at string, days int) time.Time { var h, m int fmt.Sscanf(at, "%d:%d", &h, &m) diff --git a/internal/session/periodic_test.go b/internal/session/loop_test.go similarity index 82% rename from internal/session/periodic_test.go rename to internal/session/loop_test.go index f01a8065..6697838a 100644 --- a/internal/session/periodic_test.go +++ b/internal/session/loop_test.go @@ -144,15 +144,15 @@ func TestFrequency_Duration(t *testing.T) { } } -func TestPeriodicPrompt_Validate(t *testing.T) { +func TestLoopPrompt_Validate(t *testing.T) { tests := []struct { name string - prompt PeriodicPrompt + prompt LoopPrompt wantErr bool }{ { name: "valid prompt", - prompt: PeriodicPrompt{ + prompt: LoopPrompt{ Prompt: "Check for updates", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -161,7 +161,7 @@ func TestPeriodicPrompt_Validate(t *testing.T) { }, { name: "empty prompt", - prompt: PeriodicPrompt{ + prompt: LoopPrompt{ Prompt: "", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -180,21 +180,21 @@ func TestPeriodicPrompt_Validate(t *testing.T) { } } -func TestPeriodicStore_GetNotFound(t *testing.T) { +func TestLoopStore_GetNotFound(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) _, err := ps.Get() - if err != ErrPeriodicNotFound { - t.Errorf("Get() error = %v, want ErrPeriodicNotFound", err) + if err != ErrLoopNotFound { + t.Errorf("Get() error = %v, want ErrLoopNotFound", err) } } -func TestPeriodicStore_SetAndGet(t *testing.T) { +func TestLoopStore_SetAndGet(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Check for updates", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -205,9 +205,9 @@ func TestPeriodicStore_SetAndGet(t *testing.T) { } // Verify file was created - periodicPath := filepath.Join(dir, periodicFileName) - if _, err := os.Stat(periodicPath); os.IsNotExist(err) { - t.Fatal("periodic.json should exist after Set()") + loopPath := filepath.Join(dir, loopFileName) + if _, err := os.Stat(loopPath); os.IsNotExist(err) { + t.Fatal("loop.json should exist after Set()") } got, err := ps.Get() @@ -238,12 +238,12 @@ func TestPeriodicStore_SetAndGet(t *testing.T) { } } -func TestPeriodicStore_SetValidation(t *testing.T) { +func TestLoopStore_SetValidation(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // Empty prompt - err := ps.Set(&PeriodicPrompt{ + err := ps.Set(&LoopPrompt{ Prompt: "", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -253,7 +253,7 @@ func TestPeriodicStore_SetValidation(t *testing.T) { } // Invalid frequency (value must be >= 1) - err = ps.Set(&PeriodicPrompt{ + err = ps.Set(&LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 0, Unit: FrequencyMinutes}, // Zero not allowed Enabled: true, @@ -263,12 +263,12 @@ func TestPeriodicStore_SetValidation(t *testing.T) { } } -func TestPeriodicStore_SetPreservesCreatedAt(t *testing.T) { +func TestLoopStore_SetPreservesCreatedAt(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // Create initial - p1 := &PeriodicPrompt{ + p1 := &LoopPrompt{ Prompt: "First prompt", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -284,7 +284,7 @@ func TestPeriodicStore_SetPreservesCreatedAt(t *testing.T) { time.Sleep(10 * time.Millisecond) // Update with new prompt - p2 := &PeriodicPrompt{ + p2 := &LoopPrompt{ Prompt: "Updated prompt", Frequency: Frequency{Value: 2, Unit: FrequencyHours}, Enabled: false, @@ -311,19 +311,19 @@ func TestPeriodicStore_SetPreservesCreatedAt(t *testing.T) { } } -func TestPeriodicStore_Update(t *testing.T) { +func TestLoopStore_Update(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // Update on non-existent should fail enabled := true err := ps.Update(nil, nil, nil, &enabled, nil, nil, nil, nil, nil, nil, nil, nil, nil) - if err != ErrPeriodicNotFound { - t.Errorf("Update() on empty store error = %v, want ErrPeriodicNotFound", err) + if err != ErrLoopNotFound { + t.Errorf("Update() on empty store error = %v, want ErrLoopNotFound", err) } // Create initial - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Initial prompt", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -369,12 +369,12 @@ func TestPeriodicStore_Update(t *testing.T) { } } -func TestPeriodicStore_UpdateValidation(t *testing.T) { +func TestLoopStore_UpdateValidation(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // Create initial - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Initial prompt", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -395,56 +395,56 @@ func TestPeriodicStore_UpdateValidation(t *testing.T) { } } -func TestPeriodicStore_Delete(t *testing.T) { +func TestLoopStore_Delete(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // Delete non-existent should return error err := ps.Delete() - if err != ErrPeriodicNotFound { - t.Errorf("Delete() on empty store error = %v, want ErrPeriodicNotFound", err) + if err != ErrLoopNotFound { + t.Errorf("Delete() on empty store error = %v, want ErrLoopNotFound", err) } // Create and delete - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, } ps.Set(p) - periodicPath := filepath.Join(dir, periodicFileName) - if _, err := os.Stat(periodicPath); os.IsNotExist(err) { - t.Fatal("periodic.json should exist after Set()") + loopPath := filepath.Join(dir, loopFileName) + if _, err := os.Stat(loopPath); os.IsNotExist(err) { + t.Fatal("loop.json should exist after Set()") } if err := ps.Delete(); err != nil { t.Fatalf("Delete() error = %v", err) } - if _, err := os.Stat(periodicPath); !os.IsNotExist(err) { - t.Error("periodic.json should not exist after Delete()") + if _, err := os.Stat(loopPath); !os.IsNotExist(err) { + t.Error("loop.json should not exist after Delete()") } // Get should return not found _, err = ps.Get() - if err != ErrPeriodicNotFound { - t.Errorf("Get() after Delete() error = %v, want ErrPeriodicNotFound", err) + if err != ErrLoopNotFound { + t.Errorf("Get() after Delete() error = %v, want ErrLoopNotFound", err) } } -func TestPeriodicStore_RecordSent(t *testing.T) { +func TestLoopStore_RecordSent(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // RecordSent on non-existent should fail err := ps.RecordSent() - if err != ErrPeriodicNotFound { - t.Errorf("RecordSent() on empty store error = %v, want ErrPeriodicNotFound", err) + if err != ErrLoopNotFound { + t.Errorf("RecordSent() on empty store error = %v, want ErrLoopNotFound", err) } // Create - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -475,17 +475,17 @@ func TestPeriodicStore_RecordSent(t *testing.T) { } } -func TestPeriodicStore_ResetCounters(t *testing.T) { +func TestLoopStore_ResetCounters(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // ResetCounters on non-existent should fail - if err := ps.ResetCounters(); err != ErrPeriodicNotFound { - t.Errorf("ResetCounters() on empty store error = %v, want ErrPeriodicNotFound", err) + if err := ps.ResetCounters(); err != ErrLoopNotFound { + t.Errorf("ResetCounters() on empty store error = %v, want ErrLoopNotFound", err) } // Create and run twice so IterationCount and FirstRunAt are populated. - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -532,12 +532,12 @@ func TestPeriodicStore_ResetCounters(t *testing.T) { } } -func TestPeriodicStore_NextScheduledAtWhenDisabled(t *testing.T) { +func TestLoopStore_NextScheduledAtWhenDisabled(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // Create disabled prompt - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: false, @@ -568,12 +568,12 @@ func TestPeriodicStore_NextScheduledAtWhenDisabled(t *testing.T) { } } -func TestPeriodicStore_NextScheduledAtWithDaysAndAt(t *testing.T) { +func TestLoopStore_NextScheduledAtWithDaysAndAt(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // Create a daily prompt at 09:00 UTC - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Daily check", Frequency: Frequency{Value: 1, Unit: FrequencyDays, At: "09:00"}, Enabled: true, @@ -591,12 +591,12 @@ func TestPeriodicStore_NextScheduledAtWithDaysAndAt(t *testing.T) { } } -func TestPeriodicStore_LastSentAtPreservedOnUpdate(t *testing.T) { +func TestLoopStore_LastSentAtPreservedOnUpdate(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) // Create and record sent - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -608,7 +608,7 @@ func TestPeriodicStore_LastSentAtPreservedOnUpdate(t *testing.T) { lastSent := got1.LastSentAt // Update with Set (should preserve last_sent_at) - p2 := &PeriodicPrompt{ + p2 := &LoopPrompt{ Prompt: "Updated", Frequency: Frequency{Value: 2, Unit: FrequencyHours}, Enabled: true, @@ -624,15 +624,15 @@ func TestPeriodicStore_LastSentAtPreservedOnUpdate(t *testing.T) { } } -func TestPeriodicPrompt_Validate_MaxIterations(t *testing.T) { +func TestLoopPrompt_Validate_MaxIterations(t *testing.T) { tests := []struct { name string - prompt PeriodicPrompt + prompt LoopPrompt wantErr bool }{ { name: "zero max iterations (unlimited)", - prompt: PeriodicPrompt{ + prompt: LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, MaxIterations: 0, @@ -641,7 +641,7 @@ func TestPeriodicPrompt_Validate_MaxIterations(t *testing.T) { }, { name: "positive max iterations", - prompt: PeriodicPrompt{ + prompt: LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, MaxIterations: 5, @@ -650,7 +650,7 @@ func TestPeriodicPrompt_Validate_MaxIterations(t *testing.T) { }, { name: "negative max iterations", - prompt: PeriodicPrompt{ + prompt: LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, MaxIterations: -1, @@ -669,7 +669,7 @@ func TestPeriodicPrompt_Validate_MaxIterations(t *testing.T) { } } -func TestPeriodicPrompt_ReachedMaxIterations(t *testing.T) { +func TestLoopPrompt_ReachedMaxIterations(t *testing.T) { tests := []struct { name string maxIterations int @@ -685,7 +685,7 @@ func TestPeriodicPrompt_ReachedMaxIterations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &PeriodicPrompt{MaxIterations: tt.maxIterations, IterationCount: tt.iterationCount} + p := &LoopPrompt{MaxIterations: tt.maxIterations, IterationCount: tt.iterationCount} if got := p.ReachedMaxIterations(); got != tt.want { t.Errorf("ReachedMaxIterations() = %v, want %v", got, tt.want) } @@ -693,11 +693,11 @@ func TestPeriodicPrompt_ReachedMaxIterations(t *testing.T) { } } -func TestPeriodicStore_RecordSent_IncrementsIterationCount(t *testing.T) { +func TestLoopStore_RecordSent_IncrementsIterationCount(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -722,11 +722,11 @@ func TestPeriodicStore_RecordSent_IncrementsIterationCount(t *testing.T) { } } -func TestPeriodicStore_IterationCountPreservedOnSet(t *testing.T) { +func TestLoopStore_IterationCountPreservedOnSet(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -741,7 +741,7 @@ func TestPeriodicStore_IterationCountPreservedOnSet(t *testing.T) { } // Re-save the config (simulates user updating frequency without resetting counter) - p2 := &PeriodicPrompt{ + p2 := &LoopPrompt{ Prompt: "Updated prompt", Frequency: Frequency{Value: 2, Unit: FrequencyHours}, Enabled: true, @@ -756,11 +756,11 @@ func TestPeriodicStore_IterationCountPreservedOnSet(t *testing.T) { } } -func TestPeriodicStore_UpdateDoesNotTouchIterationCount(t *testing.T) { +func TestLoopStore_UpdateDoesNotTouchIterationCount(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -787,56 +787,56 @@ func TestPeriodicStore_UpdateDoesNotTouchIterationCount(t *testing.T) { // --- New tests for trigger type, delay, maxDuration, FirstRunAt --- -func TestPeriodicPrompt_Validate_Trigger(t *testing.T) { +func TestLoopPrompt_Validate_Trigger(t *testing.T) { validFreq := Frequency{Value: 1, Unit: FrequencyHours} tests := []struct { name string - prompt PeriodicPrompt + prompt LoopPrompt wantErr error }{ { name: "valid schedule trigger explicit", - prompt: PeriodicPrompt{Prompt: "p", Frequency: validFreq, Trigger: TriggerSchedule}, + prompt: LoopPrompt{Prompt: "p", Frequency: validFreq, Trigger: TriggerSchedule}, wantErr: nil, }, { name: "valid empty trigger treated as schedule", - prompt: PeriodicPrompt{Prompt: "p", Frequency: validFreq, Trigger: ""}, + prompt: LoopPrompt{Prompt: "p", Frequency: validFreq, Trigger: ""}, wantErr: nil, }, { name: "valid onCompletion with no frequency", - prompt: PeriodicPrompt{Prompt: "p", Trigger: TriggerOnCompletion}, + prompt: LoopPrompt{Prompt: "p", Trigger: TriggerOnCompletion}, wantErr: nil, }, { name: "valid onCompletion with delay", - prompt: PeriodicPrompt{Prompt: "p", Trigger: TriggerOnCompletion, DelaySeconds: 10}, + prompt: LoopPrompt{Prompt: "p", Trigger: TriggerOnCompletion, DelaySeconds: 10}, wantErr: nil, }, { name: "valid onTasks with no frequency", - prompt: PeriodicPrompt{Prompt: "p", Trigger: TriggerOnTasks}, + prompt: LoopPrompt{Prompt: "p", Trigger: TriggerOnTasks}, wantErr: nil, }, { name: "valid onTasks with empty condition fires on any change", - prompt: PeriodicPrompt{Prompt: "p", Trigger: TriggerOnTasks, Condition: ""}, + prompt: LoopPrompt{Prompt: "p", Trigger: TriggerOnTasks, Condition: ""}, wantErr: nil, }, { name: "invalid trigger value", - prompt: PeriodicPrompt{Prompt: "p", Frequency: validFreq, Trigger: "weekly"}, + prompt: LoopPrompt{Prompt: "p", Frequency: validFreq, Trigger: "weekly"}, wantErr: ErrInvalidTrigger, }, { name: "negative DelaySeconds", - prompt: PeriodicPrompt{Prompt: "p", Trigger: TriggerOnCompletion, DelaySeconds: -1}, + prompt: LoopPrompt{Prompt: "p", Trigger: TriggerOnCompletion, DelaySeconds: -1}, wantErr: ErrInvalidDelay, }, { name: "negative MaxDurationSeconds", - prompt: PeriodicPrompt{Prompt: "p", Trigger: TriggerOnCompletion, MaxDurationSeconds: -1}, + prompt: LoopPrompt{Prompt: "p", Trigger: TriggerOnCompletion, MaxDurationSeconds: -1}, wantErr: ErrInvalidMaxDuration, }, } @@ -855,14 +855,14 @@ func TestPeriodicPrompt_Validate_Trigger(t *testing.T) { } } -// TestPeriodicPrompt_Validate_Condition verifies that Condition compile-validation +// TestLoopPrompt_Validate_Condition verifies that Condition compile-validation // is delegated to the injected ConditionValidator seam: nil validator skips the // check, a passing validator allows the condition, and a failing validator rejects // it with a wrapped error. -func TestPeriodicPrompt_Validate_Condition(t *testing.T) { +func TestLoopPrompt_Validate_Condition(t *testing.T) { t.Cleanup(func() { ConditionValidator = nil }) - p := PeriodicPrompt{Prompt: "p", Trigger: TriggerOnTasks, Condition: "tasks.changed()"} + p := LoopPrompt{Prompt: "p", Trigger: TriggerOnTasks, Condition: "tasks.changed()"} // No validator wired up: CEL compile-check is skipped, condition is accepted as-is. ConditionValidator = nil @@ -888,16 +888,16 @@ func TestPeriodicPrompt_Validate_Condition(t *testing.T) { } // Empty Condition is never validated, even with a rejecting validator wired up. - pEmpty := PeriodicPrompt{Prompt: "p", Trigger: TriggerOnTasks} + pEmpty := LoopPrompt{Prompt: "p", Trigger: TriggerOnTasks} if err := pEmpty.Validate(); err != nil { t.Errorf("Validate() with empty condition and rejecting validator error = %v, want nil", err) } } -func TestPeriodicPrompt_ClampDelay(t *testing.T) { +func TestLoopPrompt_ClampDelay(t *testing.T) { tests := []struct { name string - trigger PeriodicTrigger + trigger LoopTrigger delay int floor int wantDelay int @@ -911,7 +911,7 @@ func TestPeriodicPrompt_ClampDelay(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &PeriodicPrompt{Trigger: tt.trigger, DelaySeconds: tt.delay} + p := &LoopPrompt{Trigger: tt.trigger, DelaySeconds: tt.delay} p.ClampDelay(tt.floor) if p.DelaySeconds != tt.wantDelay { t.Errorf("DelaySeconds = %d, want %d", p.DelaySeconds, tt.wantDelay) @@ -920,7 +920,7 @@ func TestPeriodicPrompt_ClampDelay(t *testing.T) { } } -func TestPeriodicPrompt_ReachedMaxDuration(t *testing.T) { +func TestLoopPrompt_ReachedMaxDuration(t *testing.T) { now := time.Now().UTC() past := now.Add(-10 * time.Second) future := now.Add(10 * time.Second) @@ -941,7 +941,7 @@ func TestPeriodicPrompt_ReachedMaxDuration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &PeriodicPrompt{MaxDurationSeconds: tt.maxDurationSeconds, FirstRunAt: tt.firstRunAt} + p := &LoopPrompt{MaxDurationSeconds: tt.maxDurationSeconds, FirstRunAt: tt.firstRunAt} if got := p.ReachedMaxDuration(tt.now); got != tt.want { t.Errorf("ReachedMaxDuration() = %v, want %v", got, tt.want) } @@ -949,11 +949,11 @@ func TestPeriodicPrompt_ReachedMaxDuration(t *testing.T) { } } -func TestPeriodicStore_RecordSent_SetsFirstRunAt(t *testing.T) { +func TestLoopStore_RecordSent_SetsFirstRunAt(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -989,11 +989,11 @@ func TestPeriodicStore_RecordSent_SetsFirstRunAt(t *testing.T) { } } -func TestPeriodicStore_FirstRunAtPreservedOnSet(t *testing.T) { +func TestLoopStore_FirstRunAtPreservedOnSet(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -1005,7 +1005,7 @@ func TestPeriodicStore_FirstRunAtPreservedOnSet(t *testing.T) { firstRunAt := *got.FirstRunAt // Replace config via Set — FirstRunAt must survive. - p2 := &PeriodicPrompt{ + p2 := &LoopPrompt{ Prompt: "Updated", Frequency: Frequency{Value: 2, Unit: FrequencyHours}, Enabled: true, @@ -1023,11 +1023,11 @@ func TestPeriodicStore_FirstRunAtPreservedOnSet(t *testing.T) { } } -func TestPeriodicStore_Update_NewFields(t *testing.T) { +func TestLoopStore_Update_NewFields(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -1072,14 +1072,14 @@ func TestPeriodicStore_Update_NewFields(t *testing.T) { } } -// TestPeriodicStore_Update_OnTasksFields verifies that Condition, ConditionPreset, +// TestLoopStore_Update_OnTasksFields verifies that Condition, ConditionPreset, // and CooldownSeconds round-trip through Update/Get, and that a nil update leaves // them unchanged. -func TestPeriodicStore_Update_OnTasksFields(t *testing.T) { +func TestLoopStore_Update_OnTasksFields(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Trigger: TriggerOnTasks, Enabled: true, @@ -1122,11 +1122,11 @@ func TestPeriodicStore_Update_OnTasksFields(t *testing.T) { } } -func TestPeriodicStore_OnCompletion_NextScheduledAtIsNil(t *testing.T) { +func TestLoopStore_OnCompletion_NextScheduledAtIsNil(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Trigger: TriggerOnCompletion, Enabled: true, @@ -1143,11 +1143,11 @@ func TestPeriodicStore_OnCompletion_NextScheduledAtIsNil(t *testing.T) { // --- MarkStopped tests --- -func TestPeriodicStore_MarkStopped_SetsAllFields(t *testing.T) { +func TestLoopStore_MarkStopped_SetsAllFields(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - if err := ps.Set(&PeriodicPrompt{ + if err := ps.Set(&LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -1182,7 +1182,7 @@ func TestPeriodicStore_MarkStopped_SetsAllFields(t *testing.T) { } } -func TestPeriodicStore_MarkStopped_AllReasons(t *testing.T) { +func TestLoopStore_MarkStopped_AllReasons(t *testing.T) { reasons := []StoppedReason{ StoppedReasonMaxDuration, StoppedReasonMaxIterations, @@ -1194,8 +1194,8 @@ func TestPeriodicStore_MarkStopped_AllReasons(t *testing.T) { for _, reason := range reasons { t.Run(string(reason), func(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) - if err := ps.Set(&PeriodicPrompt{ + ps := NewLoopStore(dir) + if err := ps.Set(&LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -1216,11 +1216,11 @@ func TestPeriodicStore_MarkStopped_AllReasons(t *testing.T) { } } -func TestPeriodicStore_MarkStopped_PersistsAcrossRestart(t *testing.T) { +func TestLoopStore_MarkStopped_PersistsAcrossRestart(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - if err := ps.Set(&PeriodicPrompt{ + if err := ps.Set(&LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -1231,8 +1231,8 @@ func TestPeriodicStore_MarkStopped_PersistsAcrossRestart(t *testing.T) { t.Fatalf("MarkStopped() error = %v", err) } - // Simulate restart: create a fresh PeriodicStore for the same directory. - ps2 := NewPeriodicStore(dir) + // Simulate restart: create a fresh LoopStore for the same directory. + ps2 := NewLoopStore(dir) got, err := ps2.Get() if err != nil { t.Fatalf("Get() on fresh store error = %v", err) @@ -1245,21 +1245,21 @@ func TestPeriodicStore_MarkStopped_PersistsAcrossRestart(t *testing.T) { } } -func TestPeriodicStore_MarkStopped_NotFound(t *testing.T) { +func TestLoopStore_MarkStopped_NotFound(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) err := ps.MarkStopped(StoppedReasonMaxDuration) - if !errors.Is(err, ErrPeriodicNotFound) { - t.Errorf("MarkStopped() on non-existent config error = %v, want ErrPeriodicNotFound", err) + if !errors.Is(err, ErrLoopNotFound) { + t.Errorf("MarkStopped() on non-existent config error = %v, want ErrLoopNotFound", err) } } -func TestPeriodicStore_Update_EnableTrue_ClearsStoppedState(t *testing.T) { +func TestLoopStore_Update_EnableTrue_ClearsStoppedState(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - if err := ps.Set(&PeriodicPrompt{ + if err := ps.Set(&LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -1291,11 +1291,11 @@ func TestPeriodicStore_Update_EnableTrue_ClearsStoppedState(t *testing.T) { } } -func TestPeriodicStore_Update_EnableFalse_DoesNotClearStoppedState(t *testing.T) { +func TestLoopStore_Update_EnableFalse_DoesNotClearStoppedState(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - if err := ps.Set(&PeriodicPrompt{ + if err := ps.Set(&LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -1318,14 +1318,14 @@ func TestPeriodicStore_Update_EnableFalse_DoesNotClearStoppedState(t *testing.T) } } -// TestPeriodicStore_Set_ArgumentsPersisted verifies that Arguments set on a PeriodicPrompt +// TestLoopStore_Set_ArgumentsPersisted verifies that Arguments set on a LoopPrompt // via Set() survive a round-trip through Get(). -func TestPeriodicStore_Set_ArgumentsPersisted(t *testing.T) { +func TestLoopStore_Set_ArgumentsPersisted(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) args := map[string]string{"ISSUE_ID": "mitto-42", "ENV": "prod"} - if err := ps.Set(&PeriodicPrompt{ + if err := ps.Set(&LoopPrompt{ PromptName: "my-prompt", Arguments: args, Frequency: Frequency{Value: 1, Unit: FrequencyHours}, @@ -1348,13 +1348,13 @@ func TestPeriodicStore_Set_ArgumentsPersisted(t *testing.T) { } } -// TestPeriodicStore_Update_ArgumentsPersisted verifies that the arguments field +// TestLoopStore_Update_ArgumentsPersisted verifies that the arguments field // is updated via Update() and that nil leaves it unchanged. -func TestPeriodicStore_Update_ArgumentsPersisted(t *testing.T) { +func TestLoopStore_Update_ArgumentsPersisted(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - if err := ps.Set(&PeriodicPrompt{ + if err := ps.Set(&LoopPrompt{ PromptName: "my-prompt", Arguments: map[string]string{"KEY": "initial"}, Frequency: Frequency{Value: 1, Unit: FrequencyHours}, @@ -1386,7 +1386,7 @@ func TestPeriodicStore_Update_ArgumentsPersisted(t *testing.T) { } } -func TestPeriodicPrompt_PromptPreview(t *testing.T) { +func TestLoopPrompt_PromptPreview(t *testing.T) { tests := []struct { name string prompt string @@ -1442,7 +1442,7 @@ func TestPeriodicPrompt_PromptPreview(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &PeriodicPrompt{Prompt: tt.prompt} + p := &LoopPrompt{Prompt: tt.prompt} got := p.PromptPreview() if got != tt.want { t.Errorf("PromptPreview() = %q, want %q", got, tt.want) @@ -1453,11 +1453,11 @@ func TestPeriodicPrompt_PromptPreview(t *testing.T) { // --- DeferNextSchedule tests --- -func TestPeriodicStore_DeferNextSchedule(t *testing.T) { +func TestLoopStore_DeferNextSchedule(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: true, @@ -1498,11 +1498,11 @@ func TestPeriodicStore_DeferNextSchedule(t *testing.T) { } } -func TestPeriodicStore_DeferNextSchedule_OnCompletionNoop(t *testing.T) { +func TestLoopStore_DeferNextSchedule_OnCompletionNoop(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Trigger: TriggerOnCompletion, Enabled: true, @@ -1520,11 +1520,11 @@ func TestPeriodicStore_DeferNextSchedule_OnCompletionNoop(t *testing.T) { } } -func TestPeriodicStore_DeferNextSchedule_DisabledNoop(t *testing.T) { +func TestLoopStore_DeferNextSchedule_DisabledNoop(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - p := &PeriodicPrompt{ + p := &LoopPrompt{ Prompt: "Test", Frequency: Frequency{Value: 1, Unit: FrequencyHours}, Enabled: false, @@ -1542,11 +1542,11 @@ func TestPeriodicStore_DeferNextSchedule_DisabledNoop(t *testing.T) { } } -func TestPeriodicStore_DeferNextSchedule_NotFound(t *testing.T) { +func TestLoopStore_DeferNextSchedule_NotFound(t *testing.T) { dir := t.TempDir() - ps := NewPeriodicStore(dir) + ps := NewLoopStore(dir) - if err := ps.DeferNextSchedule(time.Minute); err != ErrPeriodicNotFound { - t.Errorf("DeferNextSchedule() on empty store error = %v, want ErrPeriodicNotFound", err) + if err := ps.DeferNextSchedule(time.Minute); err != ErrLoopNotFound { + t.Errorf("DeferNextSchedule() on empty store error = %v, want ErrLoopNotFound", err) } } diff --git a/internal/session/migration_002_rename_periodic_to_loop.go b/internal/session/migration_002_rename_periodic_to_loop.go new file mode 100644 index 00000000..6a836b85 --- /dev/null +++ b/internal/session/migration_002_rename_periodic_to_loop.go @@ -0,0 +1,77 @@ +package session + +import ( + "os" + "path/filepath" + + "github.com/inercia/mitto/internal/logging" +) + +// legacyPeriodicFileName is the pre-rename per-session loop-state file name. +// See mitto-8ir.1: periodicFileName ("periodic.json") was renamed to +// loopFileName ("loop.json"). This migration moves existing on-disk files +// from the old name to the new one so no data is silently dropped. +const legacyPeriodicFileName = "periodic.json" + +func init() { + RegisterMigration(Migration{ + Name: "002_rename_periodic_to_loop", + Description: "Rename per-session periodic.json to loop.json (mitto-8ir)", + Run: migrateRenamePeriodicToLoop, + }) +} + +// migrateRenamePeriodicToLoop renames the per-session periodic.json file to +// loop.json for every session directory under baseDir. The file content is +// unchanged (the LoopPrompt JSON keys contain no "periodic" string) — only +// the filename changes. +// +// The migration is idempotent and safe: +// - Sessions without a periodic.json are skipped (no-op). +// - Sessions that already have a loop.json are skipped without touching +// periodic.json, so an existing loop.json is never clobbered. +// - Re-running the migration after a successful rename is a no-op, since +// periodic.json no longer exists. +func migrateRenamePeriodicToLoop(baseDir string, _ *MigrationContext) (int, error) { + log := logging.Session() + + entries, err := os.ReadDir(baseDir) + if err != nil { + return 0, err + } + + modified := 0 + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + sessionDir := filepath.Join(baseDir, entry.Name()) + oldPath := filepath.Join(sessionDir, legacyPeriodicFileName) + newPath := filepath.Join(sessionDir, loopFileName) + + // No legacy file to migrate — nothing to do. + if _, err := os.Stat(oldPath); err != nil { + continue + } + + // Never clobber an existing loop.json. + if _, err := os.Stat(newPath); err == nil { + log.Warn("skipping periodic.json rename: loop.json already exists", + "session_dir", sessionDir) + continue + } + + if err := os.Rename(oldPath, newPath); err != nil { + log.Warn("failed to rename periodic.json to loop.json", + "session_dir", sessionDir, "error", err) + continue + } + + log.Debug("renamed periodic.json to loop.json", + "session_dir", sessionDir) + modified++ + } + + return modified, nil +} diff --git a/internal/session/migration_002_rename_periodic_to_loop_test.go b/internal/session/migration_002_rename_periodic_to_loop_test.go new file mode 100644 index 00000000..7c5dba1f --- /dev/null +++ b/internal/session/migration_002_rename_periodic_to_loop_test.go @@ -0,0 +1,197 @@ +package session + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMigrateRenamePeriodicToLoop_RenamesFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "migration_loop_rename_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sessionDir := filepath.Join(tmpDir, "session-1") + if err := os.MkdirAll(sessionDir, 0755); err != nil { + t.Fatalf("failed to create session dir: %v", err) + } + + oldPath := filepath.Join(sessionDir, legacyPeriodicFileName) + content := []byte(`{"prompt":"hello","frequency":{"value":1,"unit":"hours"},"enabled":true}`) + if err := os.WriteFile(oldPath, content, 0644); err != nil { + t.Fatalf("failed to write periodic.json: %v", err) + } + + modified, err := migrateRenamePeriodicToLoop(tmpDir, nil) + if err != nil { + t.Fatalf("migration failed: %v", err) + } + if modified != 1 { + t.Errorf("expected 1 session modified, got %d", modified) + } + + newPath := filepath.Join(sessionDir, loopFileName) + if _, err := os.Stat(oldPath); !os.IsNotExist(err) { + t.Errorf("periodic.json should no longer exist, stat err = %v", err) + } + got, err := os.ReadFile(newPath) + if err != nil { + t.Fatalf("loop.json should exist: %v", err) + } + if string(got) != string(content) { + t.Errorf("loop.json content = %q, want %q (content must be unchanged)", got, content) + } +} + +func TestMigrateRenamePeriodicToLoop_IdempotentReRun(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "migration_loop_rename_idempotent") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sessionDir := filepath.Join(tmpDir, "session-1") + if err := os.MkdirAll(sessionDir, 0755); err != nil { + t.Fatalf("failed to create session dir: %v", err) + } + + oldPath := filepath.Join(sessionDir, legacyPeriodicFileName) + content := []byte(`{"prompt":"hello"}`) + if err := os.WriteFile(oldPath, content, 0644); err != nil { + t.Fatalf("failed to write periodic.json: %v", err) + } + + // First run performs the rename. + modified, err := migrateRenamePeriodicToLoop(tmpDir, nil) + if err != nil { + t.Fatalf("first migration run failed: %v", err) + } + if modified != 1 { + t.Fatalf("expected 1 session modified on first run, got %d", modified) + } + + // Second run should be a no-op (periodic.json no longer exists). + modified, err = migrateRenamePeriodicToLoop(tmpDir, nil) + if err != nil { + t.Fatalf("second migration run failed: %v", err) + } + if modified != 0 { + t.Errorf("expected 0 sessions modified on idempotent re-run, got %d", modified) + } + + newPath := filepath.Join(sessionDir, loopFileName) + got, err := os.ReadFile(newPath) + if err != nil { + t.Fatalf("loop.json should still exist: %v", err) + } + if string(got) != string(content) { + t.Errorf("loop.json content changed across idempotent re-run: %q, want %q", got, content) + } +} + +func TestMigrateRenamePeriodicToLoop_NoOpWhenAbsent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "migration_loop_rename_absent") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sessionDir := filepath.Join(tmpDir, "session-1") + if err := os.MkdirAll(sessionDir, 0755); err != nil { + t.Fatalf("failed to create session dir: %v", err) + } + // No periodic.json and no loop.json present. + + modified, err := migrateRenamePeriodicToLoop(tmpDir, nil) + if err != nil { + t.Fatalf("migration failed: %v", err) + } + if modified != 0 { + t.Errorf("expected 0 sessions modified when periodic.json absent, got %d", modified) + } + + oldPath := filepath.Join(sessionDir, legacyPeriodicFileName) + newPath := filepath.Join(sessionDir, loopFileName) + if _, err := os.Stat(oldPath); !os.IsNotExist(err) { + t.Errorf("periodic.json should still be absent, stat err = %v", err) + } + if _, err := os.Stat(newPath); !os.IsNotExist(err) { + t.Errorf("loop.json should not have been created, stat err = %v", err) + } +} + +func TestMigrateRenamePeriodicToLoop_NoClobberExistingLoop(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "migration_loop_rename_noclobber") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sessionDir := filepath.Join(tmpDir, "session-1") + if err := os.MkdirAll(sessionDir, 0755); err != nil { + t.Fatalf("failed to create session dir: %v", err) + } + + oldPath := filepath.Join(sessionDir, legacyPeriodicFileName) + oldContent := []byte(`{"prompt":"legacy"}`) + if err := os.WriteFile(oldPath, oldContent, 0644); err != nil { + t.Fatalf("failed to write periodic.json: %v", err) + } + + newPath := filepath.Join(sessionDir, loopFileName) + newContent := []byte(`{"prompt":"already-migrated"}`) + if err := os.WriteFile(newPath, newContent, 0644); err != nil { + t.Fatalf("failed to write loop.json: %v", err) + } + + modified, err := migrateRenamePeriodicToLoop(tmpDir, nil) + if err != nil { + t.Fatalf("migration failed: %v", err) + } + if modified != 0 { + t.Errorf("expected 0 sessions modified when loop.json already exists, got %d", modified) + } + + // Both files must be left untouched: existing loop.json is not clobbered, + // and the (now orphaned but not lost) periodic.json is left in place for + // manual inspection rather than being silently deleted. + gotOld, err := os.ReadFile(oldPath) + if err != nil { + t.Fatalf("periodic.json should still exist: %v", err) + } + if string(gotOld) != string(oldContent) { + t.Errorf("periodic.json content changed, got %q, want %q", gotOld, oldContent) + } + gotNew, err := os.ReadFile(newPath) + if err != nil { + t.Fatalf("loop.json should still exist: %v", err) + } + if string(gotNew) != string(newContent) { + t.Errorf("loop.json content was clobbered, got %q, want %q", gotNew, newContent) + } +} + +func TestMigrateRenamePeriodicToLoop_SkipsNonDirEntries(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "migration_loop_rename_nondir") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // A stray file directly under baseDir (e.g. migrations.json) must not + // cause an error or be treated as a session directory. + strayFile := filepath.Join(tmpDir, "migrations.json") + if err := os.WriteFile(strayFile, []byte(`{}`), 0644); err != nil { + t.Fatalf("failed to write stray file: %v", err) + } + + modified, err := migrateRenamePeriodicToLoop(tmpDir, nil) + if err != nil { + t.Fatalf("migration failed: %v", err) + } + if modified != 0 { + t.Errorf("expected 0 sessions modified, got %d", modified) + } +} diff --git a/internal/session/queue.go b/internal/session/queue.go index 255e8882..aea3efd0 100644 --- a/internal/session/queue.go +++ b/internal/session/queue.go @@ -97,8 +97,19 @@ type QueuedMessage struct { // PromptName is the name of the workspace prompt to send by name (resolved to // full text at dispatch). Empty for ad-hoc messages. PromptName string `json:"prompt_name,omitempty"` + // Origin marks who enqueued the message: user (human UI, default) or agent + // (cross-session/MCP dispatch). Empty = user for backward compatibility. + Origin string `json:"origin,omitempty"` } +// Queue message origin sentinels (mitto-nvb): distinguish human-typed messages +// that were queued (fail-open on template render errors) from cross-session/MCP +// dispatches (fail-closed, preserving the mitto-e7u guarantee). +const ( + QueueOriginUser = "user" + QueueOriginAgent = "agent" +) + // QueueFile represents the persisted queue state. type QueueFile struct { // Messages is the ordered list of queued messages (FIFO). @@ -172,7 +183,15 @@ func (q *Queue) writeQueue(qf *QueueFile) error { // message text when it is sent to the agent. // If promptName is non-empty, the message is stored by name and resolved to full text // at dispatch via PromptWithMeta (message should be empty in this case). +// Delegates to AddWithOrigin with QueueOriginUser (human UI, the default origin). func (q *Queue) Add(message string, imageIDs, fileIDs []string, clientID string, scheduledTime *time.Time, maxSize int, arguments map[string]string, promptName string) (QueuedMessage, error) { + return q.AddWithOrigin(message, imageIDs, fileIDs, clientID, scheduledTime, maxSize, arguments, promptName, QueueOriginUser) +} + +// AddWithOrigin is identical to Add but additionally records the message's origin +// (QueueOriginUser or QueueOriginAgent). Used by cross-session/MCP dispatch sites +// to mark the message as agent-originated (mitto-nvb). +func (q *Queue) AddWithOrigin(message string, imageIDs, fileIDs []string, clientID string, scheduledTime *time.Time, maxSize int, arguments map[string]string, promptName string, origin string) (QueuedMessage, error) { q.mu.Lock() defer q.mu.Unlock() @@ -196,6 +215,7 @@ func (q *Queue) Add(message string, imageIDs, fileIDs []string, clientID string, ScheduledTime: scheduledTime, Arguments: arguments, PromptName: promptName, + Origin: origin, } qf.Messages = append(qf.Messages, msg) diff --git a/internal/session/queue_test.go b/internal/session/queue_test.go index 1348dce2..5530600c 100644 --- a/internal/session/queue_test.go +++ b/internal/session/queue_test.go @@ -61,6 +61,29 @@ func TestQueue_AddAndList(t *testing.T) { } } +func TestQueue_AddWithOrigin(t *testing.T) { + dir := t.TempDir() + q := NewQueue(dir) + + // AddWithOrigin with QueueOriginAgent persists Origin == QueueOriginAgent. + agentMsg, err := q.AddWithOrigin("agent message", nil, nil, "client1", nil, 0, nil, "", QueueOriginAgent) + if err != nil { + t.Fatalf("AddWithOrigin() error = %v", err) + } + if agentMsg.Origin != QueueOriginAgent { + t.Errorf("AddWithOrigin() origin = %q, want %q", agentMsg.Origin, QueueOriginAgent) + } + + // Plain Add() defaults to QueueOriginUser. + userMsg, err := q.Add("user message", nil, nil, "client2", nil, 0, nil, "") + if err != nil { + t.Fatalf("Add() error = %v", err) + } + if userMsg.Origin != QueueOriginUser { + t.Errorf("Add() origin = %q, want %q", userMsg.Origin, QueueOriginUser) + } +} + func TestQueue_Get(t *testing.T) { dir := t.TempDir() q := NewQueue(dir) diff --git a/internal/session/store.go b/internal/session/store.go index ea4813b2..24643466 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -96,10 +96,10 @@ func (s *Store) ActionButtons(sessionID string) *ActionButtonsStore { return NewActionButtonsStore(s.sessionDir(sessionID)) } -// Periodic returns a PeriodicStore instance for managing the periodic prompt of a session. -// The returned PeriodicStore is safe for concurrent use. -func (s *Store) Periodic(sessionID string) *PeriodicStore { - return NewPeriodicStore(s.sessionDir(sessionID)) +// Loop returns a LoopStore instance for managing the loop prompt of a session. +// The returned LoopStore is safe for concurrent use. +func (s *Store) Loop(sessionID string) *LoopStore { + return NewLoopStore(s.sessionDir(sessionID)) } // Callback returns a CallbackStore instance for managing the callback token of a session. diff --git a/internal/web/config_handlers.go b/internal/web/config_handlers.go index 0f37dba6..6d18925a 100644 --- a/internal/web/config_handlers.go +++ b/internal/web/config_handlers.go @@ -6,6 +6,7 @@ import ( "strings" configPkg "github.com/inercia/mitto/internal/config" + "github.com/inercia/mitto/internal/conversation" "github.com/inercia/mitto/internal/secrets" "github.com/inercia/mitto/internal/web/handlers" "github.com/inercia/mitto/internal/web/middleware" @@ -329,6 +330,14 @@ func (s *Server) buildNewSettings(req *ConfigSaveRequest) (*configPkg.Settings, modelsConfig = filteredModels } + // Preserve global shortcut buttons. They are not part of the config-save + // request body (edited via the dedicated /api/global/shortcuts endpoint), so + // carry the existing value forward to avoid wiping them on an unrelated save. + var shortcutsConfig map[string][]configPkg.ShortcutButton + if s.config.MittoConfig != nil { + shortcutsConfig = s.config.MittoConfig.Shortcuts + } + return &configPkg.Settings{ ACPServers: newACPServers, Prompts: settingsPrompts, @@ -339,6 +348,7 @@ func (s *Server) buildNewSettings(req *ConfigSaveRequest) (*configPkg.Settings, Permissions: permissionsConfig, MCP: mcpConfig, Models: modelsConfig, + Shortcuts: shortcutsConfig, }, nil } @@ -399,12 +409,12 @@ func (s *Server) applyConfigChanges(req *ConfigSaveRequest, settings *configPkg. // Update session manager's global conversations config so new sessions use the updated settings s.sessionManager.SetGlobalConversations(settings.Conversations) - // Update GC periodic suspend threshold at runtime if session config changed + // Update GC loop suspend threshold at runtime if session config changed if settings.Session != nil && s.acpProcessManager != nil { - if d, enabled := settings.Session.ParsePeriodicSuspendTimeout(); enabled { - s.acpProcessManager.UpdatePeriodicSuspendThreshold(d) + if d, enabled := settings.Session.ParseLoopSuspendTimeout(); enabled { + s.acpProcessManager.UpdateLoopSuspendThreshold(d) } else { - s.acpProcessManager.UpdatePeriodicSuspendThreshold(0) + s.acpProcessManager.UpdateLoopSuspendThreshold(0) } if bytes, enabled := settings.Session.ParseMemoryRecycleThreshold(); enabled { s.acpProcessManager.UpdateMemoryRecycleThreshold(bytes) @@ -412,6 +422,16 @@ func (s *Server) applyConfigChanges(req *ConfigSaveRequest, settings *configPkg. s.acpProcessManager.UpdateMemoryRecycleThreshold(0) } } + + // Update the prompt inactivity watchdog timeout at runtime if session config + // changed, so a live settings change takes effect without a restart (mitto-54y). + if settings.Session != nil { + if d, enabled := settings.Session.ParseAgentInactivityTimeout(); enabled { + conversation.SetPromptInactivityTimeout(d) + } else { + conversation.SetPromptInactivityTimeout(0) + } + } } // ACP command/cwd/env are resolved from global config at runtime and are never diff --git a/internal/web/handlers/beads.go b/internal/web/handlers/beads.go index 8decd36f..6fd68769 100644 --- a/internal/web/handlers/beads.go +++ b/internal/web/handlers/beads.go @@ -6,10 +6,20 @@ import ( "net/http" "path/filepath" "strings" + "time" "github.com/inercia/mitto/internal/beads" ) +// beadsReadRetries is the number of extra attempts for read-only bd queries +// when they fail transiently (dolt store warm-up / instability). Read queries +// are idempotent so retrying is safe. It is a var only so tests can adjust it. +var beadsReadRetries = 2 + +// beadsRetryBackoff is the base backoff between read retries. It is a var +// only so tests can shorten it. +var beadsRetryBackoff = 150 * time.Millisecond + // beadsClient returns the injectable beads Client. When the handlers were // constructed without an explicit client (e.g. in tests), it falls back to a // default client backed by the real bd binary. @@ -55,7 +65,11 @@ func isValidBeadsIssueRef(s string) bool { // writeBeadsError reports a bd-command failure using the canonical error // envelope (HTTP 500), carrying any captured stderr under error.details.stderr. -func writeBeadsError(w http.ResponseWriter, err error) { +// It also logs the failure (nil-guarded) so 500s are never silent in mitto.log. +func (h *Handlers) writeBeadsError(w http.ResponseWriter, r *http.Request, err error) { + if h.deps.Logger != nil { + h.deps.Logger.Error("beads command failed", "error", err, "stderr", beads.StderrOf(err), "path", r.URL.Path) + } var details map[string]any if s := beads.StderrOf(err); s != "" { details = map[string]any{"stderr": s} @@ -63,6 +77,35 @@ func writeBeadsError(w http.ResponseWriter, err error) { writeJSON(w, http.StatusInternalServerError, errorEnvelope{Error: errorBody{Code: errCodeServerError, Message: err.Error(), Details: details}}) } +// runBeadsRead executes a read-only bd query with bounded retries on transient +// failures. It stops retrying on context cancellation and on not-found errors, +// since retrying either would be pointless (the former can never succeed, the +// latter is a genuine result rather than a transient failure). +func (h *Handlers) runBeadsRead(ctx context.Context, fn func(context.Context) ([]byte, error)) ([]byte, error) { + var out []byte + var err error + for attempt := 0; attempt <= beadsReadRetries; attempt++ { + out, err = fn(ctx) + if err == nil { + return out, nil + } + if errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil || beads.IsNotFound(err) { + return out, err + } + if attempt < beadsReadRetries { + if h.deps.Logger != nil { + h.deps.Logger.Warn("beads read failed, retrying", "attempt", attempt+1, "error", err) + } + select { + case <-ctx.Done(): + return out, ctx.Err() + case <-time.After(beadsRetryBackoff * time.Duration(attempt+1)): + } + } + } + return out, err +} + // HandleBeadsList handles GET /api/issues?working_dir=... // Runs "bd list --json --all -n 0" in the workspace directory. // Requires authentication via the standard auth middleware (same as other API endpoints). @@ -88,13 +131,15 @@ func (h *Handlers) HandleBeadsList(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), auxBackedRequestTimeout) defer cancel() - out, err := h.beadsClient().List(ctx, workingDir) + out, err := h.runBeadsRead(ctx, func(ctx context.Context) ([]byte, error) { + return h.beadsClient().List(ctx, workingDir) + }) if err != nil { if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { writeRetryableUnavailable(w, "Task service is busy. Please try again in a few seconds.", 5) return } - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -131,13 +176,15 @@ func (h *Handlers) HandleBeadsStats(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), auxBackedRequestTimeout) defer cancel() - out, err := h.beadsClient().Status(ctx, workingDir) + out, err := h.runBeadsRead(ctx, func(ctx context.Context) ([]byte, error) { + return h.beadsClient().Status(ctx, workingDir) + }) if err != nil { if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { writeRetryableUnavailable(w, "Task service is busy. Please try again in a few seconds.", 5) return } - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -181,7 +228,9 @@ func (h *Handlers) HandleBeadsShow(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), auxBackedRequestTimeout) defer cancel() - out, err := h.beadsClient().Show(ctx, workingDir, id) + out, err := h.runBeadsRead(ctx, func(ctx context.Context) ([]byte, error) { + return h.beadsClient().Show(ctx, workingDir, id) + }) if err != nil { if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { writeRetryableUnavailable(w, "Task service is busy. Please try again in a few seconds.", 5) @@ -191,7 +240,7 @@ func (h *Handlers) HandleBeadsShow(w http.ResponseWriter, r *http.Request) { writeErrorJSON(w, http.StatusNotFound, "", "Issue not found") return } - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } diff --git a/internal/web/handlers/beads_config.go b/internal/web/handlers/beads_config.go index 180a60f7..aa75df58 100644 --- a/internal/web/handlers/beads_config.go +++ b/internal/web/handlers/beads_config.go @@ -61,7 +61,7 @@ func (h *Handlers) handleBeadsConfigGet(w http.ResponseWriter, r *http.Request) result, err := h.beadsClient().ConfigShow(r.Context(), workingDir) if err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -99,7 +99,7 @@ func (h *Handlers) handleBeadsConfigSet(w http.ResponseWriter, r *http.Request) } if err := h.beadsClient().ConfigSet(r.Context(), workingDir, req.Key, req.Value); err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -129,7 +129,7 @@ func (h *Handlers) handleBeadsConfigUnset(w http.ResponseWriter, r *http.Request } if err := h.beadsClient().ConfigUnset(r.Context(), workingDir, key); err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -145,20 +145,28 @@ type beadsUpstreamRequest struct { PullPrompt string `json:"pull_prompt"` PushPrompt string `json:"push_prompt"` SyncPrompt string `json:"sync_prompt"` + // PullPromptArgs, PushPromptArgs, SyncPromptArgs are the argument values to + // forward to the corresponding prompt when it is dispatched. + PullPromptArgs map[string]string `json:"pull_prompt_args,omitempty"` + PushPromptArgs map[string]string `json:"push_prompt_args,omitempty"` + SyncPromptArgs map[string]string `json:"sync_prompt_args,omitempty"` } // beadsUpstreamResponse reports the configured upstream task system for a folder. type beadsUpstreamResponse struct { - Upstream string `json:"upstream"` - PullPrompt string `json:"pull_prompt,omitempty"` - PushPrompt string `json:"push_prompt,omitempty"` - SyncPrompt string `json:"sync_prompt,omitempty"` + Upstream string `json:"upstream"` + PullPrompt string `json:"pull_prompt,omitempty"` + PushPrompt string `json:"push_prompt,omitempty"` + SyncPrompt string `json:"sync_prompt,omitempty"` + PullPromptArgs map[string]string `json:"pull_prompt_args,omitempty"` + PushPromptArgs map[string]string `json:"push_prompt_args,omitempty"` + SyncPromptArgs map[string]string `json:"sync_prompt_args,omitempty"` } // HandleBeadsUpstream manages the per-folder beads upstream task system stored // in folders.json (folder-native, not a bd config value): -// - GET /api/issues/upstream?working_dir=... -> {"upstream":"none|jira|github|gitlab|linear|prompts","pull_prompt","push_prompt","sync_prompt"} -// - PUT /api/issues/upstream?working_dir=... (body: upstream,pull_prompt,push_prompt,sync_prompt) -> persists the choice +// - GET /api/issues/upstream?working_dir=... -> {"upstream":"none|jira|github|gitlab|linear|prompts","pull_prompt","push_prompt","sync_prompt","pull_prompt_args","push_prompt_args","sync_prompt_args"} +// - PUT /api/issues/upstream?working_dir=... (body: upstream,pull_prompt,push_prompt,sync_prompt,pull_prompt_args,push_prompt_args,sync_prompt_args) -> persists the choice // // Requires authentication via the standard auth middleware (same as other API endpoints). func (h *Handlers) HandleBeadsUpstream(w http.ResponseWriter, r *http.Request) { @@ -192,11 +200,15 @@ func (h *Handlers) handleBeadsUpstreamGet(w http.ResponseWriter, r *http.Request upstream = "none" } pull, push, sync := config.FolderBeadsPrompts(workingDir) + pullArgs, pushArgs, syncArgs := config.FolderBeadsPromptArgs(workingDir) writeJSONOK(w, beadsUpstreamResponse{ - Upstream: upstream, - PullPrompt: pull, - PushPrompt: push, - SyncPrompt: sync, + Upstream: upstream, + PullPrompt: pull, + PushPrompt: push, + SyncPrompt: sync, + PullPromptArgs: pullArgs, + PushPromptArgs: pushArgs, + SyncPromptArgs: syncArgs, }) } @@ -227,8 +239,9 @@ func (h *Handlers) handleBeadsUpstreamSet(w http.ResponseWriter, r *http.Request } if req.Upstream == "prompts" { - // Validate each non-empty prompt name: it must exist in the folder's - // effective prompt list and must have no parameters (len(Parameters)==0). + // Validate each non-empty prompt name exists in the folder's effective + // prompt list. Parametrized prompts are allowed: their arguments are + // supplied via req.*PromptArgs and forwarded at dispatch time. var allPrompts []config.WebPrompt if h.deps.GetWorkspacePromptsAll != nil { allPrompts = h.deps.GetWorkspacePromptsAll(workingDir) @@ -245,23 +258,19 @@ func (h *Handlers) handleBeadsUpstreamSet(w http.ResponseWriter, r *http.Request if name == "" { continue // empty is allowed; operation simply unconfigured } - p, ok := promptIdx[strings.ToLower(name)] - if !ok { + if _, ok := promptIdx[strings.ToLower(name)]; !ok { writeErrorJSON(w, http.StatusBadRequest, "", fmt.Sprintf("%s: prompt %q not found in this folder's prompt list", field, name)) return } - if len(p.Parameters) > 0 { - writeErrorJSON(w, http.StatusBadRequest, "", fmt.Sprintf("%s: prompt %q requires parameters and cannot be used as a beads action prompt", field, name)) - return - } } - if err := config.SetFolderBeadsPromptUpstream(workingDir, req.PullPrompt, req.PushPrompt, req.SyncPrompt); err != nil { - writeBeadsError(w, err) + if err := config.SetFolderBeadsPromptUpstream(workingDir, req.PullPrompt, req.PushPrompt, req.SyncPrompt, + req.PullPromptArgs, req.PushPromptArgs, req.SyncPromptArgs); err != nil { + h.writeBeadsError(w, r, err) return } } else { if err := config.SetFolderBeadsUpstream(workingDir, req.Upstream); err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } } @@ -271,11 +280,15 @@ func (h *Handlers) handleBeadsUpstreamSet(w http.ResponseWriter, r *http.Request upstream = "none" } pull, push, sync := config.FolderBeadsPrompts(workingDir) + pullArgs, pushArgs, syncArgs := config.FolderBeadsPromptArgs(workingDir) writeJSONOK(w, beadsUpstreamResponse{ - Upstream: upstream, - PullPrompt: pull, - PushPrompt: push, - SyncPrompt: sync, + Upstream: upstream, + PullPrompt: pull, + PushPrompt: push, + SyncPrompt: sync, + PullPromptArgs: pullArgs, + PushPromptArgs: pushArgs, + SyncPromptArgs: syncArgs, }) } @@ -340,7 +353,7 @@ func (h *Handlers) HandleBeadsSync(w http.ResponseWriter, r *http.Request) { out, err := h.beadsClient().Sync(r.Context(), workingDir, upstream, req.Action) if err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } diff --git a/internal/web/handlers/beads_crud.go b/internal/web/handlers/beads_crud.go index 874a8b51..7976305a 100644 --- a/internal/web/handlers/beads_crud.go +++ b/internal/web/handlers/beads_crud.go @@ -128,7 +128,7 @@ func (h *Handlers) HandleBeadsCreate(w http.ResponseWriter, r *http.Request) { Notes: strings.TrimSpace(req.Notes), }) if err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -181,7 +181,7 @@ func (h *Handlers) HandleBeadsCleanup(w http.ResponseWriter, r *http.Request) { writeRetryableUnavailable(w, "Task service is busy. Please try again in a few seconds.", 5) return } - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } total := len(ids) @@ -285,7 +285,7 @@ func (h *Handlers) HandleBeadsDelete(w http.ResponseWriter, r *http.Request) { } if err := h.beadsClient().Delete(r.Context(), workingDir, id); err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -342,7 +342,7 @@ func (h *Handlers) HandleBeadsStatus(w http.ResponseWriter, r *http.Request) { } if err := h.beadsClient().SetStatus(r.Context(), workingDir, id, verb); err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -423,7 +423,7 @@ func (h *Handlers) HandleBeadsUpdate(w http.ResponseWriter, r *http.Request) { Assignee: req.Assignee, Notes: req.Notes, }); err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -475,7 +475,7 @@ func (h *Handlers) HandleBeadsComment(w http.ResponseWriter, r *http.Request) { } if err := h.beadsClient().Comment(r.Context(), workingDir, id, req.Text); err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } @@ -554,9 +554,117 @@ func (h *Handlers) HandleBeadsDep(w http.ResponseWriter, r *http.Request) { Type: req.Type, Action: req.Action, }); err != nil { - writeBeadsError(w, err) + h.writeBeadsError(w, r, err) return } writeJSONOK(w, beadsActionResponse{OK: true}) } + +// beadsLabelRequest is the JSON body for POST /api/issues/{id}/labels. +// Action must be "add" or "remove"; Label is the label name to add or remove. +type beadsLabelRequest struct { + Label string `json:"label"` + Action string `json:"action"` +} + +// HandleBeadsLabel handles POST /api/issues/{id}/labels?working_dir=... +// For action "add" it runs "bd label add <id> <label>"; for "remove" it runs +// "bd label remove <id> <label>". +// Requires authentication via the standard auth middleware (same as other API endpoints). +func (h *Handlers) HandleBeadsLabel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + + var req beadsLabelRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "", "Invalid request body") + return + } + + workingDir := r.URL.Query().Get("working_dir") + id := r.PathValue("id") + if workingDir == "" { + writeErrorJSON(w, http.StatusBadRequest, "", "working_dir is required") + return + } + if !filepath.IsAbs(workingDir) { + writeErrorJSON(w, http.StatusBadRequest, "", "working_dir must be an absolute path") + return + } + if !isValidBeadsIssueRef(id) { + writeErrorJSON(w, http.StatusBadRequest, "", "id is required") + return + } + if !beads.IsValidLabel(req.Label) { + writeErrorJSON(w, http.StatusBadRequest, "", "label is required") + return + } + switch req.Action { + case "add", "remove": + // valid + default: + writeErrorJSON(w, http.StatusBadRequest, "", "action must be 'add' or 'remove'") + return + } + if !h.isKnownWorkspaceDir(workingDir) { + writeErrorJSON(w, http.StatusBadRequest, "", "working_dir does not match any known workspace") + return + } + + if err := h.beadsClient().Label(r.Context(), workingDir, beads.LabelParams{ + ID: id, + Label: strings.TrimSpace(req.Label), + Action: req.Action, + }); err != nil { + h.writeBeadsError(w, r, err) + return + } + + writeJSONOK(w, beadsActionResponse{OK: true}) +} + +// HandleBeadsLabelsAll handles GET /api/issues/labels?working_dir=... +// Runs "bd label list-all --json" in the workspace directory, returning the +// list of {"label","count"} objects for every unique label in the database. +// Used by the Tasks view to suggest existing labels when adding one to an issue. +// Requires authentication via the standard auth middleware (same as other API endpoints). +func (h *Handlers) HandleBeadsLabelsAll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + + workingDir := r.URL.Query().Get("working_dir") + if workingDir == "" { + writeErrorJSON(w, http.StatusBadRequest, "", "working_dir is required") + return + } + if !filepath.IsAbs(workingDir) { + writeErrorJSON(w, http.StatusBadRequest, "", "working_dir must be an absolute path") + return + } + if !h.isKnownWorkspaceDir(workingDir) { + writeErrorJSON(w, http.StatusBadRequest, "", "working_dir does not match any known workspace") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), auxBackedRequestTimeout) + defer cancel() + out, err := h.beadsClient().ListAllLabels(ctx, workingDir) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { + writeRetryableUnavailable(w, "Task service is busy. Please try again in a few seconds.", 5) + return + } + h.writeBeadsError(w, r, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + w.Write(out) //nolint:errcheck +} diff --git a/internal/web/handlers/beads_test.go b/internal/web/handlers/beads_test.go index 65434023..f629ff6c 100644 --- a/internal/web/handlers/beads_test.go +++ b/internal/web/handlers/beads_test.go @@ -1,12 +1,15 @@ package handlers import ( + "bytes" "context" "encoding/json" "errors" + "log/slog" "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -87,6 +90,12 @@ func (c *stubBeadsClient) Comment(_ context.Context, _, _, _ string) error { ret func (c *stubBeadsClient) Dep(_ context.Context, _ string, _ beads.DepParams) error { return nil } +func (c *stubBeadsClient) Label(_ context.Context, _ string, _ beads.LabelParams) error { + return nil +} +func (c *stubBeadsClient) ListAllLabels(_ context.Context, _ string) ([]byte, error) { + return []byte(`[]`), nil +} func (c *stubBeadsClient) ConfigShow(_ context.Context, _ string) (map[string]string, error) { return nil, nil } @@ -300,6 +309,110 @@ func TestHandleBeadsList_Timeout_ReturnsRetryable503(t *testing.T) { } } +// TestHandleBeadsList_PersistentError_LogsError verifies AC1: a persistent +// (non-not-found, non-timeout) bd failure both returns the canonical 500 +// envelope AND emits a backend error log carrying the underlying cause, so +// the failure is never silent in mitto.log (regression test for mitto-rxd). +func TestHandleBeadsList_PersistentError_LogsError(t *testing.T) { + old := beadsReadRetries + beadsReadRetries = 0 // fail immediately, no retries needed for this test + defer func() { beadsReadRetries = old }() + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, nil)) + + sm := newBeadsTestSM() + s := New(Deps{SessionManager: sm, BeadsClient: &listErrorClient{}, Logger: logger}) + + req := localhostRequest("/api/issues?working_dir=/test/workspace") + w := httptest.NewRecorder() + s.handleBeadsList(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", w.Code, http.StatusInternalServerError) + } + logged := logBuf.String() + if !strings.Contains(logged, "level=ERROR") { + t.Errorf("log output = %q, want it to contain %q", logged, "level=ERROR") + } + if !strings.Contains(logged, "beads command failed") { + t.Errorf("log output = %q, want it to contain %q", logged, "beads command failed") + } + if !strings.Contains(logged, "bd: command failed: exit status 1") { + t.Errorf("log output = %q, want it to contain the underlying error", logged) + } +} + +// flakyListClient's List fails failCount times, then succeeds. Used to verify +// AC2: transient bd failures are retried instead of surfacing as bare 500s. +type flakyListClient struct { + stubBeadsClient + failCount int32 + calls int32 +} + +func (c *flakyListClient) List(_ context.Context, _ string) ([]byte, error) { + n := atomic.AddInt32(&c.calls, 1) + if n <= c.failCount { + return nil, errors.New("bd: transient dolt error") + } + return []byte(`[]`), nil +} + +// TestHandleBeadsList_TransientFailure_RetriesThenSucceeds verifies AC2: a +// fail-then-succeed sequence from a read-only bd query is retried internally +// and returns HTTP 200 to the client, rather than a bare 500. +func TestHandleBeadsList_TransientFailure_RetriesThenSucceeds(t *testing.T) { + oldRetries := beadsReadRetries + oldBackoff := beadsRetryBackoff + beadsReadRetries = 2 + beadsRetryBackoff = time.Millisecond + defer func() { + beadsReadRetries = oldRetries + beadsRetryBackoff = oldBackoff + }() + + client := &flakyListClient{failCount: 2} + s := newBeadsTestServerWithClient(client) + req := localhostRequest("/api/issues?working_dir=/test/workspace") + w := httptest.NewRecorder() + s.handleBeadsList(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", w.Code, http.StatusOK, w.Body.String()) + } + if got := atomic.LoadInt32(&client.calls); got != 3 { + t.Errorf("List call count = %d, want 3 (2 failures + 1 success)", got) + } +} + +// TestHandleBeadsList_PersistentTransientFailure_ExhaustsRetries verifies that +// when every attempt fails, the handler still returns the canonical 500 +// envelope after exhausting beadsReadRetries (not an infinite/unbounded retry). +func TestHandleBeadsList_PersistentTransientFailure_ExhaustsRetries(t *testing.T) { + oldRetries := beadsReadRetries + oldBackoff := beadsRetryBackoff + beadsReadRetries = 2 + beadsRetryBackoff = time.Millisecond + defer func() { + beadsReadRetries = oldRetries + beadsRetryBackoff = oldBackoff + }() + + client := &flakyListClient{failCount: 100} + s := newBeadsTestServerWithClient(client) + req := localhostRequest("/api/issues?working_dir=/test/workspace") + w := httptest.NewRecorder() + s.handleBeadsList(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", w.Code, http.StatusInternalServerError) + } + if got := atomic.LoadInt32(&client.calls); got != 3 { + t.Errorf("List call count = %d, want 3 (1 initial + 2 retries)", got) + } +} + // --- handleBeadsStats -------------------------------------------------------- func TestHandleBeadsStats_MethodNotAllowed(t *testing.T) { @@ -1669,8 +1782,9 @@ func TestHandleBeadsUpstream_SetPromptsUpstream_NonExistentPrompt(t *testing.T) } } -func TestHandleBeadsUpstream_SetPromptsUpstream_ParameterizedPromptRejected(t *testing.T) { - // A prompt with parameters must be rejected with 400. +func TestHandleBeadsUpstream_SetPromptsUpstream_ParameterizedPromptAccepted(t *testing.T) { + // A prompt with parameters must now be ACCEPTED — its arguments are + // supplied via *_prompt_args and forwarded at dispatch time. setupMittoDir(t) sm := conversation.NewSessionManager("", "", false, nil) sm.SetWorkspaces([]config.WorkspaceSettings{ @@ -1693,13 +1807,28 @@ func TestHandleBeadsUpstream_SetPromptsUpstream_ParameterizedPromptRejected(t *t }) put := httptest.NewRequest(http.MethodPut, "/api/issues/upstream?working_dir=/test/workspace", - strings.NewReader(`{"upstream":"prompts","pull_prompt":"parameterized-prompt"}`)) + strings.NewReader(`{"upstream":"prompts","pull_prompt":"parameterized-prompt","pull_prompt_args":{"id":"mitto-1"}}`)) put.RemoteAddr = "127.0.0.1:1" put.Header.Set("Content-Type", "application/json") pw := httptest.NewRecorder() s.handleBeadsUpstream(pw, put) - if pw.Code != http.StatusBadRequest { - t.Errorf("PUT status = %d, want %d (%s)", pw.Code, http.StatusBadRequest, pw.Body.String()) + if pw.Code != http.StatusOK { + t.Fatalf("PUT status = %d, want %d (%s)", pw.Code, http.StatusOK, pw.Body.String()) + } + + // GET must return the stored pull_prompt and its args. + get := localhostRequest("/api/issues/upstream?working_dir=/test/workspace") + gw := httptest.NewRecorder() + s.handleBeadsUpstream(gw, get) + if gw.Code != http.StatusOK { + t.Fatalf("GET status = %d, want %d", gw.Code, http.StatusOK) + } + body := gw.Body.String() + if !strings.Contains(body, `"pull_prompt":"parameterized-prompt"`) { + t.Errorf("GET body = %q, want pull_prompt parameterized-prompt", body) + } + if !strings.Contains(body, `"pull_prompt_args":{"id":"mitto-1"}`) { + t.Errorf("GET body = %q, want pull_prompt_args {\"id\":\"mitto-1\"}", body) } } diff --git a/internal/web/handlers/callback.go b/internal/web/handlers/callback.go index 05d9a364..15abe902 100644 --- a/internal/web/handlers/callback.go +++ b/internal/web/handlers/callback.go @@ -22,7 +22,7 @@ func writeCallbackError(w http.ResponseWriter, status int, errorCode, message st } // HandleCallbackTrigger handles POST /api/callback/{token} -// This is a PUBLIC endpoint (no auth required) that triggers a periodic prompt delivery. +// This is a PUBLIC endpoint (no auth required) that triggers a loop prompt delivery. func (h *Handlers) HandleCallbackTrigger(w http.ResponseWriter, r *http.Request) { // 1. Only accept POST requests if r.Method != http.MethodPost { @@ -90,37 +90,37 @@ func (h *Handlers) HandleCallbackTrigger(w http.ResponseWriter, r *http.Request) return } - // 8. Check periodic config exists and is enabled - periodicStore := store.Periodic(sessionID) - periodic, err := periodicStore.Get() + // 8. Check loop config exists and is enabled + loopStore := store.Loop(sessionID) + loop, err := loopStore.Get() if err != nil { - if err == session.ErrPeriodicNotFound { - writeCallbackError(w, http.StatusGone, "periodic_disabled", "No periodic prompt configured") + if err == session.ErrLoopNotFound { + writeCallbackError(w, http.StatusGone, "loop_disabled", "No loop prompt configured") return } - writeCallbackError(w, http.StatusInternalServerError, "internal", "Failed to get periodic config") + writeCallbackError(w, http.StatusInternalServerError, "internal", "Failed to get loop config") return } - if !periodic.Enabled { - writeCallbackError(w, http.StatusGone, "periodic_disabled", "Periodic is disabled") + if !loop.Enabled { + writeCallbackError(w, http.StatusGone, "loop_disabled", "Loop is disabled") return } - // 9. Trigger the periodic prompt via the runner - if h.deps.TriggerPeriodicNow == nil { - writeCallbackError(w, http.StatusInternalServerError, "internal", "Periodic runner not available") + // 9. Trigger the loop prompt via the runner + if h.deps.TriggerLoopNow == nil { + writeCallbackError(w, http.StatusInternalServerError, "internal", "Loop runner not available") return } - if err := h.deps.TriggerPeriodicNow(sessionID, true); err != nil { + if err := h.deps.TriggerLoopNow(sessionID, true); err != nil { switch err { case h.deps.ErrSessionBusy: writeCallbackError(w, http.StatusConflict, "session_busy", "Session is currently processing") - case h.deps.ErrPeriodicNotEnabled: - writeCallbackError(w, http.StatusGone, "periodic_disabled", "Periodic is not enabled") - case session.ErrPeriodicNotFound: - writeCallbackError(w, http.StatusGone, "periodic_disabled", "No periodic prompt configured") + case h.deps.ErrLoopNotEnabled: + writeCallbackError(w, http.StatusGone, "loop_disabled", "Loop is not enabled") + case session.ErrLoopNotFound: + writeCallbackError(w, http.StatusGone, "loop_disabled", "No loop prompt configured") default: if h.deps.Logger != nil { h.deps.Logger.Error("Failed to trigger callback", "error", err, "session_id", sessionID) diff --git a/internal/web/handlers/global_shortcuts.go b/internal/web/handlers/global_shortcuts.go new file mode 100644 index 00000000..7a9bb422 --- /dev/null +++ b/internal/web/handlers/global_shortcuts.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/inercia/mitto/internal/appdir" + "github.com/inercia/mitto/internal/config" +) + +// globalShortcutsBody is the JSON envelope for GET and PUT +// /api/global/shortcuts. Sections maps section IDs (e.g. "conversations") to +// their ordered list of shortcut buttons. Prompts is populated on GET only: it +// carries the global (builtin + settings) prompts so the editor can offer them +// without a second request. +type globalShortcutsBody struct { + Sections map[string][]config.ShortcutButton `json:"sections"` + Prompts []config.WebPrompt `json:"prompts,omitempty"` +} + +// HandleGlobalShortcuts routes GET and PUT for /api/global/shortcuts. +// Global shortcut buttons are stored in settings.json and merged with +// folder-level shortcuts at render time (global entries first). +// +// Requires authentication via the standard auth middleware. +func (h *Handlers) HandleGlobalShortcuts(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.handleGlobalShortcutsGet(w, r) + case http.MethodPut: + h.handleGlobalShortcutsSet(w, r) + default: + methodNotAllowed(w) + } +} + +// globalPrompts returns the merged global prompt list (builtin overridden by +// settings prompts), keeping disabled entries so the editor can render them. +func (h *Handlers) globalPrompts() []config.WebPrompt { + var builtinPrompts []config.WebPrompt + if builtinDir, err := appdir.BuiltinPromptsDir(); err == nil { + rawBuiltin, _ := config.LoadPromptsFromDir(builtinDir) + for _, p := range rawBuiltin { + wp := p.ToWebPrompt() + wp.Source = config.PromptSourceBuiltin + builtinPrompts = append(builtinPrompts, wp) + } + } + var settingsPrompts []config.WebPrompt + if h.deps.MittoConfig != nil { + settingsPrompts = h.deps.MittoConfig.Prompts + } + return config.MergePromptsKeepDisabled(builtinPrompts, settingsPrompts, nil) +} + +func (h *Handlers) handleGlobalShortcutsGet(w http.ResponseWriter, r *http.Request) { + data := config.GlobalShortcuts() + if data == nil { + data = map[string][]config.ShortcutButton{} + } + writeJSONOK(w, globalShortcutsBody{Sections: data, Prompts: h.globalPrompts()}) +} + +func (h *Handlers) handleGlobalShortcutsSet(w http.ResponseWriter, r *http.Request) { + var body globalShortcutsBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "", "Invalid request body") + return + } + if body.Sections == nil { + body.Sections = map[string][]config.ShortcutButton{} + } + + // Sanitise: drop entries with empty Prompt; cap each section to maxShortcutsPerSection. + sanitised := make(map[string][]config.ShortcutButton, len(body.Sections)) + for section, buttons := range body.Sections { + filtered := make([]config.ShortcutButton, 0, len(buttons)) + for _, b := range buttons { + if b.Prompt == "" { + continue + } + filtered = append(filtered, b) + } + if len(filtered) > maxShortcutsPerSection { + filtered = filtered[:maxShortcutsPerSection] + } + sanitised[section] = filtered + } + + if err := config.SetGlobalShortcuts(sanitised); err != nil { + writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to save shortcuts: "+err.Error()) + return + } + + // Keep the in-memory config in sync so an unrelated Settings save (which + // preserves Shortcuts from MittoConfig) does not clobber this change. + data := config.GlobalShortcuts() + if data == nil { + data = map[string][]config.ShortcutButton{} + } + if h.deps.MittoConfig != nil { + h.deps.MittoConfig.Shortcuts = data + } + writeJSONOK(w, globalShortcutsBody{Sections: data, Prompts: h.globalPrompts()}) +} diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go index 9402abaf..5f2aa140 100644 --- a/internal/web/handlers/handlers.go +++ b/internal/web/handlers/handlers.go @@ -205,7 +205,7 @@ type Deps struct { APIPrefix string // CallbackIndex mirrors Server.callbackIndex: the in-memory token→session - // index for periodic callback triggers. May be nil; callers must nil-guard. + // index for loop callback triggers. May be nil; callers must nil-guard. CallbackIndex *conversation.CallbackIndex // CallbackRateLimiter mirrors Server.callbackRateLimiter: the per-token rate @@ -221,38 +221,38 @@ type Deps struct { // May be nil; callers must nil-guard. IsExternalListenerRunning func() bool - // TriggerPeriodicNow mirrors Server.periodicRunner.TriggerNow: triggers an - // immediate periodic run for a session. May be nil; callers must nil-guard. - TriggerPeriodicNow func(sessionID string, resetTimer bool) error + // TriggerLoopNow mirrors Server.loopRunner.TriggerNow: triggers an + // immediate loop run for a session. May be nil; callers must nil-guard. + TriggerLoopNow func(sessionID string, resetTimer bool) error - // StopPeriodicForArchive mirrors Server.periodicRunner.StopPeriodicForArchive bound + // StopLoopForArchive mirrors Server.loopRunner.StopLoopForArchive bound // to the "archived" stopped reason: it authoritatively stops a conversation's - // periodic loop when the conversation is archived. May be nil; callers must nil-guard. - StopPeriodicForArchive func(sessionID string) + // loop when the conversation is archived. May be nil; callers must nil-guard. + StopLoopForArchive func(sessionID string) - // ErrSessionBusy and ErrPeriodicNotEnabled mirror the web package's - // periodic-runner sentinel errors. They are exposed here so callback handlers - // can map TriggerPeriodicNow failures to HTTP status codes without importing + // ErrSessionBusy and ErrLoopNotEnabled mirror the web package's + // loop-runner sentinel errors. They are exposed here so callback handlers + // can map TriggerLoopNow failures to HTTP status codes without importing // the web package (which would create an import cycle). May be nil. - ErrSessionBusy error - ErrPeriodicNotEnabled error + ErrSessionBusy error + ErrLoopNotEnabled error - // PeriodicDelayFloor mirrors Server.periodicDelayFloor: the configured global + // LoopDelayFloor mirrors Server.loopDelayFloor: the configured global // floor (in seconds) for the on-completion delay. When nil, handlers fall back // to the package default. - PeriodicDelayFloor func() int + LoopDelayFloor func() int - // BroadcastPeriodicUpdated mirrors Server.BroadcastPeriodicUpdated: broadcasts - // a periodic-config change to all connected clients for the given session - // (nil periodic means deleted/disabled). May be nil; callers must nil-guard. - BroadcastPeriodicUpdated func(sessionID string, periodic *session.PeriodicPrompt) + // BroadcastLoopUpdated mirrors Server.BroadcastLoopUpdated: broadcasts + // a loop-config change to all connected clients for the given session + // (nil loop means deleted/disabled). May be nil; callers must nil-guard. + BroadcastLoopUpdated func(sessionID string, loop *session.LoopPrompt) // BroadcastBeadsCleanupProgress mirrors Server.BroadcastBeadsCleanupProgress: // it broadcasts a global-events message reporting bulk closed-issue cleanup // progress to all connected clients. May be nil. BroadcastBeadsCleanupProgress func(workingDir string, deleted, total int, done bool, errMsg string) - // BootstrapOnCompletion mirrors Server.periodicRunner.BootstrapOnCompletion: + // BootstrapOnCompletion mirrors Server.loopRunner.BootstrapOnCompletion: // kicks off the very first run for a fresh onCompletion conversation. May be // nil; callers must nil-guard. BootstrapOnCompletion func(sessionID string) diff --git a/internal/web/handlers/queue.go b/internal/web/handlers/queue.go index f1488a04..29b4a802 100644 --- a/internal/web/handlers/queue.go +++ b/internal/web/handlers/queue.go @@ -170,7 +170,7 @@ func (h *Handlers) handleAddToQueue(w http.ResponseWriter, r *http.Request, queu } // Try to process the queued message immediately if agent is idle - // (skip for scheduled messages — the periodic runner will deliver them when due) + // (skip for scheduled messages — the loop runner will deliver them when due) if scheduledTime == nil { if h.deps.SessionManager != nil { if bs := h.deps.SessionManager.GetSession(sessionID); bs != nil { diff --git a/internal/web/handlers/session_create.go b/internal/web/handlers/session_create.go index 515a5d1c..a295b7bd 100644 --- a/internal/web/handlers/session_create.go +++ b/internal/web/handlers/session_create.go @@ -192,7 +192,7 @@ func (h *Handlers) HandleCreateSession(w http.ResponseWriter, r *http.Request) { } // Persist the originating prompt name (if provided), independent of seeding - // so it also works for the periodic path. Used for singleton find-or-route. + // so it also works for the loop path. Used for singleton find-or-route. // Falls back to InitialPromptName (matching the lookup in promptName above) // so callers that seed via initial_prompt_name without an explicit // origin_prompt_name still get tracked for singleton find-or-route. diff --git a/internal/web/handlers/session_list.go b/internal/web/handlers/session_list.go index 6aa3b7bf..b51ae350 100644 --- a/internal/web/handlers/session_list.go +++ b/internal/web/handlers/session_list.go @@ -11,45 +11,45 @@ import ( // SessionListResponse extends session.Metadata with additional runtime fields. type SessionListResponse struct { session.Metadata - // PeriodicConfigured is true when a periodic config exists for this session. + // LoopConfigured is true when a loop config exists for this session. // Controls editor UI mode (shows frequency panel and lock/unlock buttons). - // A conversation with PeriodicConfigured=true but PeriodicEnabled=false is - // a "draft" periodic — editor visible but runs not yet active. - PeriodicConfigured bool `json:"periodic_configured"` - // PeriodicEnabled is true when periodic runs are active (config.Enabled == true). - // Drives the sidebar PERIODIC category and clock icon. A paused/draft periodic - // conversation has PeriodicConfigured=true but PeriodicEnabled=false and falls + // A conversation with LoopConfigured=true but LoopEnabled=false is + // a "draft" loop — editor visible but runs not yet active. + LoopConfigured bool `json:"loop_configured"` + // LoopEnabled is true when loop runs are active (config.Enabled == true). + // Drives the sidebar LOOP category and clock icon. A paused/draft loop + // conversation has LoopConfigured=true but LoopEnabled=false and falls // into the regular Conversations group. - PeriodicEnabled bool `json:"periodic_enabled"` - // NextScheduledAt is the next scheduled time for periodic sessions (nil if not periodic or not scheduled). + LoopEnabled bool `json:"loop_enabled"` + // NextScheduledAt is the next scheduled time for loop sessions (nil if not loop or not scheduled). NextScheduledAt *time.Time `json:"next_scheduled_at,omitempty"` - // PeriodicFrequency is the frequency configuration for periodic sessions (nil if not periodic). - PeriodicFrequency *session.Frequency `json:"periodic_frequency,omitempty"` + // LoopFrequency is the frequency configuration for loop sessions (nil if not loop). + LoopFrequency *session.Frequency `json:"loop_frequency,omitempty"` // IsWaitingForChildren is true when the session is currently blocked on mitto_children_tasks_wait. // This is a runtime state (not persisted) tracked by the SessionManager. IsWaitingForChildren bool `json:"is_waiting_for_children,omitempty"` // IsStreaming is true when the session is currently prompting (agent streaming). // This is a runtime state (not persisted) tracked by the SessionManager. IsStreaming bool `json:"is_streaming,omitempty"` - // PeriodicStoppedReason is the reason the periodic loop was auto-stopped (empty when still running). - PeriodicStoppedReason string `json:"periodic_stopped_reason,omitempty"` - // PeriodicTrigger is "schedule" or "onCompletion" (resolved via EffectiveTrigger so schedule loops + // LoopStoppedReason is the reason the loop was auto-stopped (empty when still running). + LoopStoppedReason string `json:"loop_stopped_reason,omitempty"` + // LoopTrigger is "schedule" or "onCompletion" (resolved via EffectiveTrigger so schedule loops // always report "schedule", never the empty-string default). - PeriodicTrigger string `json:"periodic_trigger,omitempty"` - // PeriodicIterationCount is the number of scheduled runs delivered so far. - PeriodicIterationCount int `json:"periodic_iteration_count,omitempty"` - // PeriodicMaxIterations is the per-prompt cap on scheduled runs (0 = unlimited). - PeriodicMaxIterations int `json:"periodic_max_iterations,omitempty"` - // PeriodicDelaySeconds is the wait in seconds after agent idle before the next onCompletion run. - PeriodicDelaySeconds int `json:"periodic_delay_seconds,omitempty"` - // PeriodicMaxDurationSeconds is the wall-clock cap in seconds since iterating started (0 = unlimited). - PeriodicMaxDurationSeconds int `json:"periodic_max_duration_seconds,omitempty"` - // PeriodicHasPrompt is true when the periodic config has a prompt set + LoopTrigger string `json:"loop_trigger,omitempty"` + // LoopIterationCount is the number of scheduled runs delivered so far. + LoopIterationCount int `json:"loop_iteration_count,omitempty"` + // LoopMaxIterations is the per-prompt cap on scheduled runs (0 = unlimited). + LoopMaxIterations int `json:"loop_max_iterations,omitempty"` + // LoopDelaySeconds is the wait in seconds after agent idle before the next onCompletion run. + LoopDelaySeconds int `json:"loop_delay_seconds,omitempty"` + // LoopMaxDurationSeconds is the wall-clock cap in seconds since iterating started (0 = unlimited). + LoopMaxDurationSeconds int `json:"loop_max_duration_seconds,omitempty"` + // LoopHasPrompt is true when the loop config has a prompt set // (either a free-text Prompt body or a named PromptName). - PeriodicHasPrompt bool `json:"periodic_has_prompt,omitempty"` - // PeriodicPromptPreview is a short preview of the free-text Prompt body only + LoopHasPrompt bool `json:"loop_has_prompt,omitempty"` + // LoopPromptPreview is a short preview of the free-text Prompt body only // (first line, trimmed, truncated to ~80 runes). Empty for named-prompt-only configs. - PeriodicPromptPreview string `json:"periodic_prompt_preview,omitempty"` + LoopPromptPreview string `json:"loop_prompt_preview,omitempty"` } // HandleListSessions handles GET /api/sessions @@ -75,39 +75,39 @@ func (h *Handlers) HandleListSessions(w http.ResponseWriter, r *http.Request) { return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt) }) - // Build response with periodic status and scheduling info + // Build response with loop status and scheduling info response := make([]SessionListResponse, len(sessions)) for i := range sessions { meta := sessions[i] response[i] = SessionListResponse{ - Metadata: meta, - PeriodicConfigured: false, // Default to false - PeriodicEnabled: false, // Default to false + Metadata: meta, + LoopConfigured: false, // Default to false + LoopEnabled: false, // Default to false } - // Check if a periodic config exists for this session - periodicStore := store.Periodic(meta.SessionID) - if periodic, err := periodicStore.Get(); err == nil && periodic != nil { - // Periodic config exists — show editor UI regardless of enabled state - response[i].PeriodicConfigured = true - // PeriodicEnabled reflects whether runs are active (config.Enabled) - response[i].PeriodicEnabled = periodic.Enabled + // Check if a loop config exists for this session + loopStore := store.Loop(meta.SessionID) + if loop, err := loopStore.Get(); err == nil && loop != nil { + // Loop config exists — show editor UI regardless of enabled state + response[i].LoopConfigured = true + // LoopEnabled reflects whether runs are active (config.Enabled) + response[i].LoopEnabled = loop.Enabled // Include scheduling info for progress indicator - if periodic.NextScheduledAt != nil && !periodic.NextScheduledAt.IsZero() { - response[i].NextScheduledAt = periodic.NextScheduledAt + if loop.NextScheduledAt != nil && !loop.NextScheduledAt.IsZero() { + response[i].NextScheduledAt = loop.NextScheduledAt } - response[i].PeriodicFrequency = &periodic.Frequency - if periodic.StoppedReason != "" { - response[i].PeriodicStoppedReason = string(periodic.StoppedReason) + response[i].LoopFrequency = &loop.Frequency + if loop.StoppedReason != "" { + response[i].LoopStoppedReason = string(loop.StoppedReason) } // Glance fields for conversation header display. - response[i].PeriodicTrigger = string(periodic.EffectiveTrigger()) - response[i].PeriodicIterationCount = periodic.IterationCount - response[i].PeriodicMaxIterations = periodic.MaxIterations - response[i].PeriodicDelaySeconds = periodic.DelaySeconds - response[i].PeriodicMaxDurationSeconds = periodic.MaxDurationSeconds + response[i].LoopTrigger = string(loop.EffectiveTrigger()) + response[i].LoopIterationCount = loop.IterationCount + response[i].LoopMaxIterations = loop.MaxIterations + response[i].LoopDelaySeconds = loop.DelaySeconds + response[i].LoopMaxDurationSeconds = loop.MaxDurationSeconds // Prompt presence flag and free-text preview for the selector UI. - response[i].PeriodicHasPrompt = periodic.Prompt != "" || periodic.PromptName != "" - response[i].PeriodicPromptPreview = periodic.PromptPreview() + response[i].LoopHasPrompt = loop.Prompt != "" || loop.PromptName != "" + response[i].LoopPromptPreview = loop.PromptPreview() } // Check if session is currently waiting for children (runtime state from SessionManager) if h.deps.SessionManager != nil { diff --git a/internal/web/handlers/session_periodic.go b/internal/web/handlers/session_loop.go similarity index 61% rename from internal/web/handlers/session_periodic.go rename to internal/web/handlers/session_loop.go index ea8138c5..7d2ad49c 100644 --- a/internal/web/handlers/session_periodic.go +++ b/internal/web/handlers/session_loop.go @@ -8,8 +8,8 @@ import ( "github.com/inercia/mitto/internal/session" ) -// PeriodicPromptRequest is the request body for creating/updating a periodic prompt. -type PeriodicPromptRequest struct { +// LoopPromptRequest is the request body for creating/updating a loop prompt. +type LoopPromptRequest struct { Prompt string `json:"prompt"` PromptName string `json:"prompt_name,omitempty"` Frequency session.Frequency `json:"frequency"` @@ -18,7 +18,7 @@ type PeriodicPromptRequest struct { MaxIterations int `json:"max_iterations,omitempty"` // Trigger selects how the prompt fires: "" or "schedule" (frequency-based, default) // vs "onCompletion" (event-driven, after the agent stops + DelaySeconds). - Trigger session.PeriodicTrigger `json:"trigger,omitempty"` + Trigger session.LoopTrigger `json:"trigger,omitempty"` // DelaySeconds is the wait after the agent stops before the next run (onCompletion only). // Clamped to the global floor on write. DelaySeconds int `json:"delay_seconds,omitempty"` @@ -37,8 +37,8 @@ type PeriodicPromptRequest struct { CooldownSeconds *int `json:"cooldown_seconds,omitempty"` } -// PeriodicPromptPatchRequest is the request body for partial updates. -type PeriodicPromptPatchRequest struct { +// LoopPromptPatchRequest is the request body for partial updates. +type LoopPromptPatchRequest struct { Prompt *string `json:"prompt,omitempty"` PromptName *string `json:"prompt_name,omitempty"` Frequency *session.Frequency `json:"frequency,omitempty"` @@ -46,9 +46,9 @@ type PeriodicPromptPatchRequest struct { FreshContext *bool `json:"fresh_context,omitempty"` MaxIterations *int `json:"max_iterations,omitempty"` // Trigger, DelaySeconds, MaxDurationSeconds are partial updates for the on-completion fields. - Trigger *session.PeriodicTrigger `json:"trigger,omitempty"` - DelaySeconds *int `json:"delay_seconds,omitempty"` - MaxDurationSeconds *int `json:"max_duration_seconds,omitempty"` + Trigger *session.LoopTrigger `json:"trigger,omitempty"` + DelaySeconds *int `json:"delay_seconds,omitempty"` + MaxDurationSeconds *int `json:"max_duration_seconds,omitempty"` // Arguments is a partial update for the substitution arguments map. // nil = leave unchanged; non-nil = replace the entire map (including empty map to clear it). Arguments *map[string]string `json:"arguments,omitempty"` @@ -65,24 +65,24 @@ type PeriodicPromptPatchRequest struct { ResetCounters *bool `json:"reset_counters,omitempty"` } -// RunPeriodicNowRequest is the optional request body for POST /api/sessions/{id}/periodic/run-now. -type RunPeriodicNowRequest struct { +// RunLoopNowRequest is the optional request body for POST /api/sessions/{id}/loop/run-now. +type RunLoopNowRequest struct { ResetTimer *bool `json:"reset_timer,omitempty"` } -// periodicDelayFloor returns the configured global floor for the on-completion delay. -// Falls back to the package default when the periodic runner is unavailable (e.g. tests). -func (h *Handlers) periodicDelayFloor() int { - if h.deps.PeriodicDelayFloor != nil { - return h.deps.PeriodicDelayFloor() +// loopDelayFloor returns the configured global floor for the on-completion delay. +// Falls back to the package default when the loop runner is unavailable (e.g. tests). +func (h *Handlers) loopDelayFloor() int { + if h.deps.LoopDelayFloor != nil { + return h.deps.LoopDelayFloor() } - return configPkg.DefaultMinPeriodicCompletionDelaySeconds + return configPkg.DefaultMinLoopCompletionDelaySeconds } -// HandleSessionPeriodic handles periodic prompt operations for a session. -// Routes: GET, PUT, PATCH, DELETE /api/sessions/{id}/periodic -// Route: POST /api/sessions/{id}/periodic/run-now (immediate delivery) -func (h *Handlers) HandleSessionPeriodic(w http.ResponseWriter, r *http.Request, sessionID, subPath string) { +// HandleSessionLoop handles loop prompt operations for a session. +// Routes: GET, PUT, PATCH, DELETE /api/sessions/{id}/loop +// Route: POST /api/sessions/{id}/loop/run-now (immediate delivery) +func (h *Handlers) HandleSessionLoop(w http.ResponseWriter, r *http.Request, sessionID, subPath string) { store := h.deps.Store if store == nil { writeErrorJSON(w, http.StatusInternalServerError, "", "Session store not available") @@ -100,65 +100,65 @@ func (h *Handlers) HandleSessionPeriodic(w http.ResponseWriter, r *http.Request, return } - // Prevent setting periodic on child sessions - only parents/top-level sessions can be periodic + // Prevent setting loop on child sessions - only parents/top-level sessions can be loops if r.Method != http.MethodGet && meta.ParentSessionID != "" { - writeErrorJSON(w, http.StatusBadRequest, "", "Cannot set periodic on a child conversation. Only parent or top-level conversations can be periodic.") + writeErrorJSON(w, http.StatusBadRequest, "", "Cannot set loop on a child conversation. Only parent or top-level conversations can be loops.") return } // Handle run-now sub-path if subPath == "run-now" { - h.handleRunPeriodicNow(w, r, sessionID) + h.handleRunLoopNow(w, r, sessionID) return } - periodicStore := store.Periodic(sessionID) + loopStore := store.Loop(sessionID) switch r.Method { case http.MethodGet: - h.handleGetPeriodic(w, periodicStore) + h.handleGetLoop(w, loopStore) case http.MethodPut: - h.handleSetPeriodic(w, r, sessionID, periodicStore) + h.handleSetLoop(w, r, sessionID, loopStore) case http.MethodPatch: - h.handlePatchPeriodic(w, r, sessionID, periodicStore) + h.handlePatchLoop(w, r, sessionID, loopStore) case http.MethodDelete: - h.handleDeletePeriodic(w, sessionID, periodicStore) + h.handleDeleteLoop(w, sessionID, loopStore) default: methodNotAllowed(w) } } -// handleGetPeriodic handles GET /api/sessions/{id}/periodic -func (h *Handlers) handleGetPeriodic(w http.ResponseWriter, ps *session.PeriodicStore) { +// handleGetLoop handles GET /api/sessions/{id}/loop +func (h *Handlers) handleGetLoop(w http.ResponseWriter, ps *session.LoopStore) { p, err := ps.Get() if err != nil { - if err == session.ErrPeriodicNotFound { - writeErrorJSON(w, http.StatusNotFound, "", "No periodic prompt configured") + if err == session.ErrLoopNotFound { + writeErrorJSON(w, http.StatusNotFound, "", "No loop prompt configured") return } if h.deps.Logger != nil { - h.deps.Logger.Error("Failed to get periodic prompt", "error", err) + h.deps.Logger.Error("Failed to get loop prompt", "error", err) } - writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to get periodic prompt") + writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to get loop prompt") return } writeJSONOK(w, p) } -// triggerTitleFromPeriodic triggers title generation from a periodic prompt when +// triggerTitleFromLoop triggers title generation from a loop prompt when // the session has no title yet. Shared by the PUT and PATCH handlers. -func (h *Handlers) triggerTitleFromPeriodic(sessionID, prompt, promptName string) { +func (h *Handlers) triggerTitleFromLoop(sessionID, prompt, promptName string) { if h.deps.SessionManager != nil && conversation.SessionNeedsTitle(h.deps.Store, sessionID) { if bs := h.deps.SessionManager.GetSession(sessionID); bs != nil { - bs.TriggerTitleGenerationFromPeriodic(prompt, promptName) + bs.TriggerTitleGenerationFromLoop(prompt, promptName) } } } -// broadcastPeriodic broadcasts a periodic-config change when a broadcaster is wired. -func (h *Handlers) broadcastPeriodic(sessionID string, updated *session.PeriodicPrompt) { - if h.deps.BroadcastPeriodicUpdated != nil { - h.deps.BroadcastPeriodicUpdated(sessionID, updated) +// broadcastLoop broadcasts a loop-config change when a broadcaster is wired. +func (h *Handlers) broadcastLoop(sessionID string, updated *session.LoopPrompt) { + if h.deps.BroadcastLoopUpdated != nil { + h.deps.BroadcastLoopUpdated(sessionID, updated) } } diff --git a/internal/web/handlers/session_periodic_run.go b/internal/web/handlers/session_loop_run.go similarity index 56% rename from internal/web/handlers/session_periodic_run.go rename to internal/web/handlers/session_loop_run.go index 5a95dad7..1a3de6cc 100644 --- a/internal/web/handlers/session_periodic_run.go +++ b/internal/web/handlers/session_loop_run.go @@ -8,43 +8,43 @@ import ( "github.com/inercia/mitto/internal/session" ) -// handleDeletePeriodic handles DELETE /api/sessions/{id}/periodic -func (h *Handlers) handleDeletePeriodic(w http.ResponseWriter, sessionID string, ps *session.PeriodicStore) { +// handleDeleteLoop handles DELETE /api/sessions/{id}/loop +func (h *Handlers) handleDeleteLoop(w http.ResponseWriter, sessionID string, ps *session.LoopStore) { if err := ps.Delete(); err != nil { - if err == session.ErrPeriodicNotFound { - writeErrorJSON(w, http.StatusNotFound, "", "No periodic prompt configured") + if err == session.ErrLoopNotFound { + writeErrorJSON(w, http.StatusNotFound, "", "No loop prompt configured") return } if h.deps.Logger != nil { - h.deps.Logger.Error("Failed to delete periodic prompt", "error", err) + h.deps.Logger.Error("Failed to delete loop prompt", "error", err) } - writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to delete periodic prompt") + writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to delete loop prompt") return } - // Broadcast periodic disabled to all clients (nil means deleted) - h.broadcastPeriodic(sessionID, nil) + // Broadcast loop disabled to all clients (nil means deleted) + h.broadcastLoop(sessionID, nil) writeNoContent(w) } -// handleRunPeriodicNow handles POST /api/sessions/{id}/periodic/run-now -// Triggers immediate delivery of the periodic prompt, bypassing the normal schedule. -func (h *Handlers) handleRunPeriodicNow(w http.ResponseWriter, r *http.Request, sessionID string) { +// handleRunLoopNow handles POST /api/sessions/{id}/loop/run-now +// Triggers immediate delivery of the loop prompt, bypassing the normal schedule. +func (h *Handlers) handleRunLoopNow(w http.ResponseWriter, r *http.Request, sessionID string) { if r.Method != http.MethodPost { methodNotAllowed(w) return } - // Check if periodic runner is available - if h.deps.TriggerPeriodicNow == nil { - writeErrorJSON(w, http.StatusInternalServerError, "", "Periodic runner not available") + // Check if loop runner is available + if h.deps.TriggerLoopNow == nil { + writeErrorJSON(w, http.StatusInternalServerError, "", "Loop runner not available") return } // Parse optional request body to determine whether to reset the countdown timer. // Default is true (matches existing behaviour). - var req RunPeriodicNowRequest + var req RunLoopNowRequest if r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErrorJSON(w, http.StatusBadRequest, "", "Invalid request body") @@ -59,7 +59,7 @@ func (h *Handlers) handleRunPeriodicNow(w http.ResponseWriter, r *http.Request, // Trigger immediate delivery, bounded by auxBackedRequestTimeout so a slow // auto-resume (TriggerNow -> ResumeSession) returns a fast, clear retryable // 503 instead of blocking until the 30s middleware cap emits an opaque one - // (mitto-n36h). TriggerPeriodicNow/ResumeSession are not context-aware, so we + // (mitto-n36h). TriggerLoopNow/ResumeSession are not context-aware, so we // bound the call here: run it in a goroutine and race it against the deadline. // The buffered channel lets that goroutine finish (ResumeSession has its own // internal resume cap) without leaking even after we have already responded; @@ -70,14 +70,14 @@ func (h *Handlers) handleRunPeriodicNow(w http.ResponseWriter, r *http.Request, resultCh := make(chan error, 1) go func() { - resultCh <- h.deps.TriggerPeriodicNow(sessionID, resetTimer) + resultCh <- h.deps.TriggerLoopNow(sessionID, resetTimer) }() var err error select { case <-ctx.Done(): if h.deps.Logger != nil { - h.deps.Logger.Warn("Periodic run-now timed out resuming session; returning retryable 503", + h.deps.Logger.Warn("Loop run-now timed out resuming session; returning retryable 503", "session_id", sessionID) } writeRetryableUnavailable(w, "The conversation is resuming. Please try again in a few seconds.", 5) @@ -87,26 +87,26 @@ func (h *Handlers) handleRunPeriodicNow(w http.ResponseWriter, r *http.Request, if err != nil { switch err { - case session.ErrPeriodicNotFound: - writeErrorJSON(w, http.StatusNotFound, "", "No periodic prompt configured") - case h.deps.ErrPeriodicNotEnabled: - writeErrorJSON(w, http.StatusBadRequest, "", "Periodic is not enabled for this session") + case session.ErrLoopNotFound: + writeErrorJSON(w, http.StatusNotFound, "", "No loop prompt configured") + case h.deps.ErrLoopNotEnabled: + writeErrorJSON(w, http.StatusBadRequest, "", "Loop is not enabled for this session") case h.deps.ErrSessionBusy: writeErrorJSON(w, http.StatusConflict, "", "Session is currently processing a prompt") default: if h.deps.Logger != nil { - h.deps.Logger.Error("Failed to trigger periodic prompt", "error", err, "session_id", sessionID) + h.deps.Logger.Error("Failed to trigger loop prompt", "error", err, "session_id", sessionID) } - writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to trigger periodic prompt") + writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to trigger loop prompt") } return } - // Return success with the updated periodic config + // Return success with the updated loop config store := h.deps.Store if store != nil { - periodicStore := store.Periodic(sessionID) - if updated, err := periodicStore.Get(); err == nil { + loopStore := store.Loop(sessionID) + if updated, err := loopStore.Get(); err == nil { writeJSONOK(w, updated) return } diff --git a/internal/web/handlers/session_periodic_test.go b/internal/web/handlers/session_loop_test.go similarity index 67% rename from internal/web/handlers/session_periodic_test.go rename to internal/web/handlers/session_loop_test.go index f6e3b685..d1183cbf 100644 --- a/internal/web/handlers/session_periodic_test.go +++ b/internal/web/handlers/session_loop_test.go @@ -14,10 +14,10 @@ import ( "github.com/inercia/mitto/internal/session" ) -// newPeriodicStore creates a temp store and returns it together with a Handlers +// newLoopStore creates a temp store and returns it together with a Handlers // wired with only the Store dependency. Broadcast/bootstrap deps are left nil -// (no-ops), which is sufficient for the periodic REST handler tests. -func newPeriodicStore(t *testing.T) (*session.Store, *Handlers) { +// (no-ops), which is sufficient for the loop REST handler tests. +func newLoopStore(t *testing.T) (*session.Store, *Handlers) { t.Helper() store, err := session.NewStore(t.TempDir()) if err != nil { @@ -28,30 +28,30 @@ func newPeriodicStore(t *testing.T) (*session.Store, *Handlers) { return store, h } -// putPeriodicForTest is a helper that PUTs a periodic config via the REST handler and +// putLoopForTest is a helper that PUTs a loop config via the REST handler and // returns the decoded response. It fails the test on a non-200 status. -func putPeriodicForTest(t *testing.T, h *Handlers, sid string, body PeriodicPromptRequest) session.PeriodicPrompt { +func putLoopForTest(t *testing.T, h *Handlers, sid string, body LoopPromptRequest) session.LoopPrompt { t.Helper() raw, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPut, "/api/sessions/"+sid+"/periodic", bytes.NewReader(raw)) + req := httptest.NewRequest(http.MethodPut, "/api/sessions/"+sid+"/loop", bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, sid, "") + h.HandleSessionLoop(w, req, sid, "") if w.Code != http.StatusOK { - t.Fatalf("PUT periodic: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + t.Fatalf("PUT loop: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) } - var got session.PeriodicPrompt + var got session.LoopPrompt if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode PUT response: %v", err) } return got } -// TestHandleRunPeriodicNow_TimeoutReturnsRetryable503 verifies that a slow -// TriggerPeriodicNow (e.g. a blocking auto-resume) does not block the handler +// TestHandleRunLoopNow_TimeoutReturnsRetryable503 verifies that a slow +// TriggerLoopNow (e.g. a blocking auto-resume) does not block the handler // past auxBackedRequestTimeout: it returns a fast retryable 503 with a // Retry-After header and the canonical "unavailable" error code (mitto-n36h). -func TestHandleRunPeriodicNow_TimeoutReturnsRetryable503(t *testing.T) { +func TestHandleRunLoopNow_TimeoutReturnsRetryable503(t *testing.T) { // Lower the budget so the test completes quickly. old := auxBackedRequestTimeout auxBackedRequestTimeout = 20 * time.Millisecond @@ -67,11 +67,11 @@ func TestHandleRunPeriodicNow_TimeoutReturnsRetryable503(t *testing.T) { return nil } - h := New(Deps{TriggerPeriodicNow: stub}) + h := New(Deps{TriggerLoopNow: stub}) - req := httptest.NewRequest(http.MethodPost, "/api/sessions/sid/periodic/run-now", nil) + req := httptest.NewRequest(http.MethodPost, "/api/sessions/sid/loop/run-now", nil) w := httptest.NewRecorder() - h.handleRunPeriodicNow(w, req, "sid") + h.handleRunLoopNow(w, req, "sid") if w.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want %d", w.Code, http.StatusServiceUnavailable) @@ -92,12 +92,12 @@ func TestHandleRunPeriodicNow_TimeoutReturnsRetryable503(t *testing.T) { } } -func TestHandleSessionPeriodic_ChildRejected(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_ChildRejected(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() if err := store.Create(session.Metadata{ - SessionID: "test-parent-periodic", + SessionID: "test-parent-loop", ACPServer: "test-server", WorkingDir: tmpDir, }); err != nil { @@ -105,28 +105,28 @@ func TestHandleSessionPeriodic_ChildRejected(t *testing.T) { } if err := store.Create(session.Metadata{ - SessionID: "test-child-periodic", + SessionID: "test-child-loop", ACPServer: "test-server", WorkingDir: tmpDir, - ParentSessionID: "test-parent-periodic", + ParentSessionID: "test-parent-loop", }); err != nil { t.Fatalf("Create child failed: %v", err) } - // PUT periodic on child — should be rejected - body, _ := json.Marshal(PeriodicPromptRequest{ + // PUT loop on child — should be rejected + body, _ := json.Marshal(LoopPromptRequest{ Prompt: "check updates", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, }) - req := httptest.NewRequest(http.MethodPut, "/api/sessions/test-child-periodic/periodic", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPut, "/api/sessions/test-child-loop/loop", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, "test-child-periodic", "") + h.HandleSessionLoop(w, req, "test-child-loop", "") if w.Code != http.StatusBadRequest { - t.Errorf("PUT periodic on child: Status = %d, want %d", w.Code, http.StatusBadRequest) + t.Errorf("PUT loop on child: Status = %d, want %d", w.Code, http.StatusBadRequest) } var env struct { @@ -141,56 +141,56 @@ func TestHandleSessionPeriodic_ChildRejected(t *testing.T) { if env.Error.Code != "bad_request" { t.Errorf("error.code = %q, want %q", env.Error.Code, "bad_request") } - const wantMsg = "Cannot set periodic on a child conversation. Only parent or top-level conversations can be periodic." + const wantMsg = "Cannot set loop on a child conversation. Only parent or top-level conversations can be loops." if env.Error.Message != wantMsg { t.Errorf("error.message = %q, want %q", env.Error.Message, wantMsg) } // GET should still work (not rejected as 400) - req2 := httptest.NewRequest(http.MethodGet, "/api/sessions/test-child-periodic/periodic", nil) + req2 := httptest.NewRequest(http.MethodGet, "/api/sessions/test-child-loop/loop", nil) w2 := httptest.NewRecorder() - h.HandleSessionPeriodic(w2, req2, "test-child-periodic", "") + h.HandleSessionLoop(w2, req2, "test-child-loop", "") if w2.Code == http.StatusBadRequest { - t.Error("GET periodic on child should NOT be rejected with 400") + t.Error("GET loop on child should NOT be rejected with 400") } } -// TestHandleSessionPeriodic_TopLevelAllowed tests that setting periodic on a top-level session works. -func TestHandleSessionPeriodic_TopLevelAllowed(t *testing.T) { - store, h := newPeriodicStore(t) +// TestHandleSessionLoop_TopLevelAllowed tests that setting loop on a top-level session works. +func TestHandleSessionLoop_TopLevelAllowed(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() if err := store.Create(session.Metadata{ - SessionID: "test-toplevel-periodic", + SessionID: "test-toplevel-loop", ACPServer: "test-server", WorkingDir: tmpDir, }); err != nil { t.Fatalf("Create failed: %v", err) } - body, _ := json.Marshal(PeriodicPromptRequest{ + body, _ := json.Marshal(LoopPromptRequest{ Prompt: "check updates", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, }) - req := httptest.NewRequest(http.MethodPut, "/api/sessions/test-toplevel-periodic/periodic", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPut, "/api/sessions/test-toplevel-loop/loop", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, "test-toplevel-periodic", "") + h.HandleSessionLoop(w, req, "test-toplevel-loop", "") if w.Code != http.StatusOK { - t.Errorf("PUT periodic on top-level: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + t.Errorf("PUT loop on top-level: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) } } -// TestHandleSessionPeriodic_OnCompletionRoundTrip verifies that the on-completion trigger, +// TestHandleSessionLoop_OnCompletionRoundTrip verifies that the on-completion trigger, // completion delay, and max-duration fields round-trip through the PUT handler. A frequency // is not required for the onCompletion trigger. -func TestHandleSessionPeriodic_OnCompletionRoundTrip(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_OnCompletionRoundTrip(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() const sid = "test-oncompletion-roundtrip" @@ -198,7 +198,7 @@ func TestHandleSessionPeriodic_OnCompletionRoundTrip(t *testing.T) { t.Fatalf("Create failed: %v", err) } - got := putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + got := putLoopForTest(t, h, sid, LoopPromptRequest{ Prompt: "keep going", Enabled: true, Trigger: session.TriggerOnCompletion, @@ -217,11 +217,11 @@ func TestHandleSessionPeriodic_OnCompletionRoundTrip(t *testing.T) { } } -// TestHandleSessionPeriodic_OnCompletionDelayClampedOnPut verifies that a delay below the -// global floor is clamped up to the floor on write (PUT). With no periodic runner configured, +// TestHandleSessionLoop_OnCompletionDelayClampedOnPut verifies that a delay below the +// global floor is clamped up to the floor on write (PUT). With no loop runner configured, // the floor is the package default. -func TestHandleSessionPeriodic_OnCompletionDelayClampedOnPut(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_OnCompletionDelayClampedOnPut(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() const sid = "test-oncompletion-clamp-put" @@ -229,22 +229,22 @@ func TestHandleSessionPeriodic_OnCompletionDelayClampedOnPut(t *testing.T) { t.Fatalf("Create failed: %v", err) } - got := putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + got := putLoopForTest(t, h, sid, LoopPromptRequest{ Prompt: "keep going", Enabled: true, Trigger: session.TriggerOnCompletion, DelaySeconds: 1, // below the default floor (5) }) - if got.DelaySeconds != h.periodicDelayFloor() { - t.Errorf("DelaySeconds = %d, want clamped to floor %d", got.DelaySeconds, h.periodicDelayFloor()) + if got.DelaySeconds != h.loopDelayFloor() { + t.Errorf("DelaySeconds = %d, want clamped to floor %d", got.DelaySeconds, h.loopDelayFloor()) } } -// TestHandleSessionPeriodic_PatchPartialPreservesOnCompletionFields verifies that a partial +// TestHandleSessionLoop_PatchPartialPreservesOnCompletionFields verifies that a partial // PATCH updating only max_duration_seconds does not clobber the trigger or delay. -func TestHandleSessionPeriodic_PatchPartialPreservesOnCompletionFields(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_PatchPartialPreservesOnCompletionFields(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() const sid = "test-oncompletion-patch" @@ -253,7 +253,7 @@ func TestHandleSessionPeriodic_PatchPartialPreservesOnCompletionFields(t *testin } // Seed an onCompletion config with a delay and no duration cap. - putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + putLoopForTest(t, h, sid, LoopPromptRequest{ Prompt: "keep going", Enabled: true, Trigger: session.TriggerOnCompletion, @@ -262,18 +262,18 @@ func TestHandleSessionPeriodic_PatchPartialPreservesOnCompletionFields(t *testin // PATCH only max_duration_seconds. maxDur := 7200 - patchBody, _ := json.Marshal(PeriodicPromptPatchRequest{MaxDurationSeconds: &maxDur}) - req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/periodic", bytes.NewReader(patchBody)) + patchBody, _ := json.Marshal(LoopPromptPatchRequest{MaxDurationSeconds: &maxDur}) + req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/loop", bytes.NewReader(patchBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, sid, "") + h.HandleSessionLoop(w, req, sid, "") if w.Code != http.StatusOK { - t.Fatalf("PATCH periodic: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + t.Fatalf("PATCH loop: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) } - stored, err := store.Periodic(sid).Get() + stored, err := store.Loop(sid).Get() if err != nil { - t.Fatalf("Get periodic after PATCH: %v", err) + t.Fatalf("Get loop after PATCH: %v", err) } if stored.Trigger != session.TriggerOnCompletion { t.Errorf("Trigger after PATCH = %q, want %q (must not be clobbered)", stored.Trigger, session.TriggerOnCompletion) @@ -286,11 +286,11 @@ func TestHandleSessionPeriodic_PatchPartialPreservesOnCompletionFields(t *testin } } -// TestHandleSessionPeriodic_PatchResetCounters verifies that PATCHing with +// TestHandleSessionLoop_PatchResetCounters verifies that PATCHing with // reset_counters=true (used when restoring a loop that hit its cap) re-enables the // loop and resets IterationCount=0 and FirstRunAt=nil (elapsed time = 0). -func TestHandleSessionPeriodic_PatchResetCounters(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_PatchResetCounters(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() const sid = "test-reset-counters-patch" @@ -299,7 +299,7 @@ func TestHandleSessionPeriodic_PatchResetCounters(t *testing.T) { } // Seed an onCompletion config with a duration cap. - putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + putLoopForTest(t, h, sid, LoopPromptRequest{ Prompt: "keep going", Enabled: true, Trigger: session.TriggerOnCompletion, @@ -308,7 +308,7 @@ func TestHandleSessionPeriodic_PatchResetCounters(t *testing.T) { }) // Simulate two completed runs, then auto-stop on the duration cap. - ps := store.Periodic(sid) + ps := store.Loop(sid) if err := ps.RecordSent(); err != nil { t.Fatalf("RecordSent: %v", err) } @@ -322,18 +322,18 @@ func TestHandleSessionPeriodic_PatchResetCounters(t *testing.T) { // PATCH restore with reset_counters=true. enabled := true reset := true - patchBody, _ := json.Marshal(PeriodicPromptPatchRequest{Enabled: &enabled, ResetCounters: &reset}) - req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/periodic", bytes.NewReader(patchBody)) + patchBody, _ := json.Marshal(LoopPromptPatchRequest{Enabled: &enabled, ResetCounters: &reset}) + req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/loop", bytes.NewReader(patchBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, sid, "") + h.HandleSessionLoop(w, req, sid, "") if w.Code != http.StatusOK { - t.Fatalf("PATCH periodic: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + t.Fatalf("PATCH loop: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) } stored, err := ps.Get() if err != nil { - t.Fatalf("Get periodic after PATCH: %v", err) + t.Fatalf("Get loop after PATCH: %v", err) } if !stored.Enabled { t.Error("Enabled after restore = false, want true") @@ -355,10 +355,10 @@ func TestHandleSessionPeriodic_PatchResetCounters(t *testing.T) { } } -// TestHandleSessionPeriodic_PatchDelayClamped verifies that a PATCH lowering the delay below +// TestHandleSessionLoop_PatchDelayClamped verifies that a PATCH lowering the delay below // the floor on an onCompletion config is clamped up to the floor. -func TestHandleSessionPeriodic_PatchDelayClamped(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_PatchDelayClamped(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() const sid = "test-oncompletion-patch-clamp" @@ -366,7 +366,7 @@ func TestHandleSessionPeriodic_PatchDelayClamped(t *testing.T) { t.Fatalf("Create failed: %v", err) } - putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + putLoopForTest(t, h, sid, LoopPromptRequest{ Prompt: "keep going", Enabled: true, Trigger: session.TriggerOnCompletion, @@ -374,77 +374,77 @@ func TestHandleSessionPeriodic_PatchDelayClamped(t *testing.T) { }) belowFloor := 1 - patchBody, _ := json.Marshal(PeriodicPromptPatchRequest{DelaySeconds: &belowFloor}) - req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/periodic", bytes.NewReader(patchBody)) + patchBody, _ := json.Marshal(LoopPromptPatchRequest{DelaySeconds: &belowFloor}) + req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/loop", bytes.NewReader(patchBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, sid, "") + h.HandleSessionLoop(w, req, sid, "") if w.Code != http.StatusOK { - t.Fatalf("PATCH periodic: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + t.Fatalf("PATCH loop: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) } - stored, err := store.Periodic(sid).Get() + stored, err := store.Loop(sid).Get() if err != nil { - t.Fatalf("Get periodic after PATCH: %v", err) + t.Fatalf("Get loop after PATCH: %v", err) } - if stored.DelaySeconds != h.periodicDelayFloor() { - t.Errorf("DelaySeconds after PATCH = %d, want clamped to floor %d", stored.DelaySeconds, h.periodicDelayFloor()) + if stored.DelaySeconds != h.loopDelayFloor() { + t.Errorf("DelaySeconds after PATCH = %d, want clamped to floor %d", stored.DelaySeconds, h.loopDelayFloor()) } } -// TestHandleSessionPeriodic_MakePeriodicDraft verifies the "Make periodic" frontend flow: -// PUT /api/sessions/{id}/periodic with a draft body (enabled:false, prompt:"(pending)") +// TestHandleSessionLoop_MakeLoopDraft verifies the "Make loop" frontend flow: +// PUT /api/sessions/{id}/loop with a draft body (enabled:false, prompt:"(pending)") // on an existing top-level session succeeds and stores the draft config. -func TestHandleSessionPeriodic_MakePeriodicDraft(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_MakeLoopDraft(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() if err := store.Create(session.Metadata{ - SessionID: "test-make-periodic-draft", + SessionID: "test-make-loop-draft", ACPServer: "test-server", WorkingDir: tmpDir, }); err != nil { t.Fatalf("Create failed: %v", err) } - // Draft body — mirrors what handleMakePeriodic in app.js sends. - body, _ := json.Marshal(PeriodicPromptRequest{ + // Draft body — mirrors what handleMakeLoop in app.js sends. + body, _ := json.Marshal(LoopPromptRequest{ Prompt: "(pending)", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: false, }) - req := httptest.NewRequest(http.MethodPut, "/api/sessions/test-make-periodic-draft/periodic", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPut, "/api/sessions/test-make-loop-draft/loop", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, "test-make-periodic-draft", "") + h.HandleSessionLoop(w, req, "test-make-loop-draft", "") if w.Code != http.StatusOK { - t.Errorf("PUT periodic draft: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + t.Errorf("PUT loop draft: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) } - // Verify the stored periodic config reflects the draft state. - ps := store.Periodic("test-make-periodic-draft") + // Verify the stored loop config reflects the draft state. + ps := store.Loop("test-make-loop-draft") stored, err := ps.Get() if err != nil { - t.Fatalf("Get periodic after PUT: %v", err) + t.Fatalf("Get loop after PUT: %v", err) } if stored.Enabled { - t.Errorf("Draft periodic should have Enabled=false, got true") + t.Errorf("Draft loop should have Enabled=false, got true") } if stored.Prompt != "(pending)" { - t.Errorf("Draft periodic prompt = %q, want %q", stored.Prompt, "(pending)") + t.Errorf("Draft loop prompt = %q, want %q", stored.Prompt, "(pending)") } } -// TestHandleSessionPeriodic_DeleteRemovesConfig verifies the "Make non-periodic" frontend flow: -// PUT a draft config, confirm it exists, then DELETE it via HandleSessionPeriodic, +// TestHandleSessionLoop_DeleteRemovesConfig verifies the "Make non-loop" frontend flow: +// PUT a draft config, confirm it exists, then DELETE it via HandleSessionLoop, // assert HTTP 204, and confirm the config is gone from the store. -func TestHandleSessionPeriodic_DeleteRemovesConfig(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_DeleteRemovesConfig(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() - const sid = "test-delete-periodic" + const sid = "test-delete-loop" if err := store.Create(session.Metadata{ SessionID: sid, ACPServer: "test-server", @@ -453,46 +453,46 @@ func TestHandleSessionPeriodic_DeleteRemovesConfig(t *testing.T) { t.Fatalf("Create failed: %v", err) } - // Step 1: PUT a draft periodic config so there is something to delete. - putBody, _ := json.Marshal(PeriodicPromptRequest{ + // Step 1: PUT a draft loop config so there is something to delete. + putBody, _ := json.Marshal(LoopPromptRequest{ Prompt: "(pending)", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: false, }) - putReq := httptest.NewRequest(http.MethodPut, "/api/sessions/"+sid+"/periodic", bytes.NewReader(putBody)) + putReq := httptest.NewRequest(http.MethodPut, "/api/sessions/"+sid+"/loop", bytes.NewReader(putBody)) putReq.Header.Set("Content-Type", "application/json") putW := httptest.NewRecorder() - h.HandleSessionPeriodic(putW, putReq, sid, "") + h.HandleSessionLoop(putW, putReq, sid, "") if putW.Code != http.StatusOK { - t.Fatalf("PUT periodic: Status = %d, want 200. Body: %s", putW.Code, putW.Body.String()) + t.Fatalf("PUT loop: Status = %d, want 200. Body: %s", putW.Code, putW.Body.String()) } // Confirm the config exists before deleting. - if _, err := store.Periodic(sid).Get(); err != nil { - t.Fatalf("Get periodic before DELETE: %v", err) + if _, err := store.Loop(sid).Get(); err != nil { + t.Fatalf("Get loop before DELETE: %v", err) } - // Step 2: DELETE — mirrors what handleMakeNonPeriodic in app.js sends. - delReq := httptest.NewRequest(http.MethodDelete, "/api/sessions/"+sid+"/periodic", nil) + // Step 2: DELETE — mirrors what handleMakeNonLoop in app.js sends. + delReq := httptest.NewRequest(http.MethodDelete, "/api/sessions/"+sid+"/loop", nil) delW := httptest.NewRecorder() - h.HandleSessionPeriodic(delW, delReq, sid, "") + h.HandleSessionLoop(delW, delReq, sid, "") - // handleDeletePeriodic calls writeNoContent → HTTP 204. + // handleDeleteLoop calls writeNoContent → HTTP 204. if delW.Code != http.StatusNoContent { - t.Errorf("DELETE periodic: Status = %d, want %d. Body: %s", delW.Code, http.StatusNoContent, delW.Body.String()) + t.Errorf("DELETE loop: Status = %d, want %d. Body: %s", delW.Code, http.StatusNoContent, delW.Body.String()) } // Step 3: Confirm the config is gone. - _, getErr := store.Periodic(sid).Get() + _, getErr := store.Loop(sid).Get() if getErr == nil { t.Errorf("Expected error (config gone) after DELETE, got nil") } } -// TestHandleSetPeriodic_PendingPlaceholderDoesNotBecomeTitle verifies that when a periodic +// TestHandleSetLoop_PendingPlaceholderDoesNotBecomeTitle verifies that when a loop // prompt is set with a "(pending)" placeholder body plus a prompt_name, the generated title // is derived from the resolved prompt body rather than the placeholder. -func TestHandleSetPeriodic_PendingPlaceholderDoesNotBecomeTitle(t *testing.T) { +func TestHandleSetLoop_PendingPlaceholderDoesNotBecomeTitle(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore: %v", err) @@ -520,7 +520,7 @@ func TestHandleSetPeriodic_PendingPlaceholderDoesNotBecomeTitle(t *testing.T) { h := New(Deps{Store: store, SessionManager: sm}) - putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + putLoopForTest(t, h, sid, LoopPromptRequest{ Prompt: "(pending)", PromptName: "CGW: latest questions", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, @@ -539,11 +539,11 @@ func TestHandleSetPeriodic_PendingPlaceholderDoesNotBecomeTitle(t *testing.T) { } } -// TestHandleSessionPeriodic_OnTasksRoundTrip verifies that the onTasks trigger and its +// TestHandleSessionLoop_OnTasksRoundTrip verifies that the onTasks trigger and its // condition/condition_preset/cooldown_seconds fields round-trip through PUT and PATCH, // and that a frequency is not required for the onTasks trigger. -func TestHandleSessionPeriodic_OnTasksRoundTrip(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_OnTasksRoundTrip(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() const sid = "test-ontasks-roundtrip" @@ -555,7 +555,7 @@ func TestHandleSessionPeriodic_OnTasksRoundTrip(t *testing.T) { preset := "any-open-increase" cooldown := 120 - got := putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + got := putLoopForTest(t, h, sid, LoopPromptRequest{ Prompt: "review beads changes", Enabled: true, Trigger: session.TriggerOnTasks, @@ -579,18 +579,18 @@ func TestHandleSessionPeriodic_OnTasksRoundTrip(t *testing.T) { // PATCH: change only the condition; other onTasks fields must be preserved. newCond := `size(Changes.Reopened) > 0` - patchBody, _ := json.Marshal(PeriodicPromptPatchRequest{Condition: &newCond}) - req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/periodic", bytes.NewReader(patchBody)) + patchBody, _ := json.Marshal(LoopPromptPatchRequest{Condition: &newCond}) + req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/loop", bytes.NewReader(patchBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, sid, "") + h.HandleSessionLoop(w, req, sid, "") if w.Code != http.StatusOK { - t.Fatalf("PATCH periodic: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + t.Fatalf("PATCH loop: Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) } - stored, err := store.Periodic(sid).Get() + stored, err := store.Loop(sid).Get() if err != nil { - t.Fatalf("Get periodic after PATCH: %v", err) + t.Fatalf("Get loop after PATCH: %v", err) } if stored.Condition != newCond { t.Errorf("Condition after PATCH = %q, want %q", stored.Condition, newCond) @@ -606,18 +606,18 @@ func TestHandleSessionPeriodic_OnTasksRoundTrip(t *testing.T) { } } -// TestHandleSessionPeriodic_PatchInvalidConditionRejected verifies that an invalid CEL +// TestHandleSessionLoop_PatchInvalidConditionRejected verifies that an invalid CEL // condition is rejected with a 400 Bad Request when session.ConditionValidator is wired. // The real wiring (config.ValidateCondition) is owned by a sibling worker, so this test // injects a fake rejecting validator to exercise the same seam in isolation. -func TestHandleSessionPeriodic_PatchInvalidConditionRejected(t *testing.T) { +func TestHandleSessionLoop_PatchInvalidConditionRejected(t *testing.T) { old := session.ConditionValidator session.ConditionValidator = func(expr string) error { return errors.New("simulated invalid CEL") } defer func() { session.ConditionValidator = old }() - store, h := newPeriodicStore(t) + store, h := newLoopStore(t) tmpDir := t.TempDir() const sid = "test-ontasks-invalid-condition" @@ -625,18 +625,18 @@ func TestHandleSessionPeriodic_PatchInvalidConditionRejected(t *testing.T) { t.Fatalf("Create failed: %v", err) } - putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + putLoopForTest(t, h, sid, LoopPromptRequest{ Prompt: "review beads changes", Enabled: true, Trigger: session.TriggerOnTasks, }) badCond := "not valid cel(" - patchBody, _ := json.Marshal(PeriodicPromptPatchRequest{Condition: &badCond}) - req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/periodic", bytes.NewReader(patchBody)) + patchBody, _ := json.Marshal(LoopPromptPatchRequest{Condition: &badCond}) + req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/loop", bytes.NewReader(patchBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, sid, "") + h.HandleSessionLoop(w, req, sid, "") if w.Code != http.StatusBadRequest { t.Fatalf("PATCH invalid condition: Status = %d, want %d. Body: %s", w.Code, http.StatusBadRequest, w.Body.String()) @@ -654,19 +654,19 @@ func TestHandleSessionPeriodic_PatchInvalidConditionRejected(t *testing.T) { } // Verify the rejected condition was not persisted. - stored, err := store.Periodic(sid).Get() + stored, err := store.Loop(sid).Get() if err != nil { - t.Fatalf("Get periodic after rejected PATCH: %v", err) + t.Fatalf("Get loop after rejected PATCH: %v", err) } if stored.Condition == badCond { t.Errorf("rejected condition must not be persisted, got %q", stored.Condition) } } -// TestHandleSessionPeriodic_PUT_ArgumentsPersisted verifies that Arguments supplied in a -// PUT request are stored in the periodic config and returned by Get. -func TestHandleSessionPeriodic_PUT_ArgumentsPersisted(t *testing.T) { - store, h := newPeriodicStore(t) +// TestHandleSessionLoop_PUT_ArgumentsPersisted verifies that Arguments supplied in a +// PUT request are stored in the loop config and returned by Get. +func TestHandleSessionLoop_PUT_ArgumentsPersisted(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() sid := "put-args-session" if err := store.Create(session.Metadata{ @@ -678,7 +678,7 @@ func TestHandleSessionPeriodic_PUT_ArgumentsPersisted(t *testing.T) { } args := map[string]string{"ISSUE_ID": "mitto-42", "ENV": "staging"} - got := putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + got := putLoopForTest(t, h, sid, LoopPromptRequest{ PromptName: "check-status", Arguments: args, Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, @@ -695,9 +695,9 @@ func TestHandleSessionPeriodic_PUT_ArgumentsPersisted(t *testing.T) { } // Verify round-trip via the store directly. - stored, err := store.Periodic(sid).Get() + stored, err := store.Loop(sid).Get() if err != nil { - t.Fatalf("Periodic().Get() error = %v", err) + t.Fatalf("Loop().Get() error = %v", err) } for k, v := range args { if stored.Arguments[k] != v { @@ -706,10 +706,10 @@ func TestHandleSessionPeriodic_PUT_ArgumentsPersisted(t *testing.T) { } } -// TestHandleSessionPeriodic_PATCH_ArgumentsPersisted verifies that Arguments supplied in a +// TestHandleSessionLoop_PATCH_ArgumentsPersisted verifies that Arguments supplied in a // PATCH request replace the existing arguments and are returned by Get. -func TestHandleSessionPeriodic_PATCH_ArgumentsPersisted(t *testing.T) { - store, h := newPeriodicStore(t) +func TestHandleSessionLoop_PATCH_ArgumentsPersisted(t *testing.T) { + store, h := newLoopStore(t) tmpDir := t.TempDir() sid := "patch-args-session" if err := store.Create(session.Metadata{ @@ -721,7 +721,7 @@ func TestHandleSessionPeriodic_PATCH_ArgumentsPersisted(t *testing.T) { } // Seed via PUT with initial arguments. - putPeriodicForTest(t, h, sid, PeriodicPromptRequest{ + putLoopForTest(t, h, sid, LoopPromptRequest{ PromptName: "check-status", Arguments: map[string]string{"KEY": "initial"}, Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, @@ -730,18 +730,18 @@ func TestHandleSessionPeriodic_PATCH_ArgumentsPersisted(t *testing.T) { // PATCH with new arguments. newArgs := map[string]string{"KEY": "patched", "EXTRA": "yes"} - body, _ := json.Marshal(PeriodicPromptPatchRequest{ + body, _ := json.Marshal(LoopPromptPatchRequest{ Arguments: &newArgs, }) - req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/periodic", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/loop", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - h.HandleSessionPeriodic(w, req, sid, "") + h.HandleSessionLoop(w, req, sid, "") if w.Code != http.StatusOK { - t.Fatalf("PATCH periodic status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + t.Fatalf("PATCH loop status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) } - var got session.PeriodicPrompt + var got session.LoopPrompt if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode PATCH response: %v", err) } @@ -753,15 +753,15 @@ func TestHandleSessionPeriodic_PATCH_ArgumentsPersisted(t *testing.T) { } // Nil arguments in PATCH must leave stored map unchanged. - body2, _ := json.Marshal(PeriodicPromptPatchRequest{}) // nil Arguments - req2 := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/periodic", bytes.NewReader(body2)) + body2, _ := json.Marshal(LoopPromptPatchRequest{}) // nil Arguments + req2 := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid+"/loop", bytes.NewReader(body2)) req2.Header.Set("Content-Type", "application/json") w2 := httptest.NewRecorder() - h.HandleSessionPeriodic(w2, req2, sid, "") + h.HandleSessionLoop(w2, req2, sid, "") if w2.Code != http.StatusOK { t.Fatalf("PATCH (nil args) status = %d. Body: %s", w2.Code, w2.Body.String()) } - stored, _ := store.Periodic(sid).Get() + stored, _ := store.Loop(sid).Get() if stored.Arguments["KEY"] != "patched" { t.Errorf("nil PATCH should not clear Arguments; KEY = %q", stored.Arguments["KEY"]) } diff --git a/internal/web/handlers/session_periodic_write.go b/internal/web/handlers/session_loop_write.go similarity index 70% rename from internal/web/handlers/session_periodic_write.go rename to internal/web/handlers/session_loop_write.go index 3bfe7294..85b96d03 100644 --- a/internal/web/handlers/session_periodic_write.go +++ b/internal/web/handlers/session_loop_write.go @@ -7,7 +7,7 @@ import ( "github.com/inercia/mitto/internal/session" ) -// isInvalidConditionErr reports whether err originates from PeriodicPrompt.Validate's +// isInvalidConditionErr reports whether err originates from LoopPrompt.Validate's // CEL condition check (session.ConditionValidator, wired to config.ValidateCondition). // There is no dedicated sentinel for this — Validate wraps the validator's error with // the fixed prefix "invalid condition: " — so we match on that prefix to classify it @@ -16,14 +16,14 @@ func isInvalidConditionErr(err error) bool { return err != nil && strings.HasPrefix(err.Error(), "invalid condition:") } -// handleSetPeriodic handles PUT /api/sessions/{id}/periodic -func (h *Handlers) handleSetPeriodic(w http.ResponseWriter, r *http.Request, sessionID string, ps *session.PeriodicStore) { - var req PeriodicPromptRequest +// handleSetLoop handles PUT /api/sessions/{id}/loop +func (h *Handlers) handleSetLoop(w http.ResponseWriter, r *http.Request, sessionID string, ps *session.LoopStore) { + var req LoopPromptRequest if !parseJSONBody(w, r, &req) { return } - p := &session.PeriodicPrompt{ + p := &session.LoopPrompt{ Prompt: req.Prompt, PromptName: req.PromptName, Arguments: req.Arguments, @@ -45,7 +45,7 @@ func (h *Handlers) handleSetPeriodic(w http.ResponseWriter, r *http.Request, ses p.CooldownSeconds = *req.CooldownSeconds } // Clamp the on-completion delay to the global floor on write (no-op for schedule trigger). - p.ClampDelay(h.periodicDelayFloor()) + p.ClampDelay(h.loopDelayFloor()) if err := ps.Set(p); err != nil { if err == session.ErrInvalidFrequency || err == session.ErrPromptEmpty || err == session.ErrInvalidMaxIterations || @@ -55,25 +55,25 @@ func (h *Handlers) handleSetPeriodic(w http.ResponseWriter, r *http.Request, ses return } if h.deps.Logger != nil { - h.deps.Logger.Error("Failed to set periodic prompt", "error", err) + h.deps.Logger.Error("Failed to set loop prompt", "error", err) } - writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to set periodic prompt") + writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to set loop prompt") return } - h.resetPeriodicContinuation(sessionID) + h.resetLoopContinuation(sessionID) - // Return the updated periodic prompt + // Return the updated loop prompt updated, err := ps.Get() if err != nil { - writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to get updated periodic prompt") + writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to get updated loop prompt") return } - // If the session has no title, trigger title generation from the periodic prompt. - h.triggerTitleFromPeriodic(sessionID, req.Prompt, req.PromptName) + // If the session has no title, trigger title generation from the loop prompt. + h.triggerTitleFromLoop(sessionID, req.Prompt, req.PromptName) - // Broadcast periodic state change to all clients (includes full config) - h.broadcastPeriodic(sessionID, updated) + // Broadcast loop state change to all clients (includes full config) + h.broadcastLoop(sessionID, updated) // Kick off the very first run for a fresh onCompletion conversation. if h.deps.BootstrapOnCompletion != nil { @@ -83,9 +83,9 @@ func (h *Handlers) handleSetPeriodic(w http.ResponseWriter, r *http.Request, ses writeJSONOK(w, updated) } -// handlePatchPeriodic handles PATCH /api/sessions/{id}/periodic -func (h *Handlers) handlePatchPeriodic(w http.ResponseWriter, r *http.Request, sessionID string, ps *session.PeriodicStore) { - var req PeriodicPromptPatchRequest +// handlePatchLoop handles PATCH /api/sessions/{id}/loop +func (h *Handlers) handlePatchLoop(w http.ResponseWriter, r *http.Request, sessionID string, ps *session.LoopStore) { + var req LoopPromptPatchRequest if !parseJSONBody(w, r, &req) { return } @@ -93,9 +93,9 @@ func (h *Handlers) handlePatchPeriodic(w http.ResponseWriter, r *http.Request, s // Clamp the on-completion delay to the global floor on write. The effective trigger // is the patched value when provided, otherwise the currently-stored trigger. if req.DelaySeconds != nil { - floor := h.periodicDelayFloor() + floor := h.loopDelayFloor() if *req.DelaySeconds < floor { - effTrigger := session.PeriodicTrigger("") + effTrigger := session.LoopTrigger("") if req.Trigger != nil { effTrigger = *req.Trigger } else if cur, err := ps.Get(); err == nil && cur != nil { @@ -109,8 +109,8 @@ func (h *Handlers) handlePatchPeriodic(w http.ResponseWriter, r *http.Request, s } if err := ps.Update(req.Prompt, req.PromptName, req.Frequency, req.Enabled, req.FreshContext, req.MaxIterations, req.Trigger, req.DelaySeconds, req.MaxDurationSeconds, req.Arguments, req.Condition, req.ConditionPreset, req.CooldownSeconds); err != nil { - if err == session.ErrPeriodicNotFound { - writeErrorJSON(w, http.StatusNotFound, "", "No periodic prompt configured") + if err == session.ErrLoopNotFound { + writeErrorJSON(w, http.StatusNotFound, "", "No loop prompt configured") return } if err == session.ErrInvalidFrequency || err == session.ErrPromptEmpty || err == session.ErrInvalidMaxIterations || @@ -120,9 +120,9 @@ func (h *Handlers) handlePatchPeriodic(w http.ResponseWriter, r *http.Request, s return } if h.deps.Logger != nil { - h.deps.Logger.Error("Failed to update periodic prompt", "error", err) + h.deps.Logger.Error("Failed to update loop prompt", "error", err) } - writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to update periodic prompt") + writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to update loop prompt") return } @@ -131,9 +131,9 @@ func (h *Handlers) handlePatchPeriodic(w http.ResponseWriter, r *http.Request, s if req.ResetCounters != nil && *req.ResetCounters { if err := ps.ResetCounters(); err != nil { if h.deps.Logger != nil { - h.deps.Logger.Error("Failed to reset periodic counters", "error", err) + h.deps.Logger.Error("Failed to reset loop counters", "error", err) } - writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to reset periodic counters") + writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to reset loop counters") return } } @@ -145,25 +145,25 @@ func (h *Handlers) handlePatchPeriodic(w http.ResponseWriter, r *http.Request, s h.deps.Logger.Warn("Failed to record pausedByUser reason", "error", err) } } - h.resetPeriodicContinuation(sessionID) + h.resetLoopContinuation(sessionID) - // Return the updated periodic prompt + // Return the updated loop prompt updated, err := ps.Get() if err != nil { - writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to get updated periodic prompt") + writeErrorJSON(w, http.StatusInternalServerError, "", "Failed to get updated loop prompt") return } - // If the session has no title, trigger title generation from the periodic prompt. + // If the session has no title, trigger title generation from the loop prompt. var pPrompt, pName string if updated != nil { pPrompt = updated.Prompt pName = updated.PromptName } - h.triggerTitleFromPeriodic(sessionID, pPrompt, pName) + h.triggerTitleFromLoop(sessionID, pPrompt, pName) - // Broadcast periodic state change to all clients (includes full config) - h.broadcastPeriodic(sessionID, updated) + // Broadcast loop state change to all clients (includes full config) + h.broadcastLoop(sessionID, updated) // Kick off the very first run for a fresh onCompletion conversation. if h.deps.BootstrapOnCompletion != nil { @@ -173,14 +173,14 @@ func (h *Handlers) handlePatchPeriodic(w http.ResponseWriter, r *http.Request, s writeJSONOK(w, updated) } -// resetPeriodicContinuation clears the live BackgroundSession's periodic continuation marker -// (mitto-5xjn) so the next periodic run after a config change/pause/re-enable renders the +// resetLoopContinuation clears the live BackgroundSession's loop continuation marker +// (mitto-5xjn) so the next loop run after a config change/pause/re-enable renders the // verbose form. No-op when the session is not currently live. -func (h *Handlers) resetPeriodicContinuation(sessionID string) { +func (h *Handlers) resetLoopContinuation(sessionID string) { if h.deps.SessionManager == nil { return } if bs := h.deps.SessionManager.GetSession(sessionID); bs != nil { - bs.ResetPeriodicContinuation() + bs.ResetLoopContinuation() } } diff --git a/internal/web/handlers/session_update.go b/internal/web/handlers/session_update.go index 43ba5d5c..97ab79dc 100644 --- a/internal/web/handlers/session_update.go +++ b/internal/web/handlers/session_update.go @@ -135,10 +135,10 @@ func (h *Handlers) HandleUpdateSession(w http.ResponseWriter, r *http.Request, s // Delete all child sessions when parent is archived if req.Archived != nil && *req.Archived { - // Authoritatively stop the periodic loop on archive so it can never schedule a + // Authoritatively stop the loop on archive so it can never schedule a // new run or spawn new children, and the UI badge clears (mitto-efnb). - if h.deps.StopPeriodicForArchive != nil { - h.deps.StopPeriodicForArchive(sessionID) + if h.deps.StopLoopForArchive != nil { + h.deps.StopLoopForArchive(sessionID) } if h.deps.SessionManager != nil { go h.deps.SessionManager.DeleteChildSessions(sessionID) @@ -177,7 +177,68 @@ func (h *Handlers) HandleUpdateSession(w http.ResponseWriter, r *http.Request, s if h.deps.BroadcastSessionArchived != nil { h.deps.BroadcastSessionArchived(sessionID, false) } + + // Restore/re-surface any loop configuration that was left disabled by + // the archive (mitto-vmp): auto-resume archive-related stops, keep + // other pauses paused, and always re-broadcast the current config. + h.restoreLoopOnUnarchive(sessionID) } writeJSONOK(w, meta) } + +// restoreLoopOnUnarchive re-surfaces a session's loop configuration after +// unarchive. Loop config (prompt/arguments/trigger/etc.) survives archive in +// loop.json, but MarkStopped(StoppedReasonArchived/ResumeFailures) leaves it +// disabled with no broadcast, so clients never learn it's still there +// (mitto-vmp). This: +// 1. Does nothing when the session has no loop configured. +// 2. Auto-re-enables the loop when it was stopped for an archive-related +// reason (manual archive or ACP resume-failure auto-archive), kicking off +// BootstrapOnCompletion for onCompletion loops. +// 3. Leaves other pause reasons (user-paused, max iterations, etc.) alone. +// 4. Always re-broadcasts the current loop state so the UI can re-render it. +func (h *Handlers) restoreLoopOnUnarchive(sessionID string) { + store := h.deps.Store + if store == nil { + return + } + + loopStore := store.Loop(sessionID) + loop, err := loopStore.Get() + if err != nil { + if err != session.ErrLoopNotFound { + if h.deps.Logger != nil { + h.deps.Logger.Warn("Failed to read loop config on unarchive", + "session_id", sessionID, "error", err) + } + } + return + } + if loop == nil { + return + } + + archiveRelated := loop.StoppedReason == session.StoppedReasonArchived || + loop.StoppedReason == session.StoppedReasonResumeFailures + + if archiveRelated && !loop.Enabled { + enabled := true + if err := loopStore.Update(nil, nil, nil, &enabled, nil, nil, nil, nil, nil, nil, nil, nil, nil); err != nil { + if h.deps.Logger != nil { + h.deps.Logger.Warn("Failed to re-enable loop on unarchive", + "session_id", sessionID, "error", err) + } + } else if updated, gErr := loopStore.Get(); gErr == nil && updated != nil && updated.IsOnCompletion() { + if h.deps.BootstrapOnCompletion != nil { + h.deps.BootstrapOnCompletion(sessionID) + } + } + } + + // Always re-broadcast the latest loop state so the editor/pill render, + // whether the loop was just re-enabled or is still paused. + if final, gErr := loopStore.Get(); gErr == nil && h.deps.BroadcastLoopUpdated != nil { + h.deps.BroadcastLoopUpdated(sessionID, final) + } +} diff --git a/internal/web/handlers/session_update_test.go b/internal/web/handlers/session_update_test.go new file mode 100644 index 00000000..4c8b9c26 --- /dev/null +++ b/internal/web/handlers/session_update_test.go @@ -0,0 +1,256 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/inercia/mitto/internal/session" +) + +// seedArchivedLoopSession creates a session store + archived session with a +// loop config that was stopped for the given reason, mimicking the state +// left behind by an archive. +func seedArchivedLoopSession(t *testing.T, sid string, stoppedReason session.StoppedReason) (*session.Store, *session.LoopPrompt) { + t.Helper() + store, err := session.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + t.Cleanup(func() { store.Close() }) + + if err := store.Create(session.Metadata{ + SessionID: sid, + ACPServer: "test-server", + WorkingDir: t.TempDir(), + }); err != nil { + t.Fatalf("Create failed: %v", err) + } + + loop := &session.LoopPrompt{ + Prompt: "do the thing", + Arguments: map[string]string{"foo": "bar"}, + Trigger: session.TriggerOnCompletion, + Enabled: true, + } + if err := store.Loop(sid).Set(loop); err != nil { + t.Fatalf("Loop Set failed: %v", err) + } + if err := store.Loop(sid).MarkStopped(stoppedReason); err != nil { + t.Fatalf("MarkStopped failed: %v", err) + } + + // Mark the session as archived (metadata only; MarkStopped doesn't do this). + if err := store.UpdateMetadata(sid, func(meta *session.Metadata) { + meta.Archived = true + meta.ArchiveReason = session.ArchiveReasonManual + }); err != nil { + t.Fatalf("UpdateMetadata failed: %v", err) + } + + return store, loop +} + +// unarchiveViaHandler issues a PATCH {"archived": false} against the given +// session and returns the recorded response. +func unarchiveViaHandler(t *testing.T, h *Handlers, sid string) *httptest.ResponseRecorder { + t.Helper() + archived := false + body, _ := json.Marshal(SessionUpdateRequest{Archived: &archived}) + req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sid, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.HandleUpdateSession(w, req, sid) + return w +} + +func TestRestoreLoopOnUnarchive_RoundTripsOriginalConfig(t *testing.T) { + sid := "test-loop-unarchive-roundtrip" + store, original := seedArchivedLoopSession(t, sid, session.StoppedReasonArchived) + h := New(Deps{Store: store}) + + w := unarchiveViaHandler(t, h, sid) + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + got, err := store.Loop(sid).Get() + if err != nil { + t.Fatalf("Get() after unarchive error = %v", err) + } + if got.Prompt != original.Prompt { + t.Errorf("Prompt = %q, want %q", got.Prompt, original.Prompt) + } + if got.Arguments["foo"] != original.Arguments["foo"] { + t.Errorf("Arguments = %v, want %v", got.Arguments, original.Arguments) + } + if got.Trigger != original.Trigger { + t.Errorf("Trigger = %q, want %q", got.Trigger, original.Trigger) + } + if !got.Enabled { + t.Error("Enabled should be true after unarchive of an archive-stopped loop") + } + if got.StoppedReason != "" { + t.Errorf("StoppedReason = %q, want empty", got.StoppedReason) + } +} + +func TestRestoreLoopOnUnarchive_AutoResumeArchived(t *testing.T) { + sid := "test-loop-unarchive-archived" + store, _ := seedArchivedLoopSession(t, sid, session.StoppedReasonArchived) + + var mu sync.Mutex + var broadcastCount int + var lastLoop *session.LoopPrompt + h := New(Deps{ + Store: store, + BroadcastLoopUpdated: func(_ string, loop *session.LoopPrompt) { + mu.Lock() + defer mu.Unlock() + broadcastCount++ + lastLoop = loop + }, + }) + + w := unarchiveViaHandler(t, h, sid) + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, want %d", w.Code, http.StatusOK) + } + + got, err := store.Loop(sid).Get() + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !got.Enabled { + t.Error("Enabled should be true") + } + if got.StoppedReason != "" { + t.Errorf("StoppedReason = %q, want empty", got.StoppedReason) + } + + mu.Lock() + defer mu.Unlock() + if broadcastCount != 1 { + t.Errorf("BroadcastLoopUpdated call count = %d, want 1", broadcastCount) + } + if lastLoop == nil { + t.Error("BroadcastLoopUpdated called with nil loop") + } +} + +func TestRestoreLoopOnUnarchive_AutoResumeResumeFailures(t *testing.T) { + sid := "test-loop-unarchive-resumefailures" + store, _ := seedArchivedLoopSession(t, sid, session.StoppedReasonResumeFailures) + + var mu sync.Mutex + var broadcastCount int + h := New(Deps{ + Store: store, + BroadcastLoopUpdated: func(_ string, _ *session.LoopPrompt) { + mu.Lock() + defer mu.Unlock() + broadcastCount++ + }, + }) + + w := unarchiveViaHandler(t, h, sid) + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, want %d", w.Code, http.StatusOK) + } + + got, err := store.Loop(sid).Get() + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !got.Enabled { + t.Error("Enabled should be true") + } + if got.StoppedReason != "" { + t.Errorf("StoppedReason = %q, want empty", got.StoppedReason) + } + + mu.Lock() + defer mu.Unlock() + if broadcastCount != 1 { + t.Errorf("BroadcastLoopUpdated call count = %d, want 1", broadcastCount) + } +} + +func TestRestoreLoopOnUnarchive_NonArchivePauseStaysPaused(t *testing.T) { + sid := "test-loop-unarchive-maxiterations" + store, _ := seedArchivedLoopSession(t, sid, session.StoppedReasonMaxIterations) + + var mu sync.Mutex + var broadcastCount int + h := New(Deps{ + Store: store, + BroadcastLoopUpdated: func(_ string, _ *session.LoopPrompt) { + mu.Lock() + defer mu.Unlock() + broadcastCount++ + }, + }) + + w := unarchiveViaHandler(t, h, sid) + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, want %d", w.Code, http.StatusOK) + } + + got, err := store.Loop(sid).Get() + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.Enabled { + t.Error("Enabled should stay false for a non-archive-related stop reason") + } + if got.StoppedReason != session.StoppedReasonMaxIterations { + t.Errorf("StoppedReason = %q, want %q", got.StoppedReason, session.StoppedReasonMaxIterations) + } + + mu.Lock() + defer mu.Unlock() + if broadcastCount != 1 { + t.Errorf("BroadcastLoopUpdated call count = %d, want 1 (config should still be surfaced)", broadcastCount) + } +} + +func TestRestoreLoopOnUnarchive_NoLoopSessionIsNoop(t *testing.T) { + sid := "test-no-loop-unarchive" + store, err := session.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + t.Cleanup(func() { store.Close() }) + + if err := store.Create(session.Metadata{ + SessionID: sid, + ACPServer: "test-server", + WorkingDir: t.TempDir(), + Archived: true, + }); err != nil { + t.Fatalf("Create failed: %v", err) + } + + var broadcastCount int + h := New(Deps{ + Store: store, + BroadcastLoopUpdated: func(_ string, _ *session.LoopPrompt) { + broadcastCount++ + }, + }) + + w := unarchiveViaHandler(t, h, sid) + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, want %d", w.Code, http.StatusOK) + } + + if _, err := store.Loop(sid).Get(); err != session.ErrLoopNotFound { + t.Errorf("Loop Get() error = %v, want ErrLoopNotFound", err) + } + if broadcastCount != 0 { + t.Errorf("BroadcastLoopUpdated call count = %d, want 0 for a non-loop session", broadcastCount) + } +} diff --git a/internal/web/handlers/ui_preferences.go b/internal/web/handlers/ui_preferences.go index 94eb9bec..2ef114ae 100644 --- a/internal/web/handlers/ui_preferences.go +++ b/internal/web/handlers/ui_preferences.go @@ -20,7 +20,7 @@ type UIPreferences struct { ExpandedGroups map[string]bool `json:"expanded_groups,omitempty"` // FilterTabGrouping maps filter tab IDs to their grouping mode - // Each filter tab (conversations, periodic, archived) can have its own grouping mode + // Each filter tab (conversations, loop, archived) can have its own grouping mode FilterTabGrouping map[string]string `json:"filter_tab_grouping,omitempty"` // PromptSortMode is the sorting mode for prompts in the dropdown: "alphabetical" or "color" @@ -88,11 +88,11 @@ func (h *Handlers) handleSaveUIPreferences(w http.ResponseWriter, r *http.Reques } // Validate filter_tab_grouping keys and values - validFilterTabs := map[string]bool{"conversations": true, "periodic": true, "archived": true} + validFilterTabs := map[string]bool{"conversations": true, "loop": true, "archived": true} validGroupingModes := map[string]bool{"none": true, "server": true, "folder": true, "workspace": true} for key, value := range prefs.FilterTabGrouping { if !validFilterTabs[key] { - writeErrorJSON(w, http.StatusBadRequest, "", "Invalid filter_tab_grouping key: must be 'conversations', 'periodic', or 'archived'") + writeErrorJSON(w, http.StatusBadRequest, "", "Invalid filter_tab_grouping key: must be 'conversations', 'loop', or 'archived'") return } if !validGroupingModes[value] { diff --git a/internal/web/periodic_runner.go b/internal/web/loop_runner.go similarity index 65% rename from internal/web/periodic_runner.go rename to internal/web/loop_runner.go index f659b42d..eed0b270 100644 --- a/internal/web/periodic_runner.go +++ b/internal/web/loop_runner.go @@ -15,82 +15,82 @@ import ( ) const ( - // DefaultPollInterval is the default interval between periodic prompt checks. + // DefaultPollInterval is the default interval between loop prompt checks. DefaultPollInterval = 1 * time.Minute - // MaxPeriodicResumeFailures is the number of consecutive ACP resume failures - // after which a periodic session is automatically archived. - MaxPeriodicResumeFailures = 3 + // MaxLoopResumeFailures is the number of consecutive ACP resume failures + // after which a loop session is automatically archived. + MaxLoopResumeFailures = 3 // MaxPromptResolveFailures is the number of consecutive prompt-name resolution - // failures after which the periodic config is auto-paused (disabled). + // failures after which the loop config is auto-paused (disabled). MaxPromptResolveFailures = 3 - // periodicScheduleBackoffBase is the initial delay applied to NextScheduledAt - // after the first scheduled periodic delivery failure. It doubles with each - // consecutive failure, capped at periodicScheduleBackoffCap. This prevents a + // loopScheduleBackoffBase is the initial delay applied to NextScheduledAt + // after the first scheduled loop delivery failure. It doubles with each + // consecutive failure, capped at loopScheduleBackoffCap. This prevents a // flaky transport from re-firing the same prompt on every poll tick (mitto-qal.2). - periodicScheduleBackoffBase = 30 * time.Second + loopScheduleBackoffBase = 30 * time.Second - // periodicScheduleBackoffCap is the maximum backoff delay for scheduled - // periodic delivery failures. - periodicScheduleBackoffCap = 15 * time.Minute + // loopScheduleBackoffCap is the maximum backoff delay for scheduled + // loop delivery failures. + loopScheduleBackoffCap = 15 * time.Minute ) -// periodicScheduleBackoff returns the delay to defer the next scheduled run after +// loopScheduleBackoff returns the delay to defer the next scheduled run after // `failures` consecutive delivery failures. It grows exponentially from -// periodicScheduleBackoffBase, doubling on each failure, capped at -// periodicScheduleBackoffCap. -func periodicScheduleBackoff(failures int) time.Duration { +// loopScheduleBackoffBase, doubling on each failure, capped at +// loopScheduleBackoffCap. +func loopScheduleBackoff(failures int) time.Duration { if failures < 1 { failures = 1 } - delay := periodicScheduleBackoffBase + delay := loopScheduleBackoffBase for i := 1; i < failures; i++ { delay *= 2 - if delay >= periodicScheduleBackoffCap { - return periodicScheduleBackoffCap + if delay >= loopScheduleBackoffCap { + return loopScheduleBackoffCap } } - if delay > periodicScheduleBackoffCap { - delay = periodicScheduleBackoffCap + if delay > loopScheduleBackoffCap { + delay = loopScheduleBackoffCap } return delay } -// Errors for periodic runner operations. +// Errors for loop runner operations. var ( ErrSessionStoreNotAvailable = errors.New("session store not available") ErrSessionManagerNotAvailable = errors.New("session manager not available") - ErrPeriodicNotEnabled = errors.New("periodic is not enabled for this session") + ErrLoopNotEnabled = errors.New("loop is not enabled for this session") ErrSessionBusy = errors.New("session is currently processing a prompt") - ErrPromptResolveFailed = errors.New("periodic prompt could not be resolved") + ErrPromptResolveFailed = errors.New("loop prompt could not be resolved") ) -// PeriodicStartedCallback is called when a periodic prompt is delivered. +// LoopStartedCallback is called when a loop prompt is delivered. // sessionID is the session that received the prompt. // sessionName is the display name of the session. -type PeriodicStartedCallback func(sessionID, sessionName string) +type LoopStartedCallback func(sessionID, sessionName string) -// AutoArchiveCallback is called when the periodic runner auto-archives a session. +// AutoArchiveCallback is called when the loop runner auto-archives a session. // It should handle broadcasting the archive state change and stopping ACP. type AutoArchiveCallback func(sessionID string) -// PeriodicAutoStoppedCallback is called when a periodic conversation is auto-stopped after reaching max iterations. -// It should broadcast the updated periodic state to all WebSocket clients. -type PeriodicAutoStoppedCallback func(sessionID string, periodic *session.PeriodicPrompt) +// LoopAutoStoppedCallback is called when a loop conversation is auto-stopped after reaching max iterations. +// It should broadcast the updated loop state to all WebSocket clients. +type LoopAutoStoppedCallback func(sessionID string, loop *session.LoopPrompt) -// PeriodicUpdatedCallback is called when a periodic conversation's schedule advances after a delivery. -// It should broadcast the updated periodic state (including the new next_scheduled_at) to all +// LoopUpdatedCallback is called when a loop conversation's schedule advances after a delivery. +// It should broadcast the updated loop state (including the new next_scheduled_at) to all // WebSocket clients so the countdown resets. -type PeriodicUpdatedCallback func(sessionID string, periodic *session.PeriodicPrompt) +type LoopUpdatedCallback func(sessionID string, loop *session.LoopPrompt) -// PeriodicRunner manages scheduled periodic prompt delivery and session housekeeping. +// LoopRunner manages scheduled loop prompt delivery and session housekeeping. // It polls all sessions at regular intervals and: -// - Delivers periodic prompts that are due +// - Delivers loop prompts that are due // - Auto-archives sessions inactive beyond the configured threshold // - Cleans up archived sessions past their retention period -type PeriodicRunner struct { +type LoopRunner struct { store *session.Store sessionManager *conversation.SessionManager logger *slog.Logger @@ -102,22 +102,22 @@ type PeriodicRunner struct { startupDelay time.Duration // resumeStagger is the delay between consecutive session resumes within a single poll. - // This prevents thundering herd when many periodic sessions are due simultaneously. + // This prevents thundering herd when many loop sessions are due simultaneously. resumeStagger time.Duration - // onPeriodicStarted is called when a periodic prompt is delivered - onPeriodicStarted PeriodicStartedCallback + // onLoopStarted is called when a loop prompt is delivered + onLoopStarted LoopStartedCallback // onAutoArchive is called when a session is auto-archived. // The callback should broadcast the archive state change and ACP stop to WebSocket clients. onAutoArchive AutoArchiveCallback - // onPeriodicAutoStopped is called when a periodic conversation is disabled after reaching max iterations. - onPeriodicAutoStopped PeriodicAutoStoppedCallback + // onLoopAutoStopped is called when a loop conversation is disabled after reaching max iterations. + onLoopAutoStopped LoopAutoStoppedCallback - // onPeriodicUpdated is called when a periodic conversation's schedule advances after a delivery, + // onLoopUpdated is called when a loop conversation's schedule advances after a delivery, // so clients can reset the countdown to the new next-run time. - onPeriodicUpdated PeriodicUpdatedCallback + onLoopUpdated LoopUpdatedCallback // autoArchiveAfter, when > 0, causes sessions inactive for this long to be archived. autoArchiveAfter time.Duration @@ -129,35 +129,35 @@ type PeriodicRunner struct { // promptResolver resolves a prompt name to its text at execution time. promptResolver conversation.PromptResolver - // maxPeriodicIterations is the user-configured default cap on scheduled - // periodic runs. 0 means unlimited; the hardcoded backstop still applies. - maxPeriodicIterations int + // maxLoopIterations is the user-configured default cap on scheduled + // loop runs. 0 means unlimited; the hardcoded backstop still applies. + maxLoopIterations int // minCompletionDelaySeconds is the global floor applied to the on-completion - // periodic trigger's delay, preventing hot loops. + // loop trigger's delay, preventing hot loops. minCompletionDelaySeconds int - // consecutiveFailures tracks how many times in a row a session's periodic - // prompt delivery failed due to ACP resume errors. After MaxPeriodicResumeFailures + // consecutiveFailures tracks how many times in a row a session's loop + // prompt delivery failed due to ACP resume errors. After MaxLoopResumeFailures // consecutive failures, the session is automatically archived. consecutiveFailures map[string]int consecutiveFailuresMu sync.Mutex - // promptResolveFailures tracks consecutive failures to resolve a periodic prompt - // name. After MaxPromptResolveFailures consecutive failures the periodic config is + // promptResolveFailures tracks consecutive failures to resolve a loop prompt + // name. After MaxPromptResolveFailures consecutive failures the loop config is // auto-paused (disabled) to stop the retry storm. promptResolveFailures map[string]int promptResolveFailuresMu sync.Mutex // scheduleBackoffFailures tracks consecutive delivery failures for scheduled - // periodic prompts. It drives an exponential backoff on NextScheduledAt so a + // loop prompts. It drives an exponential backoff on NextScheduledAt so a // flaky transport does not cause the same prompt to re-fire every poll tick // (mitto-qal.2). Reset to zero on the next successful delivery. Distinct from // consecutiveFailures, which tracks resume failures and triggers auto-archive. scheduleBackoffFailures map[string]int scheduleBackoffFailuresMu sync.Mutex - // completionTimers holds the armed one-shot timers for onCompletion periodic + // completionTimers holds the armed one-shot timers for onCompletion loop // conversations, keyed by session ID. Arming a new timer replaces (stops) any // existing one, so at most one firing is pending per session. completionTimers map[string]*time.Timer @@ -204,8 +204,8 @@ type PeriodicRunner struct { doneCh chan struct{} } -// NewPeriodicRunner creates a new periodic runner. -func NewPeriodicRunner(store *session.Store, sm *conversation.SessionManager, logger *slog.Logger) *PeriodicRunner { +// NewLoopRunner creates a new loop runner. +func NewLoopRunner(store *session.Store, sm *conversation.SessionManager, logger *slog.Logger) *LoopRunner { evaluator, err := config.NewTasksConditionEvaluator() if err != nil { evaluator = nil @@ -213,19 +213,19 @@ func NewPeriodicRunner(store *session.Store, sm *conversation.SessionManager, lo logger.Warn("Failed to initialize onTasks CEL evaluator; onTasks trigger will be inactive", "error", err) } } - return &PeriodicRunner{ + return &LoopRunner{ store: store, sessionManager: sm, logger: logger, pollInterval: DefaultPollInterval, - maxPeriodicIterations: config.DefaultMaxPeriodicIterations, - minCompletionDelaySeconds: config.DefaultMinPeriodicCompletionDelaySeconds, + maxLoopIterations: config.DefaultMaxLoopIterations, + minCompletionDelaySeconds: config.DefaultMinLoopCompletionDelaySeconds, consecutiveFailures: make(map[string]int), promptResolveFailures: make(map[string]int), scheduleBackoffFailures: make(map[string]int), completionTimers: make(map[string]*time.Timer), tasksEvaluator: evaluator, - minTasksCooldownSeconds: DefaultMinPeriodicTasksCooldownSeconds, + minTasksCooldownSeconds: DefaultMinLoopTasksCooldownSeconds, tasksQuiescenceWindow: tasksDefaultQuiescenceWindow, tasksRebaseTimers: make(map[string]*time.Timer), tasksNoProgressCount: make(map[string]int), @@ -234,72 +234,72 @@ func NewPeriodicRunner(store *session.Store, sm *conversation.SessionManager, lo } // SetPollInterval sets the polling interval. Must be called before Start(). -func (r *PeriodicRunner) SetPollInterval(interval time.Duration) { +func (r *LoopRunner) SetPollInterval(interval time.Duration) { r.pollInterval = interval } // SetStartupDelay sets the delay before the first poll on startup. // This gives interactive sessions time to resume first via WebSocket connections. // Must be called before Start(). -func (r *PeriodicRunner) SetStartupDelay(d time.Duration) { +func (r *LoopRunner) SetStartupDelay(d time.Duration) { r.startupDelay = d } // SetResumeStagger sets the stagger delay between consecutive session resumes within a poll. // When non-zero, the runner waits this long between each resume to prevent thundering herd. -func (r *PeriodicRunner) SetResumeStagger(d time.Duration) { +func (r *LoopRunner) SetResumeStagger(d time.Duration) { r.resumeStagger = d } -// SetOnPeriodicStarted sets the callback for when a periodic prompt is delivered. -func (r *PeriodicRunner) SetOnPeriodicStarted(callback PeriodicStartedCallback) { - r.onPeriodicStarted = callback +// SetOnLoopStarted sets the callback for when a loop prompt is delivered. +func (r *LoopRunner) SetOnLoopStarted(callback LoopStartedCallback) { + r.onLoopStarted = callback } // SetAutoArchiveAfter configures the runner to automatically archive sessions // that have been inactive for longer than the given duration. // A duration of 0 disables auto-archiving. -func (r *PeriodicRunner) SetAutoArchiveAfter(d time.Duration) { +func (r *LoopRunner) SetAutoArchiveAfter(d time.Duration) { r.mu.Lock() defer r.mu.Unlock() r.autoArchiveAfter = d } // SetOnAutoArchive sets the callback for when a session is auto-archived. -func (r *PeriodicRunner) SetOnAutoArchive(callback AutoArchiveCallback) { +func (r *LoopRunner) SetOnAutoArchive(callback AutoArchiveCallback) { r.onAutoArchive = callback } -// SetOnPeriodicAutoStopped sets the callback for when a periodic conversation is auto-stopped after reaching max iterations. -func (r *PeriodicRunner) SetOnPeriodicAutoStopped(callback PeriodicAutoStoppedCallback) { - r.onPeriodicAutoStopped = callback +// SetOnLoopAutoStopped sets the callback for when a loop conversation is auto-stopped after reaching max iterations. +func (r *LoopRunner) SetOnLoopAutoStopped(callback LoopAutoStoppedCallback) { + r.onLoopAutoStopped = callback } -// SetOnPeriodicUpdated sets the callback for when a periodic conversation's schedule advances after a delivery. -func (r *PeriodicRunner) SetOnPeriodicUpdated(callback PeriodicUpdatedCallback) { - r.onPeriodicUpdated = callback +// SetOnLoopUpdated sets the callback for when a loop conversation's schedule advances after a delivery. +func (r *LoopRunner) SetOnLoopUpdated(callback LoopUpdatedCallback) { + r.onLoopUpdated = callback } // SetArchiveRetentionPeriod sets the retention period for archived session cleanup. // When set, archived sessions older than this period are permanently deleted during each poll. -// Pass an empty string to disable periodic cleanup. -func (r *PeriodicRunner) SetArchiveRetentionPeriod(period string) { +// Pass an empty string to disable loop cleanup. +func (r *LoopRunner) SetArchiveRetentionPeriod(period string) { r.mu.Lock() defer r.mu.Unlock() r.archiveRetentionPeriod = period } -// SetMaxPeriodicIterations sets the user-configured default cap on scheduled -// periodic runs. 0 means unlimited (still bounded by GlobalMaxPeriodicIterations). -func (r *PeriodicRunner) SetMaxPeriodicIterations(n int) { +// SetMaxLoopIterations sets the user-configured default cap on scheduled +// loop runs. 0 means unlimited (still bounded by GlobalMaxLoopIterations). +func (r *LoopRunner) SetMaxLoopIterations(n int) { r.mu.Lock() defer r.mu.Unlock() - r.maxPeriodicIterations = n + r.maxLoopIterations = n } -// SetMinPeriodicCompletionDelaySeconds sets the global floor for the on-completion -// periodic trigger's delay. Values < 0 are clamped to 0. -func (r *PeriodicRunner) SetMinPeriodicCompletionDelaySeconds(n int) { +// SetMinLoopCompletionDelaySeconds sets the global floor for the on-completion +// loop trigger's delay. Values < 0 are clamped to 0. +func (r *LoopRunner) SetMinLoopCompletionDelaySeconds(n int) { if n < 0 { n = 0 } @@ -308,22 +308,22 @@ func (r *PeriodicRunner) SetMinPeriodicCompletionDelaySeconds(n int) { r.minCompletionDelaySeconds = n } -// MinPeriodicCompletionDelaySeconds returns the current floor for the on-completion -// periodic trigger's delay in seconds. -func (r *PeriodicRunner) MinPeriodicCompletionDelaySeconds() int { +// MinLoopCompletionDelaySeconds returns the current floor for the on-completion +// loop trigger's delay in seconds. +func (r *LoopRunner) MinLoopCompletionDelaySeconds() int { r.mu.Lock() defer r.mu.Unlock() return r.minCompletionDelaySeconds } // SetPromptResolver sets the function used to resolve prompt names to their text at execution time. -func (r *PeriodicRunner) SetPromptResolver(resolver conversation.PromptResolver) { +func (r *LoopRunner) SetPromptResolver(resolver conversation.PromptResolver) { r.promptResolver = resolver } -// Start begins the periodic polling loop in a background goroutine. +// Start begins the loop polling loop in a background goroutine. // It returns immediately. Call Stop() to stop the runner. -func (r *PeriodicRunner) Start() { +func (r *LoopRunner) Start() { r.mu.Lock() defer r.mu.Unlock() @@ -338,12 +338,12 @@ func (r *PeriodicRunner) Start() { go r.pollLoop() if r.logger != nil { - r.logger.Debug("Periodic runner started", "poll_interval", r.pollInterval) + r.logger.Debug("Loop runner started", "poll_interval", r.pollInterval) } } -// Stop gracefully stops the periodic runner and waits for it to finish. -func (r *PeriodicRunner) Stop() { +// Stop gracefully stops the loop runner and waits for it to finish. +func (r *LoopRunner) Stop() { r.mu.Lock() if !r.running { r.mu.Unlock() @@ -374,25 +374,25 @@ func (r *PeriodicRunner) Stop() { <-doneCh if r.logger != nil { - r.logger.Debug("Periodic runner stopped") + r.logger.Debug("Loop runner stopped") } } // IsRunning returns true if the runner is currently active. -func (r *PeriodicRunner) IsRunning() bool { +func (r *LoopRunner) IsRunning() bool { r.mu.Lock() defer r.mu.Unlock() return r.running } -// TriggerNow immediately delivers the periodic prompt for a session, +// TriggerNow immediately delivers the loop prompt for a session, // bypassing the normal schedule check. This is used for manual "run now" requests. // resetTimer controls whether RecordSent() is called after the prompt completes: // - true → the countdown resets from now (same as a normal scheduled run) // - false → the existing next-run schedule is preserved unchanged // -// Returns an error if the delivery fails or the session is not configured for periodic prompts. -func (r *PeriodicRunner) TriggerNow(sessionID string, resetTimer bool) error { +// Returns an error if the delivery fails or the session is not configured for loop prompts. +func (r *LoopRunner) TriggerNow(sessionID string, resetTimer bool) error { if r.store == nil { return ErrSessionStoreNotAvailable } @@ -403,16 +403,16 @@ func (r *PeriodicRunner) TriggerNow(sessionID string, resetTimer bool) error { return err } - // Get periodic config for this session - periodicStore := r.store.Periodic(sessionID) - periodic, err := periodicStore.Get() + // Get loop config for this session + loopStore := r.store.Loop(sessionID) + loop, err := loopStore.Get() if err != nil { return err } // Check if enabled - if !periodic.Enabled { - return ErrPeriodicNotEnabled + if !loop.Enabled { + return ErrLoopNotEnabled } // Check if session manager is available @@ -423,9 +423,9 @@ func (r *PeriodicRunner) TriggerNow(sessionID string, resetTimer bool) error { // Check if session is running (has an active ACP connection) bs := r.sessionManager.GetSession(sessionID) if bs == nil { - // Session not running - auto-resume it to deliver the periodic prompt + // Session not running - auto-resume it to deliver the loop prompt if r.logger != nil { - r.logger.Debug("Auto-resuming session for immediate periodic delivery", + r.logger.Debug("Auto-resuming session for immediate loop delivery", "session_id", sessionID, "session_name", meta.Name) } @@ -436,7 +436,7 @@ func (r *PeriodicRunner) TriggerNow(sessionID string, resetTimer bool) error { } if r.logger != nil { - r.logger.Info("Session auto-resumed for immediate periodic delivery", + r.logger.Info("Session auto-resumed for immediate loop delivery", "session_id", sessionID, "session_name", meta.Name) } @@ -448,14 +448,14 @@ func (r *PeriodicRunner) TriggerNow(sessionID string, resetTimer bool) error { } if r.logger != nil { - r.logger.Info("Triggering immediate periodic delivery", + r.logger.Info("Triggering immediate loop delivery", "session_id", sessionID, "session_name", meta.Name, - "prompt_preview", truncatePrompt(periodic.Prompt, 100)) + "prompt_preview", truncatePrompt(loop.Prompt, 100)) } // Deliver the prompt - return r.deliverPrompt(bs, meta.Name, periodic, periodicStore, resetTimer, true) + return r.deliverPrompt(bs, meta.Name, loop, loopStore, resetTimer, true) } // OnConversationIdle is invoked when a session's agent has stopped and the session @@ -463,7 +463,7 @@ func (r *PeriodicRunner) TriggerNow(sessionID string, resetTimer bool) error { // trigger it arms a one-shot timer that delivers the next run after the configured // delay (clamped to the global minimum floor). For any other configuration it cancels // a possibly-stale timer and returns. -func (r *PeriodicRunner) OnConversationIdle(sessionID string) { +func (r *LoopRunner) OnConversationIdle(sessionID string) { if r.store == nil { return } @@ -475,9 +475,9 @@ func (r *PeriodicRunner) OnConversationIdle(sessionID string) { return } - periodicStore := r.store.Periodic(sessionID) - periodic, err := periodicStore.Get() - if err != nil || periodic == nil || !periodic.Enabled || !periodic.IsOnCompletion() { + loopStore := r.store.Loop(sessionID) + loop, err := loopStore.Get() + if err != nil || loop == nil || !loop.Enabled || !loop.IsOnCompletion() { // Not an active onCompletion loop — drop any timer left over from a prior config. r.cancelCompletionTimer(sessionID) return @@ -487,7 +487,7 @@ func (r *PeriodicRunner) OnConversationIdle(sessionID string) { floor := r.minCompletionDelaySeconds r.mu.Unlock() - delaySeconds := periodic.DelaySeconds + delaySeconds := loop.DelaySeconds if delaySeconds < floor { delaySeconds = floor } @@ -496,7 +496,7 @@ func (r *PeriodicRunner) OnConversationIdle(sessionID string) { r.armCompletionTimer(sessionID, delay) if r.logger != nil { - r.logger.Debug("Armed on-completion periodic timer", + r.logger.Debug("Armed on-completion loop timer", "session_id", sessionID, "delay_seconds", delaySeconds) } @@ -504,7 +504,7 @@ func (r *PeriodicRunner) OnConversationIdle(sessionID string) { // armCompletionTimer schedules fireOnCompletion after delay, replacing (and stopping) // any timer already pending for the session so only one firing is queued. -func (r *PeriodicRunner) armCompletionTimer(sessionID string, delay time.Duration) { +func (r *LoopRunner) armCompletionTimer(sessionID string, delay time.Duration) { r.completionTimersMu.Lock() defer r.completionTimersMu.Unlock() if existing, ok := r.completionTimers[sessionID]; ok { @@ -515,49 +515,49 @@ func (r *PeriodicRunner) armCompletionTimer(sessionID string, delay time.Duratio }) } -// StopPeriodicForArchive authoritatively stops a conversation's periodic loop as part +// StopLoopForArchive authoritatively stops a conversation's loop as part // of archiving it (manual or automatic). It cancels any pending on-completion timer, -// disables the periodic config with the given reason when currently enabled, and -// broadcasts the updated periodic state so the UI no longer shows an enabled loop. -// It is a no-op for sessions without a periodic config and is idempotent (an +// disables the loop config with the given reason when currently enabled, and +// broadcasts the updated loop state so the UI no longer shows an enabled loop. +// It is a no-op for sessions without a loop config and is idempotent (an // already-disabled config keeps its existing StoppedReason). -func (r *PeriodicRunner) StopPeriodicForArchive(sessionID string, reason session.StoppedReason) { +func (r *LoopRunner) StopLoopForArchive(sessionID string, reason session.StoppedReason) { if r.store == nil { return } // Cancel any pending on-completion timer regardless of config state. r.cancelCompletionTimer(sessionID) - periodicStore := r.store.Periodic(sessionID) - periodic, err := periodicStore.Get() + loopStore := r.store.Loop(sessionID) + loop, err := loopStore.Get() if err != nil { - // No periodic config (ErrPeriodicNotFound) or unreadable — nothing to disable. + // No loop config (ErrLoopNotFound) or unreadable — nothing to disable. return } - if !periodic.Enabled { + if !loop.Enabled { // Already stopped/paused — leave the existing reason intact. return } - if err := periodicStore.MarkStopped(reason); err != nil { + if err := loopStore.MarkStopped(reason); err != nil { if r.logger != nil { - r.logger.Warn("Failed to stop periodic config on archive", + r.logger.Warn("Failed to stop loop config on archive", "session_id", sessionID, "error", err) } return } - if r.onPeriodicAutoStopped != nil { - if final, gErr := periodicStore.Get(); gErr == nil { - r.onPeriodicAutoStopped(sessionID, final) + if r.onLoopAutoStopped != nil { + if final, gErr := loopStore.Get(); gErr == nil { + r.onLoopAutoStopped(sessionID, final) } } if r.logger != nil { - r.logger.Info("Stopped periodic loop on archive", + r.logger.Info("Stopped loop on archive", "session_id", sessionID, "reason", reason) } } // cancelCompletionTimer stops and removes any pending on-completion timer for the session. -func (r *PeriodicRunner) cancelCompletionTimer(sessionID string) { +func (r *LoopRunner) cancelCompletionTimer(sessionID string) { r.completionTimersMu.Lock() defer r.completionTimersMu.Unlock() if existing, ok := r.completionTimers[sessionID]; ok { @@ -566,7 +566,7 @@ func (r *PeriodicRunner) cancelCompletionTimer(sessionID string) { } } -// BootstrapOnCompletion delivers the very first run of an onCompletion periodic +// BootstrapOnCompletion delivers the very first run of an onCompletion loop // conversation that has never executed (IterationCount == 0 && LastSentAt == nil). // // Why this is needed — the bootstrap deadlock: @@ -588,22 +588,22 @@ func (r *PeriodicRunner) cancelCompletionTimer(sessionID string) { // - TriggerNow's internal IsPrompting() check rejects a racing call with ErrSessionBusy // once PromptWithMeta sets isPrompting synchronously before returning. // -// Called from checkSession (crash-safe on poll-loop restart), handleSetPeriodic, -// handlePatchPeriodic (HTTP), and handleConversationStart/handleConversationUpdate (MCP). +// Called from checkSession (crash-safe on poll-loop restart), handleSetLoop, +// handlePatchLoop (HTTP), and handleConversationStart/handleConversationUpdate (MCP). // Best-effort — errors are logged but not propagated. -func (r *PeriodicRunner) BootstrapOnCompletion(sessionID string) { +func (r *LoopRunner) BootstrapOnCompletion(sessionID string) { if r.store == nil { return } - periodicStore := r.store.Periodic(sessionID) - periodic, err := periodicStore.Get() - if err != nil || periodic == nil || !periodic.Enabled || !periodic.IsOnCompletion() { + loopStore := r.store.Loop(sessionID) + loop, err := loopStore.Get() + if err != nil || loop == nil || !loop.Enabled || !loop.IsOnCompletion() { return } // Only bootstrap the very first run. - if periodic.IterationCount != 0 || periodic.LastSentAt != nil { + if loop.IterationCount != 0 || loop.LastSentAt != nil { return } @@ -632,7 +632,7 @@ func (r *PeriodicRunner) BootstrapOnCompletion(sessionID string) { } // recoverStalledOnCompletion is the poll-loop self-healing fallback for an -// onCompletion periodic loop that missed its end-of-turn re-arm and would +// onCompletion loop that missed its end-of-turn re-arm and would // otherwise stall forever (see mitto-5dn). // // The next onCompletion run is normally armed only by an in-memory timer set @@ -657,13 +657,13 @@ func (r *PeriodicRunner) BootstrapOnCompletion(sessionID string) { // and arms the timer with the floor-clamped delay. The downstream // fireOnCompletion auto-resumes a non-running session and enforces caps, so this // also self-heals after a process restart (in-memory timers do not survive one). -func (r *PeriodicRunner) recoverStalledOnCompletion(meta session.Metadata, periodic *session.PeriodicPrompt) { - if periodic == nil { +func (r *LoopRunner) recoverStalledOnCompletion(meta session.Metadata, loop *session.LoopPrompt) { + if loop == nil { return } // Fresh loops are bootstrapped elsewhere; only recover loops that have run. - if periodic.IterationCount == 0 && periodic.LastSentAt == nil { + if loop.IterationCount == 0 && loop.LastSentAt == nil { return } @@ -678,10 +678,10 @@ func (r *PeriodicRunner) recoverStalledOnCompletion(meta session.Metadata, perio // If the wall-clock cap is reached, auto-stop consistently with the schedule path // (sets Enabled=false, StoppedReason=maxDuration, broadcasts). Without this the // onCompletion loop stays Enabled=true but dormant, inconsistent with schedule loops. - if periodic.ReachedMaxDuration(time.Now()) { + if loop.ReachedMaxDuration(time.Now()) { if r.store != nil { - periodicStore := r.store.Periodic(meta.SessionID) - r.autoStopIfMaxDurationReached(meta.SessionID, periodic, periodicStore, time.Now()) + loopStore := r.store.Loop(meta.SessionID) + r.autoStopIfMaxDurationReached(meta.SessionID, loop, loopStore, time.Now()) } return } @@ -696,20 +696,20 @@ func (r *PeriodicRunner) recoverStalledOnCompletion(meta session.Metadata, perio } if r.logger != nil { - r.logger.Info("Re-arming stalled on-completion periodic loop (missed end-of-turn re-arm)", + r.logger.Info("Re-arming stalled on-completion loop (missed end-of-turn re-arm)", "session_id", meta.SessionID, - "iteration_count", periodic.IterationCount) + "iteration_count", loop.IterationCount) } // Re-read config and arm the timer with the floor-clamped delay. r.OnConversationIdle(meta.SessionID) } -// fireOnCompletion delivers the next onCompletion periodic run. It re-validates the -// session and periodic configuration (the conversation may have been archived, disabled, +// fireOnCompletion delivers the next onCompletion loop run. It re-validates the +// session and loop configuration (the conversation may have been archived, disabled, // or reconfigured during the delay) and then delivers via TriggerNow. A busy session is // skipped — the next idle transition re-arms the timer. -func (r *PeriodicRunner) fireOnCompletion(sessionID string) { +func (r *LoopRunner) fireOnCompletion(sessionID string) { // Drop our timer handle; it has fired. r.completionTimersMu.Lock() delete(r.completionTimers, sessionID) @@ -724,14 +724,14 @@ func (r *PeriodicRunner) fireOnCompletion(sessionID string) { return } - periodicStore := r.store.Periodic(sessionID) - periodic, err := periodicStore.Get() - if err != nil || periodic == nil || !periodic.Enabled || !periodic.IsOnCompletion() { + loopStore := r.store.Loop(sessionID) + loop, err := loopStore.Get() + if err != nil || loop == nil || !loop.Enabled || !loop.IsOnCompletion() { return } // Auto-stop if the wall-clock maxDuration cap is reached before delivering. - if r.autoStopIfMaxDurationReached(sessionID, periodic, periodicStore, time.Now()) { + if r.autoStopIfMaxDurationReached(sessionID, loop, loopStore, time.Now()) { return } @@ -743,10 +743,10 @@ func (r *PeriodicRunner) fireOnCompletion(sessionID string) { return } if errors.Is(err, ErrSessionBusy) { - r.logger.Debug("On-completion periodic firing skipped, session busy", + r.logger.Debug("On-completion loop firing skipped, session busy", "session_id", sessionID) } else { - r.logger.Warn("On-completion periodic firing failed", + r.logger.Warn("On-completion loop firing failed", "session_id", sessionID, "error", err) } @@ -754,14 +754,14 @@ func (r *PeriodicRunner) fireOnCompletion(sessionID string) { } // pollLoop is the main polling loop that checks for due prompts. -func (r *PeriodicRunner) pollLoop() { +func (r *LoopRunner) pollLoop() { defer close(r.doneCh) // Wait before first poll to let interactive sessions resume first via WebSocket. - // Periodic sessions can afford to wait since their prompts are scheduled. + // Loop sessions can afford to wait since their prompts are scheduled. if r.startupDelay > 0 { if r.logger != nil { - r.logger.Info("Deferring periodic poll to let interactive sessions resume first", + r.logger.Info("Deferring loop poll to let interactive sessions resume first", "startup_delay", r.startupDelay) } select { @@ -791,7 +791,7 @@ func (r *PeriodicRunner) pollLoop() { // auto-archiving inactive sessions, and cleaning up old archived sessions. // Returns counts of delivered, skipped, and errored prompts. // This method is exported for testing purposes. -func (r *PeriodicRunner) RunOnce() (delivered, skipped, errored int) { +func (r *LoopRunner) RunOnce() (delivered, skipped, errored int) { if r.store == nil { return 0, 0, 0 } @@ -800,15 +800,15 @@ func (r *PeriodicRunner) RunOnce() (delivered, skipped, errored int) { sessions, err := r.store.List() if err != nil { if r.logger != nil { - r.logger.Error("Failed to list sessions for periodic check", "error", err) + r.logger.Error("Failed to list sessions for loop check", "error", err) } return 0, 0, 1 } now := time.Now().UTC() - // Sort sessions so most-overdue periodic prompts are processed first. - // Non-periodic sessions are kept in original order (sorted to the end). + // Sort sessions so most-overdue loop prompts are processed first. + // Non-loop sessions are kept in original order (sorted to the end). sort.SliceStable(sessions, func(i, j int) bool { pi := r.getNextScheduledAt(sessions[i]) pj := r.getNextScheduledAt(sessions[j]) @@ -816,27 +816,27 @@ func (r *PeriodicRunner) RunOnce() (delivered, skipped, errored int) { return false } if pi == nil { - return false // non-periodic sorts after periodic + return false // non-loop sorts after loop } if pj == nil { - return true // periodic sorts before non-periodic + return true // loop sorts before non-loop } return pi.Before(*pj) // most overdue (earliest NextScheduledAt) first }) - // Collect sessions that have due periodic prompts and need resuming. + // Collect sessions that have due loop prompts and need resuming. // Process them with stagger delay to prevent thundering herd. var lastResumeTime time.Time for _, meta := range sessions { - // Apply stagger delay between resume-triggering periodic checks. + // Apply stagger delay between resume-triggering loop checks. // Only stagger when we actually resumed a session in a previous iteration. if r.resumeStagger > 0 && !lastResumeTime.IsZero() { elapsed := time.Since(lastResumeTime) if elapsed < r.resumeStagger { wait := r.resumeStagger - elapsed if r.logger != nil { - r.logger.Debug("Staggering periodic session resume", + r.logger.Debug("Staggering loop session resume", "session_id", meta.SessionID, "wait_ms", wait.Milliseconds()) } @@ -865,7 +865,7 @@ func (r *PeriodicRunner) RunOnce() (delivered, skipped, errored int) { r.checkArchiveCleanup() if r.logger != nil { - r.logger.Debug("Periodic poll completed", + r.logger.Debug("Loop poll completed", "delivered", delivered, "skipped", skipped, "errored", errored) @@ -876,18 +876,18 @@ func (r *PeriodicRunner) RunOnce() (delivered, skipped, errored int) { // sessionNeedsResume returns true if checkSession would trigger a ResumeSession call. // Used to apply stagger delays between consecutive resume attempts. -func (r *PeriodicRunner) sessionNeedsResume(meta session.Metadata, now time.Time) bool { +func (r *LoopRunner) sessionNeedsResume(meta session.Metadata, now time.Time) bool { if meta.Archived { return false } - periodicStore := r.store.Periodic(meta.SessionID) - periodic, err := periodicStore.Get() - if err != nil || !periodic.Enabled { + loopStore := r.store.Loop(meta.SessionID) + loop, err := loopStore.Get() + if err != nil || !loop.Enabled { return false } - if periodic.NextScheduledAt == nil || periodic.NextScheduledAt.After(now) { + if loop.NextScheduledAt == nil || loop.NextScheduledAt.After(now) { return false } @@ -896,22 +896,22 @@ func (r *PeriodicRunner) sessionNeedsResume(meta session.Metadata, now time.Time return bs == nil } -// getNextScheduledAt returns the NextScheduledAt for a session's periodic config, or nil if not periodic/not enabled. -func (r *PeriodicRunner) getNextScheduledAt(meta session.Metadata) *time.Time { +// getNextScheduledAt returns the NextScheduledAt for a session's loop config, or nil if not loop/not enabled. +func (r *LoopRunner) getNextScheduledAt(meta session.Metadata) *time.Time { if meta.Archived { return nil } - periodicStore := r.store.Periodic(meta.SessionID) - periodic, err := periodicStore.Get() - if err != nil || !periodic.Enabled { + loopStore := r.store.Loop(meta.SessionID) + loop, err := loopStore.Get() + if err != nil || !loop.Enabled { return nil } - return periodic.NextScheduledAt + return loop.NextScheduledAt } // checkScheduledQueues checks all active sessions for scheduled queue messages // that are now due for delivery, and triggers processing. -func (r *PeriodicRunner) checkScheduledQueues(sessions []session.Metadata) { +func (r *LoopRunner) checkScheduledQueues(sessions []session.Metadata) { if r.store == nil || r.sessionManager == nil { return } @@ -941,26 +941,26 @@ func (r *PeriodicRunner) checkScheduledQueues(sessions []session.Metadata) { } } -// checkSession checks a single session for due periodic prompts. +// checkSession checks a single session for due loop prompts. // Returns (1, 0, 0) if delivered, (0, 1, 0) if skipped, (0, 0, 1) if error. -func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (delivered, skipped, errored int) { +func (r *LoopRunner) checkSession(meta session.Metadata, now time.Time) (delivered, skipped, errored int) { sessionID := meta.SessionID - // Skip archived sessions - periodic prompts are inactive for archived sessions + // Skip archived sessions - loop prompts are inactive for archived sessions if meta.Archived { return 0, 0, 0 } - // Get periodic config for this session - periodicStore := r.store.Periodic(sessionID) - periodic, err := periodicStore.Get() + // Get loop config for this session + loopStore := r.store.Loop(sessionID) + loop, err := loopStore.Get() if err != nil { - if err == session.ErrPeriodicNotFound { - // No periodic config - this is normal, not an error + if err == session.ErrLoopNotFound { + // No loop config - this is normal, not an error return 0, 0, 0 } if r.logger != nil { - r.logger.Error("Failed to read periodic config", + r.logger.Error("Failed to read loop config", "session_id", sessionID, "error", err) } @@ -968,19 +968,19 @@ func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (del } // Skip if disabled - if !periodic.Enabled { + if !loop.Enabled { return 0, 0, 0 } // onCompletion configs never have a NextScheduledAt — the schedule loop cannot // deliver them. Bootstrap the very first run here so that a crash or restart // before any delivery still kicks off the loop. No-op if already run or in-flight. - if periodic.IsOnCompletion() { + if loop.IsOnCompletion() { r.BootstrapOnCompletion(sessionID) // Self-healing safety net for an already-running loop whose end-of-turn // re-arm was missed (e.g. around an ACP resume or a heavy children-wait // turn that did not register as a clean idle transition). See mitto-5dn. - r.recoverStalledOnCompletion(meta, periodic) + r.recoverStalledOnCompletion(meta, loop) return 0, 0, 0 } @@ -988,53 +988,53 @@ func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (del // a NextScheduledAt either. Bootstrap the baseline here so a crash/restart // before the baseline was ever captured does not cause a spurious first fire // the next time beads change (mitto-oja.2). - if periodic.IsOnTasks() { + if loop.IsOnTasks() { r.BootstrapTasksBaseline(sessionID) return 0, 0, 0 } // Check if due - if periodic.NextScheduledAt == nil || periodic.NextScheduledAt.After(now) { + if loop.NextScheduledAt == nil || loop.NextScheduledAt.After(now) { return 0, 0, 0 } // Auto-stop if the wall-clock maxDuration cap is reached before delivering. - if r.autoStopIfMaxDurationReached(sessionID, periodic, periodicStore, now) { + if r.autoStopIfMaxDurationReached(sessionID, loop, loopStore, now) { return 0, 0, 0 } // Prompt is due - calculate how overdue it is - scheduledAt := *periodic.NextScheduledAt + scheduledAt := *loop.NextScheduledAt overdueBy := now.Sub(scheduledAt) // Calculate how many runs were missed (for logging purposes) missedRuns := 0 - if overdueBy > 0 && periodic.Frequency.Duration() > 0 { + if overdueBy > 0 && loop.Frequency.Duration() > 0 { // Number of full intervals that passed since scheduled time - missedRuns = int(overdueBy / periodic.Frequency.Duration()) + missedRuns = int(overdueBy / loop.Frequency.Duration()) } // Log the catch-up situation if r.logger != nil { if missedRuns > 0 { - r.logger.Debug("Periodic prompt overdue - running catch-up (skipping missed runs)", + r.logger.Debug("Loop prompt overdue - running catch-up (skipping missed runs)", "session_id", sessionID, "scheduled_at", scheduledAt, "overdue_by", overdueBy.Round(time.Second), "missed_runs", missedRuns, - "prompt_preview", truncatePrompt(periodic.Prompt, 50)) + "prompt_preview", truncatePrompt(loop.Prompt, 50)) } else { - r.logger.Debug("Periodic prompt is due", + r.logger.Debug("Loop prompt is due", "session_id", sessionID, "scheduled_at", scheduledAt, - "prompt_preview", truncatePrompt(periodic.Prompt, 50)) + "prompt_preview", truncatePrompt(loop.Prompt, 50)) } } // Check if session manager is available if r.sessionManager == nil { if r.logger != nil { - r.logger.Debug("Skipping periodic prompt - no session manager", + r.logger.Debug("Skipping loop prompt - no session manager", "session_id", sessionID) } return 0, 1, 0 @@ -1043,9 +1043,9 @@ func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (del // Check if session is running (has an active ACP connection) bs := r.sessionManager.GetSession(sessionID) if bs == nil { - // Session not running - auto-resume it to deliver the periodic prompt + // Session not running - auto-resume it to deliver the loop prompt if r.logger != nil { - r.logger.Debug("Auto-resuming session for periodic prompt", + r.logger.Debug("Auto-resuming session for loop prompt", "session_id", sessionID, "session_name", meta.Name) } @@ -1059,16 +1059,16 @@ func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (del r.consecutiveFailuresMu.Unlock() if r.logger != nil { - r.logger.Error("Failed to resume session for periodic prompt", + r.logger.Error("Failed to resume session for loop prompt", "session_id", sessionID, "consecutive_failures", failures, - "max_failures", MaxPeriodicResumeFailures, + "max_failures", MaxLoopResumeFailures, "error", err) } // After too many consecutive failures, archive the session // to stop the retry storm. The user can unarchive it manually. - if failures >= MaxPeriodicResumeFailures { + if failures >= MaxLoopResumeFailures { if r.logger != nil { r.logger.Warn("Archiving session after repeated ACP resume failures", "session_id", sessionID, @@ -1080,10 +1080,10 @@ func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (del // Persist the stopped reason before archiving so it survives even though // the session leaves the active view. Failures are non-fatal — archiving proceeds. - periodicStore := r.store.Periodic(sessionID) - if markErr := periodicStore.MarkStopped(session.StoppedReasonResumeFailures); markErr != nil { + loopStore := r.store.Loop(sessionID) + if markErr := loopStore.MarkStopped(session.StoppedReasonResumeFailures); markErr != nil { if r.logger != nil { - r.logger.Warn("Failed to mark periodic stopped reason before archive", + r.logger.Warn("Failed to mark loop stopped reason before archive", "session_id", sessionID, "error", markErr) } @@ -1110,10 +1110,10 @@ func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (del // Delete child sessions (async, same as manual archive) go r.sessionManager.DeleteChildSessions(sessionID) - // Broadcast the periodic disable so the UI badge reflects reality (mitto-efnb). - if r.onPeriodicAutoStopped != nil { - if final, gErr := periodicStore.Get(); gErr == nil { - r.onPeriodicAutoStopped(sessionID, final) + // Broadcast the loop disable so the UI badge reflects reality (mitto-efnb). + if r.onLoopAutoStopped != nil { + if final, gErr := loopStore.Get(); gErr == nil { + r.onLoopAutoStopped(sessionID, final) } } @@ -1138,7 +1138,7 @@ func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (del r.consecutiveFailuresMu.Unlock() if r.logger != nil { - r.logger.Info("Session auto-resumed for periodic prompt", + r.logger.Info("Session auto-resumed for loop prompt", "session_id", sessionID, "session_name", meta.Name) } @@ -1147,19 +1147,19 @@ func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (del // Check if session is currently processing a prompt if bs.IsPrompting() { if r.logger != nil { - r.logger.Debug("Skipping periodic prompt - session is busy", + r.logger.Debug("Skipping loop prompt - session is busy", "session_id", sessionID) } return 0, 1, 0 } // Deliver the prompt — normal scheduled runs always reset the timer. - if err := r.deliverPrompt(bs, meta.Name, periodic, periodicStore, true, false); err != nil { + if err := r.deliverPrompt(bs, meta.Name, loop, loopStore, true, false); err != nil { if errors.Is(err, ErrPromptResolveFailed) { - r.handlePromptResolveFailure(sessionID, meta.Name, periodic, periodicStore, err) + r.handlePromptResolveFailure(sessionID, meta.Name, loop, loopStore, err) } else { if r.logger != nil { - r.logger.Error("Failed to deliver periodic prompt", + r.logger.Error("Failed to deliver loop prompt", "session_id", sessionID, "error", err) } @@ -1175,51 +1175,51 @@ func (r *PeriodicRunner) checkSession(meta session.Metadata, now time.Time) (del return 1, 0, 0 } -// autoStopIfMaxDurationReached checks whether the periodic conversation has exceeded +// autoStopIfMaxDurationReached checks whether the loop conversation has exceeded // its wall-clock maxDuration cap (elapsed time since FirstRunAt). When the cap is -// reached it disables the periodic config (without archiving) and broadcasts the -// auto-stop via onPeriodicAutoStopped, mirroring the max-iterations auto-stop. It +// reached it disables the loop config (without archiving) and broadcasts the +// auto-stop via onLoopAutoStopped, mirroring the max-iterations auto-stop. It // returns true to signal the caller to skip delivery. Returns false when the cap is // unlimited, not yet anchored (FirstRunAt nil), or not reached — delivery may proceed. -func (r *PeriodicRunner) autoStopIfMaxDurationReached(sessionID string, periodic *session.PeriodicPrompt, periodicStore *session.PeriodicStore, now time.Time) bool { - if periodic == nil || !periodic.ReachedMaxDuration(now) { +func (r *LoopRunner) autoStopIfMaxDurationReached(sessionID string, loop *session.LoopPrompt, loopStore *session.LoopStore, now time.Time) bool { + if loop == nil || !loop.ReachedMaxDuration(now) { return false } if r.logger != nil { var elapsed time.Duration - if periodic.FirstRunAt != nil { - elapsed = now.Sub(*periodic.FirstRunAt).Round(time.Second) + if loop.FirstRunAt != nil { + elapsed = now.Sub(*loop.FirstRunAt).Round(time.Second) } - r.logger.Info("Periodic conversation reached max duration, auto-stopping", + r.logger.Info("Loop conversation reached max duration, auto-stopping", "session_id", sessionID, - "max_duration_seconds", periodic.MaxDurationSeconds, + "max_duration_seconds", loop.MaxDurationSeconds, "elapsed", elapsed) } - if err := periodicStore.MarkStopped(session.StoppedReasonMaxDuration); err != nil { + if err := loopStore.MarkStopped(session.StoppedReasonMaxDuration); err != nil { if r.logger != nil { - r.logger.Warn("Failed to disable periodic after reaching max duration", + r.logger.Warn("Failed to disable loop after reaching max duration", "session_id", sessionID, "error", err) } return true } - if r.onPeriodicAutoStopped != nil { + if r.onLoopAutoStopped != nil { // Re-read so the broadcast reflects Enabled=false / NextScheduledAt=nil. - if final, err := periodicStore.Get(); err == nil { - r.onPeriodicAutoStopped(sessionID, final) + if final, err := loopStore.Get(); err == nil { + r.onLoopAutoStopped(sessionID, final) } } return true } -// handlePromptResolveFailure handles a periodic prompt whose name no longer resolves. +// handlePromptResolveFailure handles a loop prompt whose name no longer resolves. // It logs the first failure at WARN and suppresses subsequent identical failures (to // avoid one ERROR per tick), and after MaxPromptResolveFailures consecutive failures it -// auto-pauses (disables) the periodic config and broadcasts the change, mirroring the -// MaxPeriodicResumeFailures auto-archive safety. -func (r *PeriodicRunner) handlePromptResolveFailure(sessionID, sessionName string, periodic *session.PeriodicPrompt, periodicStore *session.PeriodicStore, err error) { +// auto-pauses (disables) the loop config and broadcasts the change, mirroring the +// MaxLoopResumeFailures auto-archive safety. +func (r *LoopRunner) handlePromptResolveFailure(sessionID, sessionName string, loop *session.LoopPrompt, loopStore *session.LoopStore, err error) { r.promptResolveFailuresMu.Lock() r.promptResolveFailures[sessionID]++ failures := r.promptResolveFailures[sessionID] @@ -1227,16 +1227,16 @@ func (r *PeriodicRunner) handlePromptResolveFailure(sessionID, sessionName strin if r.logger != nil { if failures == 1 { - r.logger.Warn("Periodic prompt could not be resolved; will auto-pause after repeated failures", + r.logger.Warn("Loop prompt could not be resolved; will auto-pause after repeated failures", "session_id", sessionID, - "prompt_name", periodic.PromptName, + "prompt_name", loop.PromptName, "consecutive_failures", failures, "max_failures", MaxPromptResolveFailures, "error", err) } else { - r.logger.Debug("Periodic prompt still unresolved", + r.logger.Debug("Loop prompt still unresolved", "session_id", sessionID, - "prompt_name", periodic.PromptName, + "prompt_name", loop.PromptName, "consecutive_failures", failures) } } @@ -1245,23 +1245,23 @@ func (r *PeriodicRunner) handlePromptResolveFailure(sessionID, sessionName strin return } - if updErr := periodicStore.MarkStopped(session.StoppedReasonPromptUnresolved); updErr != nil { + if updErr := loopStore.MarkStopped(session.StoppedReasonPromptUnresolved); updErr != nil { if r.logger != nil { - r.logger.Warn("Failed to disable periodic after repeated resolve failures", + r.logger.Warn("Failed to disable loop after repeated resolve failures", "session_id", sessionID, "error", updErr) } return } if r.logger != nil { - r.logger.Warn("Auto-paused periodic conversation after repeated prompt resolve failures", + r.logger.Warn("Auto-paused loop conversation after repeated prompt resolve failures", "session_id", sessionID, "session_name", sessionName, - "prompt_name", periodic.PromptName, + "prompt_name", loop.PromptName, "consecutive_failures", failures) } - if r.onPeriodicAutoStopped != nil { - if final, gErr := periodicStore.Get(); gErr == nil { - r.onPeriodicAutoStopped(sessionID, final) + if r.onLoopAutoStopped != nil { + if final, gErr := loopStore.Get(); gErr == nil { + r.onLoopAutoStopped(sessionID, final) } } r.promptResolveFailuresMu.Lock() @@ -1269,35 +1269,35 @@ func (r *PeriodicRunner) handlePromptResolveFailure(sessionID, sessionName strin r.promptResolveFailuresMu.Unlock() } -// deliverPrompt sends the periodic prompt to the session. +// deliverPrompt sends the loop prompt to the session. // resetTimer controls whether RecordSent() is called when the prompt completes: // - true → schedule advances from now (normal behaviour) // - false → schedule is left untouched (manual "run now" without resetting the timer) -func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessionName string, periodic *session.PeriodicPrompt, periodicStore *session.PeriodicStore, resetTimer bool, forced bool) error { +func (r *LoopRunner) deliverPrompt(bs *conversation.BackgroundSession, sessionName string, loop *session.LoopPrompt, loopStore *session.LoopStore, resetTimer bool, forced bool) error { sessionID := bs.GetSessionID() // Resolve prompt text from name if needed - promptText := periodic.Prompt - if periodic.PromptName != "" && r.promptResolver != nil { + promptText := loop.Prompt + if loop.PromptName != "" && r.promptResolver != nil { sessionMeta, err := r.store.GetMetadata(sessionID) if err != nil { return fmt.Errorf("failed to get session metadata for prompt resolution: %w", err) } - resolved, err := r.promptResolver(periodic.PromptName, sessionMeta.WorkingDir) + resolved, err := r.promptResolver(loop.PromptName, sessionMeta.WorkingDir) if err != nil { - return fmt.Errorf("%w: %q: %v", ErrPromptResolveFailed, periodic.PromptName, err) + return fmt.Errorf("%w: %q: %v", ErrPromptResolveFailed, loop.PromptName, err) } promptText = resolved if r.logger != nil { - r.logger.Debug("Resolved periodic prompt name to text", + r.logger.Debug("Resolved loop prompt name to text", "session_id", sessionID, - "prompt_name", periodic.PromptName, + "prompt_name", loop.PromptName, "prompt_preview", truncatePrompt(promptText, 100)) } } if r.logger != nil { - r.logger.Debug("Delivering periodic prompt", + r.logger.Debug("Delivering loop prompt", "session_id", sessionID, "session_name", sessionName, "reset_timer", resetTimer, @@ -1308,20 +1308,20 @@ func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessi // PromptWithMeta is async — it returns nil immediately. Without OnComplete, // RecordSent would advance the schedule even if the prompt later fails // (e.g., ACP process crash). - periodicKind := conversation.PeriodicKindScheduled + loopKind := conversation.LoopKindScheduled if forced { - periodicKind = conversation.PeriodicKindForced + loopKind = conversation.LoopKindForced } meta := conversation.PromptMeta{ - SenderID: "periodic-runner", - PromptID: "", // No client to confirm delivery to - PromptName: periodic.PromptName, // Pass prompt name so UI can render a badge instead of full text - Arguments: periodic.Arguments, // User-supplied values for Go-template .Args placeholders in the resolved text - IsPeriodicForced: forced, - PeriodicKind: periodicKind, - IterationNumber: periodic.IterationCount, - MaxIterations: periodic.MaxIterations, - FreshContext: periodic.FreshContext, + SenderID: "loop-runner", + PromptID: "", // No client to confirm delivery to + PromptName: loop.PromptName, // Pass prompt name so UI can render a badge instead of full text + Arguments: loop.Arguments, // User-supplied values for Go-template .Args placeholders in the resolved text + IsLoopForced: forced, + LoopKind: loopKind, + IterationNumber: loop.IterationCount, + MaxIterations: loop.MaxIterations, + FreshContext: loop.FreshContext, OnComplete: func(err error) { if err != nil { // Scheduled triggers: back off NextScheduledAt so a transient transport @@ -1329,16 +1329,16 @@ func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessi // tick (mitto-qal.2). onCompletion triggers are event-driven (their // NextScheduledAt is nil) and manual "keep schedule" runs (resetTimer=false) // or forced one-shots must not push out the regular schedule. - if resetTimer && !forced && !periodic.IsOnCompletion() { + if resetTimer && !forced && !loop.IsOnCompletion() { r.scheduleBackoffFailuresMu.Lock() r.scheduleBackoffFailures[sessionID]++ failures := r.scheduleBackoffFailures[sessionID] r.scheduleBackoffFailuresMu.Unlock() - delay := periodicScheduleBackoff(failures) - if deferErr := periodicStore.DeferNextSchedule(delay); deferErr != nil { + delay := loopScheduleBackoff(failures) + if deferErr := loopStore.DeferNextSchedule(delay); deferErr != nil { if r.logger != nil { - r.logger.Warn("Periodic prompt failed, backoff could not be applied", + r.logger.Warn("Loop prompt failed, backoff could not be applied", "session_id", sessionID, "session_name", sessionName, "consecutive_failures", failures, @@ -1346,7 +1346,7 @@ func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessi } } else { if r.logger != nil { - r.logger.Warn("Periodic prompt failed, backing off next run", + r.logger.Warn("Loop prompt failed, backing off next run", "session_id", sessionID, "session_name", sessionName, "consecutive_failures", failures, @@ -1354,9 +1354,9 @@ func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessi "error", err) } // Broadcast the new next-run time so the countdown reflects the backoff. - if r.onPeriodicUpdated != nil { - if updated, gErr := periodicStore.Get(); gErr == nil && updated != nil { - r.onPeriodicUpdated(sessionID, updated) + if r.onLoopUpdated != nil { + if updated, gErr := loopStore.Get(); gErr == nil && updated != nil { + r.onLoopUpdated(sessionID, updated) } } } @@ -1364,7 +1364,7 @@ func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessi } if r.logger != nil { - r.logger.Warn("Periodic prompt failed, schedule not advanced", + r.logger.Warn("Loop prompt failed, schedule not advanced", "session_id", sessionID, "session_name", sessionName, "error", err) @@ -1380,7 +1380,7 @@ func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessi if !resetTimer { // Manual run with "keep schedule" — leave NextScheduledAt unchanged. if r.logger != nil { - r.logger.Debug("Periodic prompt completed, timer not reset (manual run)", + r.logger.Debug("Loop prompt completed, timer not reset (manual run)", "session_id", sessionID, "session_name", sessionName) } @@ -1388,36 +1388,36 @@ func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessi } // Prompt completed successfully — now update the schedule - if err := periodicStore.RecordSent(); err != nil { + if err := loopStore.RecordSent(); err != nil { if r.logger != nil { - r.logger.Warn("Failed to update periodic last_sent_at", + r.logger.Warn("Failed to update loop last_sent_at", "session_id", sessionID, "error", err) } } else { - updated, getErr := periodicStore.Get() + updated, getErr := loopStore.Get() if getErr == nil && updated != nil { r.mu.Lock() - cfgCap := r.maxPeriodicIterations + cfgCap := r.maxLoopIterations r.mu.Unlock() - effective := config.EffectiveMaxPeriodicIterations(updated.MaxIterations, cfgCap) + effective := config.EffectiveMaxLoopIterations(updated.MaxIterations, cfgCap) perPromptReached := updated.ReachedMaxIterations() if updated.IterationCount >= effective { - // Cap reached — disable the periodic prompt so it stops firing. + // Cap reached — disable the loop prompt so it stops firing. if r.logger != nil { if perPromptReached { - r.logger.Info("Periodic conversation reached max iterations, auto-stopping", + r.logger.Info("Loop conversation reached max iterations, auto-stopping", "session_id", sessionID, "max_iterations", updated.MaxIterations, "iteration_count", updated.IterationCount) } else { // Stopped by the global/config backstop rather than the per-prompt cap. - r.logger.Warn("Periodic conversation reached global iteration safeguard, auto-stopping", + r.logger.Warn("Loop conversation reached global iteration safeguard, auto-stopping", "session_id", sessionID, "iteration_count", updated.IterationCount, "effective_cap", effective, "config_cap", cfgCap, - "backstop", config.GlobalMaxPeriodicIterations) + "backstop", config.GlobalMaxLoopIterations) } } // Distinguish per-prompt cap from global/config backstop. @@ -1425,26 +1425,26 @@ func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessi if perPromptReached { stoppedReason = session.StoppedReasonMaxIterations } - if disableErr := periodicStore.MarkStopped(stoppedReason); disableErr != nil { + if disableErr := loopStore.MarkStopped(stoppedReason); disableErr != nil { if r.logger != nil { - r.logger.Warn("Failed to disable periodic after reaching iteration cap", + r.logger.Warn("Failed to disable loop after reaching iteration cap", "session_id", sessionID, "error", disableErr) } - } else if r.onPeriodicAutoStopped != nil { + } else if r.onLoopAutoStopped != nil { // Re-read so the broadcast reflects Enabled=false / NextScheduledAt=nil. - if final, err := periodicStore.Get(); err == nil { - r.onPeriodicAutoStopped(sessionID, final) + if final, err := loopStore.Get(); err == nil { + r.onLoopAutoStopped(sessionID, final) } } } else { // Schedule advanced normally — notify clients so the countdown resets // to the freshly computed next-run time. - if r.onPeriodicUpdated != nil { - r.onPeriodicUpdated(sessionID, updated) + if r.onLoopUpdated != nil { + r.onLoopUpdated(sessionID, updated) } if r.logger != nil && updated.NextScheduledAt != nil { - r.logger.Debug("Periodic schedule updated after delivery", + r.logger.Debug("Loop schedule updated after delivery", "session_id", sessionID, "next_scheduled_at", updated.NextScheduledAt) } @@ -1458,11 +1458,11 @@ func (r *PeriodicRunner) deliverPrompt(bs *conversation.BackgroundSession, sessi return err } - // Notify about the periodic prompt delivery (the prompt is now queued/started). + // Notify about the loop prompt delivery (the prompt is now queued/started). // Skip notification for forced (manual "Run Now") triggers — the user already // knows they triggered it, so showing a notification is redundant. - if r.onPeriodicStarted != nil && !forced { - r.onPeriodicStarted(sessionID, sessionName) + if r.onLoopStarted != nil && !forced { + r.onLoopStarted(sessionID, sessionName) } return nil @@ -1485,8 +1485,8 @@ const autoArchiveWaitTimeout = 30 * time.Second // checkAutoArchive archives sessions that have been inactive for longer than autoArchiveAfter. // It skips sessions that are already archived, child sessions (children are archived via parent cascade), -// or sessions with periodic prompts — enabled or paused (they should remain active indefinitely). -func (r *PeriodicRunner) checkAutoArchive(sessions []session.Metadata, now time.Time) { +// or sessions with loop prompts — enabled or paused (they should remain active indefinitely). +func (r *LoopRunner) checkAutoArchive(sessions []session.Metadata, now time.Time) { r.mu.Lock() threshold := r.autoArchiveAfter r.mu.Unlock() @@ -1510,14 +1510,14 @@ func (r *PeriodicRunner) checkAutoArchive(sessions []session.Metadata, now time. continue } - // Skip sessions with periodic prompts (enabled or paused) — they should remain active indefinitely. - // A paused periodic conversation is still a periodic conversation and should not be auto-archived; + // Skip sessions with loop prompts (enabled or paused) — they should remain active indefinitely. + // A paused loop conversation is still a loop conversation and should not be auto-archived; // the user may re-enable it at any time. - periodicStore := r.store.Periodic(meta.SessionID) - _, err := periodicStore.Get() - if err != nil && err != session.ErrPeriodicNotFound { + loopStore := r.store.Loop(meta.SessionID) + _, err := loopStore.Get() + if err != nil && err != session.ErrLoopNotFound { if r.logger != nil { - r.logger.Error("Failed to read periodic config during auto-archive check", + r.logger.Error("Failed to read loop config during auto-archive check", "session_id", meta.SessionID, "error", err) } @@ -1526,7 +1526,7 @@ func (r *PeriodicRunner) checkAutoArchive(sessions []session.Metadata, now time. } if err == nil { if r.logger != nil { - r.logger.Debug("Skipping auto-archive for periodic session", + r.logger.Debug("Skipping auto-archive for loop session", "session_id", meta.SessionID, "session_name", meta.Name) } @@ -1596,7 +1596,7 @@ func (r *PeriodicRunner) checkAutoArchive(sessions []session.Metadata, now time. } // checkArchiveCleanup permanently deletes archived sessions older than the retention period. -func (r *PeriodicRunner) checkArchiveCleanup() { +func (r *LoopRunner) checkArchiveCleanup() { r.mu.Lock() retentionPeriod := r.archiveRetentionPeriod r.mu.Unlock() @@ -1616,7 +1616,7 @@ func (r *PeriodicRunner) checkArchiveCleanup() { } if deleted > 0 && r.logger != nil { - r.logger.Info("Periodic archive cleanup completed", + r.logger.Info("Loop archive cleanup completed", "deleted_count", deleted, "retention_period", retentionPeriod) } diff --git a/internal/web/periodic_runner_tasks.go b/internal/web/loop_runner_tasks.go similarity index 81% rename from internal/web/periodic_runner_tasks.go rename to internal/web/loop_runner_tasks.go index f1b91653..23456be1 100644 --- a/internal/web/periodic_runner_tasks.go +++ b/internal/web/loop_runner_tasks.go @@ -10,11 +10,11 @@ import ( "github.com/inercia/mitto/internal/session" ) -// DefaultMinPeriodicTasksCooldownSeconds is the default floor (seconds) applied -// to the onTasks periodic trigger's cooldown between fires, preventing hot -// loops from rapid beads churn. Mirrors DefaultMinPeriodicCompletionDelaySeconds +// DefaultMinLoopTasksCooldownSeconds is the default floor (seconds) applied +// to the onTasks loop trigger's cooldown between fires, preventing hot +// loops from rapid beads churn. Mirrors DefaultMinLoopCompletionDelaySeconds // for the onCompletion trigger. -const DefaultMinPeriodicTasksCooldownSeconds = 30 +const DefaultMinLoopTasksCooldownSeconds = 30 // tasksDefaultQuiescenceWindow is the default value for tasksQuiescenceWindow. const tasksDefaultQuiescenceWindow = 30 * time.Second @@ -28,13 +28,13 @@ const tasksListTimeout = 30 * time.Second // breaker (Layer 3) auto-pauses the trigger. const tasksNoProgressLimit = 3 -// Compile-time assertion: *PeriodicRunner implements config.BeadsSubscriber. -var _ config.BeadsSubscriber = (*PeriodicRunner)(nil) +// Compile-time assertion: *LoopRunner implements config.BeadsSubscriber. +var _ config.BeadsSubscriber = (*LoopRunner)(nil) // SetBeadsClient injects the beads.Client used to list issues for onTasks // condition evaluation. Intended for tests; production code may leave this // unset to lazily default to beads.NewClient(). -func (r *PeriodicRunner) SetBeadsClient(c beads.Client) { +func (r *LoopRunner) SetBeadsClient(c beads.Client) { r.beadsClientMu.Lock() defer r.beadsClientMu.Unlock() r.beadsClient = c @@ -42,7 +42,7 @@ func (r *PeriodicRunner) SetBeadsClient(c beads.Client) { // beadsClientOrDefault returns the configured beads.Client, lazily defaulting // to beads.NewClient() on first use. -func (r *PeriodicRunner) beadsClientOrDefault() beads.Client { +func (r *LoopRunner) beadsClientOrDefault() beads.Client { r.beadsClientMu.Lock() defer r.beadsClientMu.Unlock() if r.beadsClient == nil { @@ -51,9 +51,9 @@ func (r *PeriodicRunner) beadsClientOrDefault() beads.Client { return r.beadsClient } -// SetMinPeriodicTasksCooldownSeconds sets the global floor for the onTasks +// SetMinLoopTasksCooldownSeconds sets the global floor for the onTasks // trigger's cooldown between fires. Values < 0 are clamped to 0. -func (r *PeriodicRunner) SetMinPeriodicTasksCooldownSeconds(n int) { +func (r *LoopRunner) SetMinLoopTasksCooldownSeconds(n int) { if n < 0 { n = 0 } @@ -62,9 +62,9 @@ func (r *PeriodicRunner) SetMinPeriodicTasksCooldownSeconds(n int) { r.minTasksCooldownSeconds = n } -// MinPeriodicTasksCooldownSeconds returns the current floor for the onTasks +// MinLoopTasksCooldownSeconds returns the current floor for the onTasks // trigger's cooldown between fires, in seconds. -func (r *PeriodicRunner) MinPeriodicTasksCooldownSeconds() int { +func (r *LoopRunner) MinLoopTasksCooldownSeconds() int { r.mu.Lock() defer r.mu.Unlock() return r.minTasksCooldownSeconds @@ -74,7 +74,7 @@ func (r *PeriodicRunner) MinPeriodicTasksCooldownSeconds() int { // conversation's whole child subtree goes idle, before rebasing the baseline. // Intended for tests to use a short window; production uses // tasksDefaultQuiescenceWindow. -func (r *PeriodicRunner) SetTasksQuiescenceWindow(d time.Duration) { +func (r *LoopRunner) SetTasksQuiescenceWindow(d time.Duration) { r.mu.Lock() defer r.mu.Unlock() r.tasksQuiescenceWindow = d @@ -89,7 +89,7 @@ func (r *PeriodicRunner) SetTasksQuiescenceWindow(d time.Duration) { // // The beads snapshot for each distinct working directory is listed at most // once per call, regardless of how many onTasks conversations share it. -func (r *PeriodicRunner) OnBeadsChanged(event config.BeadsChangeEvent) { +func (r *LoopRunner) OnBeadsChanged(event config.BeadsChangeEvent) { if r.store == nil || r.tasksEvaluator == nil { return } @@ -121,9 +121,9 @@ func (r *PeriodicRunner) OnBeadsChanged(event config.BeadsChangeEvent) { continue } - periodicStore := r.store.Periodic(meta.SessionID) - periodic, err := periodicStore.Get() - if err != nil || periodic == nil || !periodic.Enabled || !periodic.IsOnTasks() { + loopStore := r.store.Loop(meta.SessionID) + loop, err := loopStore.Get() + if err != nil || loop == nil || !loop.Enabled || !loop.IsOnTasks() { continue } @@ -146,7 +146,7 @@ func (r *PeriodicRunner) OnBeadsChanged(event config.BeadsChangeEvent) { rawCache[meta.WorkingDir] = raw } - r.processTasksChange(meta, periodic, periodicStore, raw) + r.processTasksChange(meta, loop, loopStore, raw) } } @@ -182,7 +182,7 @@ type tasksDecision struct { // no side effects other than logging — callers (processTasksChange) act on the // returned decision. Kept side-effect-free (besides logging) so the decision // logic is directly unit-testable without a session manager or ACP connection. -func (r *PeriodicRunner) evaluateTasksChange(meta session.Metadata, periodic *session.PeriodicPrompt, raw []byte) tasksDecision { +func (r *LoopRunner) evaluateTasksChange(meta session.Metadata, loop *session.LoopPrompt, raw []byte) tasksDecision { sessionID := meta.SessionID // Layer 1 (temporal): ignore while the conversation or any delegated child @@ -193,13 +193,13 @@ func (r *PeriodicRunner) evaluateTasksChange(meta session.Metadata, periodic *se // Auto-stop if the wall-clock maxDuration cap is reached, exactly like the // other triggers (fireOnCompletion / checkSession). - periodicStore := r.store.Periodic(sessionID) - if r.autoStopIfMaxDurationReached(sessionID, periodic, periodicStore, time.Now()) { + loopStore := r.store.Loop(sessionID) + if r.autoStopIfMaxDurationReached(sessionID, loop, loopStore, time.Now()) { return tasksDecision{action: tasksActionSkip} } // Layer 0 (hard backstop): per-conversation cooldown floor. - if r.tasksCooldownActive(periodic) { + if r.tasksCooldownActive(loop) { return tasksDecision{action: tasksActionSkip} } @@ -235,12 +235,12 @@ func (r *PeriodicRunner) evaluateTasksChange(meta session.Metadata, periodic *se } changeCtx := &config.TasksChangeContext{Tasks: currSnap, Prev: prevSnap, Changes: delta} - ok, evalErr := r.tasksEvaluator.Evaluate(periodic.Condition, changeCtx) + ok, evalErr := r.tasksEvaluator.Evaluate(loop.Condition, changeCtx) if evalErr != nil { // Fail-closed: a misconfigured condition must not silently fire. if r.logger != nil { r.logger.Warn("onTasks: condition evaluation failed (fail-closed, not firing)", - "session_id", sessionID, "condition", periodic.Condition, "error", evalErr) + "session_id", sessionID, "condition", loop.Condition, "error", evalErr) } return tasksDecision{action: tasksActionSkip, delta: delta} } @@ -254,13 +254,13 @@ func (r *PeriodicRunner) evaluateTasksChange(meta session.Metadata, periodic *se // processTasksChange evaluates a single onTasks conversation against the // latest beads snapshot (raw) for its working directory and acts on the // resulting decision: arming a rebase, initializing the baseline, or firing. -func (r *PeriodicRunner) processTasksChange(meta session.Metadata, periodic *session.PeriodicPrompt, periodicStore *session.PeriodicStore, raw []byte) { +func (r *LoopRunner) processTasksChange(meta session.Metadata, loop *session.LoopPrompt, loopStore *session.LoopStore, raw []byte) { sessionID := meta.SessionID - decision := r.evaluateTasksChange(meta, periodic, raw) + decision := r.evaluateTasksChange(meta, loop, raw) switch decision.action { case tasksActionDeferBusy: - r.armTasksRebase(sessionID, periodicStore) + r.armTasksRebase(sessionID, loopStore) case tasksActionInitBaseline: if err := decision.baseline.Set(raw); err != nil && r.logger != nil { @@ -283,7 +283,7 @@ func (r *PeriodicRunner) processTasksChange(meta session.Metadata, periodic *ses r.logger.Warn("onTasks: failed to persist baseline after fire", "session_id", sessionID, "error", err) } - r.recordTasksFireOutcome(sessionID, periodicStore, decision.delta) + r.recordTasksFireOutcome(sessionID, loopStore, decision.delta) case tasksActionSkip: // Nothing to do. @@ -303,28 +303,28 @@ func tasksDeltaIsMaterial(delta *config.TasksDelta) bool { // tasksCooldownActive returns true if firing should be skipped because the // per-conversation cooldown (clamped to the global floor) has not elapsed // since the last delivery. -func (r *PeriodicRunner) tasksCooldownActive(periodic *session.PeriodicPrompt) bool { - if periodic.LastSentAt == nil { +func (r *LoopRunner) tasksCooldownActive(loop *session.LoopPrompt) bool { + if loop.LastSentAt == nil { return false } r.mu.Lock() floor := r.minTasksCooldownSeconds r.mu.Unlock() - cooldown := periodic.CooldownSeconds + cooldown := loop.CooldownSeconds if cooldown < floor { cooldown = floor } if cooldown <= 0 { return false } - return time.Since(*periodic.LastSentAt) < time.Duration(cooldown)*time.Second + return time.Since(*loop.LastSentAt) < time.Duration(cooldown)*time.Second } // isTasksSubtreeBusy returns true if the conversation, or any conversation in // its delegated-child subtree, is currently prompting or blocked on // mitto_children_tasks_wait. -func (r *PeriodicRunner) isTasksSubtreeBusy(sessionID string) bool { +func (r *LoopRunner) isTasksSubtreeBusy(sessionID string) bool { if r.sessionManager == nil || r.store == nil { return false } @@ -345,7 +345,7 @@ func (r *PeriodicRunner) isTasksSubtreeBusy(sessionID string) bool { // isSessionBusy returns true if sessionID is currently prompting or blocked on // mitto_children_tasks_wait. -func (r *PeriodicRunner) isSessionBusy(sessionID string) bool { +func (r *LoopRunner) isSessionBusy(sessionID string) bool { if bs := r.sessionManager.GetSession(sessionID); bs != nil && bs.IsPrompting() { return true } @@ -355,7 +355,7 @@ func (r *PeriodicRunner) isSessionBusy(sessionID string) bool { // armTasksRebase schedules a baseline rebase for sessionID after the // quiescence window, replacing (and stopping) any timer already pending so at // most one rebase is queued per session. -func (r *PeriodicRunner) armTasksRebase(sessionID string, periodicStore *session.PeriodicStore) { +func (r *LoopRunner) armTasksRebase(sessionID string, loopStore *session.LoopStore) { r.mu.Lock() window := r.tasksQuiescenceWindow r.mu.Unlock() @@ -366,7 +366,7 @@ func (r *PeriodicRunner) armTasksRebase(sessionID string, periodicStore *session existing.Stop() } r.tasksRebaseTimers[sessionID] = time.AfterFunc(window, func() { - r.fireTasksRebase(sessionID, periodicStore) + r.fireTasksRebase(sessionID, loopStore) }) } @@ -374,7 +374,7 @@ func (r *PeriodicRunner) armTasksRebase(sessionID string, periodicStore *session // rebases the onTasks baseline to the current beads snapshot — absorbing any // edits the conversation (or a delegated child) made to beads during its run. // If still busy, it re-arms itself for another quiescence window. -func (r *PeriodicRunner) fireTasksRebase(sessionID string, periodicStore *session.PeriodicStore) { +func (r *LoopRunner) fireTasksRebase(sessionID string, loopStore *session.LoopStore) { r.tasksRebaseTimersMu.Lock() delete(r.tasksRebaseTimers, sessionID) r.tasksRebaseTimersMu.Unlock() @@ -384,7 +384,7 @@ func (r *PeriodicRunner) fireTasksRebase(sessionID string, periodicStore *sessio } if r.isTasksSubtreeBusy(sessionID) { - r.armTasksRebase(sessionID, periodicStore) + r.armTasksRebase(sessionID, loopStore) return } @@ -393,8 +393,8 @@ func (r *PeriodicRunner) fireTasksRebase(sessionID string, periodicStore *sessio return } - periodic, err := periodicStore.Get() - if err != nil || periodic == nil || !periodic.Enabled || !periodic.IsOnTasks() { + loop, err := loopStore.Get() + if err != nil || loop == nil || !loop.Enabled || !loop.IsOnTasks() { return } @@ -426,14 +426,14 @@ func (r *PeriodicRunner) fireTasksRebase(sessionID string, periodicStore *sessio // conversation is newly enabled for onTasks or the server restarts before any // baseline was ever captured. No-op for sessions that already have a baseline, // are archived, are not onTasks, or are not enabled. -func (r *PeriodicRunner) BootstrapTasksBaseline(sessionID string) { +func (r *LoopRunner) BootstrapTasksBaseline(sessionID string) { if r.store == nil { return } - periodicStore := r.store.Periodic(sessionID) - periodic, err := periodicStore.Get() - if err != nil || periodic == nil || !periodic.Enabled || !periodic.IsOnTasks() { + loopStore := r.store.Loop(sessionID) + loop, err := loopStore.Get() + if err != nil || loop == nil || !loop.Enabled || !loop.IsOnTasks() { return } @@ -468,7 +468,7 @@ func (r *PeriodicRunner) BootstrapTasksBaseline(sessionID string) { // genuine forward progress), it auto-pauses the trigger via MarkStopped, // mirroring the existing failure-pause patterns (handlePromptResolveFailure, // autoStopIfMaxDurationReached). -func (r *PeriodicRunner) recordTasksFireOutcome(sessionID string, periodicStore *session.PeriodicStore, delta *config.TasksDelta) { +func (r *LoopRunner) recordTasksFireOutcome(sessionID string, loopStore *session.LoopStore, delta *config.TasksDelta) { curr := tasksTouchedIDs(delta) r.tasksNoProgressMu.Lock() @@ -487,7 +487,7 @@ func (r *PeriodicRunner) recordTasksFireOutcome(sessionID string, periodicStore return } - if err := periodicStore.MarkStopped(session.StoppedReasonNoProgress); err != nil { + if err := loopStore.MarkStopped(session.StoppedReasonNoProgress); err != nil { if r.logger != nil { r.logger.Warn("onTasks: failed to auto-pause after no-progress fires", "session_id", sessionID, "error", err) @@ -500,9 +500,9 @@ func (r *PeriodicRunner) recordTasksFireOutcome(sessionID string, periodicStore delete(r.tasksLastTouchedIDs, sessionID) r.tasksNoProgressMu.Unlock() - if r.onPeriodicAutoStopped != nil { - if final, err := periodicStore.Get(); err == nil { - r.onPeriodicAutoStopped(sessionID, final) + if r.onLoopAutoStopped != nil { + if final, err := loopStore.Get(); err == nil { + r.onLoopAutoStopped(sessionID, final) } } if r.logger != nil { diff --git a/internal/web/periodic_runner_test.go b/internal/web/loop_runner_test.go similarity index 70% rename from internal/web/periodic_runner_test.go rename to internal/web/loop_runner_test.go index 1523bc59..03afa612 100644 --- a/internal/web/periodic_runner_test.go +++ b/internal/web/loop_runner_test.go @@ -17,8 +17,8 @@ import ( "github.com/inercia/mitto/internal/session" ) -// writeTestPeriodicFile writes a periodic prompt directly to a file for testing. -func writeTestPeriodicFile(path string, p *session.PeriodicPrompt) error { +// writeTestLoopFile writes a loop prompt directly to a file for testing. +func writeTestLoopFile(path string, p *session.LoopPrompt) error { data, err := json.MarshalIndent(p, "", " ") if err != nil { return err @@ -41,14 +41,14 @@ func setSessionUpdatedAt(t *testing.T, store *session.Store, sessionID string, u } } -func TestPeriodicRunner_StartStop(t *testing.T) { +func TestLoopRunner_StartStop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.SetPollInterval(100 * time.Millisecond) if runner.IsRunning() { @@ -78,14 +78,14 @@ func TestPeriodicRunner_StartStop(t *testing.T) { } } -func TestPeriodicRunner_RunOnceNoSessions(t *testing.T) { +func TestLoopRunner_RunOnceNoSessions(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) delivered, skipped, errored := runner.RunOnce() if delivered != 0 || skipped != 0 || errored != 0 { @@ -93,14 +93,14 @@ func TestPeriodicRunner_RunOnceNoSessions(t *testing.T) { } } -func TestPeriodicRunner_RunOnceNoPeriodicConfig(t *testing.T) { +func TestLoopRunner_RunOnceNoLoopConfig(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a session without periodic config + // Create a session without loop config meta := session.Metadata{ SessionID: "test-session-1", ACPServer: "test", @@ -110,7 +110,7 @@ func TestPeriodicRunner_RunOnceNoPeriodicConfig(t *testing.T) { t.Fatalf("Create() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) delivered, skipped, errored := runner.RunOnce() if delivered != 0 || skipped != 0 || errored != 0 { @@ -118,14 +118,14 @@ func TestPeriodicRunner_RunOnceNoPeriodicConfig(t *testing.T) { } } -func TestPeriodicRunner_RunOnceSkipsArchivedSessions(t *testing.T) { +func TestLoopRunner_RunOnceSkipsArchivedSessions(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create an archived session with periodic config + // Create an archived session with loop config meta := session.Metadata{ SessionID: "archived-session", ACPServer: "test", @@ -136,10 +136,10 @@ func TestPeriodicRunner_RunOnceSkipsArchivedSessions(t *testing.T) { t.Fatalf("Create() error = %v", err) } - // Add periodic config that would be due - periodicStore := store.Periodic("archived-session") + // Add loop config that would be due + loopStore := store.Loop("archived-session") past := time.Now().UTC().Add(-1 * time.Hour) - p := &session.PeriodicPrompt{ + p := &session.LoopPrompt{ Prompt: "Test prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, @@ -147,11 +147,11 @@ func TestPeriodicRunner_RunOnceSkipsArchivedSessions(t *testing.T) { UpdatedAt: past, NextScheduledAt: &past, // Due in the past } - if err := periodicStore.Set(p); err != nil { + if err := loopStore.Set(p); err != nil { t.Fatalf("Set() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) delivered, _, _ := runner.RunOnce() // Should not deliver because session is archived @@ -160,14 +160,14 @@ func TestPeriodicRunner_RunOnceSkipsArchivedSessions(t *testing.T) { } } -func TestPeriodicRunner_RunOnceSkipsDisabledConfig(t *testing.T) { +func TestLoopRunner_RunOnceSkipsDisabledConfig(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a session with disabled periodic config + // Create a session with disabled loop config meta := session.Metadata{ SessionID: "disabled-session", ACPServer: "test", @@ -177,9 +177,9 @@ func TestPeriodicRunner_RunOnceSkipsDisabledConfig(t *testing.T) { t.Fatalf("Create() error = %v", err) } - periodicStore := store.Periodic("disabled-session") + loopStore := store.Loop("disabled-session") past := time.Now().UTC().Add(-1 * time.Hour) - p := &session.PeriodicPrompt{ + p := &session.LoopPrompt{ Prompt: "Test prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: false, // Disabled @@ -187,11 +187,11 @@ func TestPeriodicRunner_RunOnceSkipsDisabledConfig(t *testing.T) { UpdatedAt: past, NextScheduledAt: &past, } - if err := periodicStore.Set(p); err != nil { + if err := loopStore.Set(p); err != nil { t.Fatalf("Set() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) delivered, _, _ := runner.RunOnce() if delivered != 0 { @@ -199,14 +199,14 @@ func TestPeriodicRunner_RunOnceSkipsDisabledConfig(t *testing.T) { } } -func TestPeriodicRunner_RunOnceSkipsNotDueYet(t *testing.T) { +func TestLoopRunner_RunOnceSkipsNotDueYet(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a session with periodic config not due yet + // Create a session with loop config not due yet meta := session.Metadata{ SessionID: "not-due-session", ACPServer: "test", @@ -216,9 +216,9 @@ func TestPeriodicRunner_RunOnceSkipsNotDueYet(t *testing.T) { t.Fatalf("Create() error = %v", err) } - periodicStore := store.Periodic("not-due-session") + loopStore := store.Loop("not-due-session") future := time.Now().UTC().Add(1 * time.Hour) // Due in the future - p := &session.PeriodicPrompt{ + p := &session.LoopPrompt{ Prompt: "Test prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, @@ -226,11 +226,11 @@ func TestPeriodicRunner_RunOnceSkipsNotDueYet(t *testing.T) { UpdatedAt: time.Now().UTC(), NextScheduledAt: &future, } - if err := periodicStore.Set(p); err != nil { + if err := loopStore.Set(p); err != nil { t.Fatalf("Set() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) delivered, _, errored := runner.RunOnce() if delivered != 0 { @@ -241,14 +241,14 @@ func TestPeriodicRunner_RunOnceSkipsNotDueYet(t *testing.T) { } } -func TestPeriodicRunner_RunOnceAutoResumesInactiveSession(t *testing.T) { +func TestLoopRunner_RunOnceAutoResumesInactiveSession(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a session with due periodic config but no active ACP connection + // Create a session with due loop config but no active ACP connection meta := session.Metadata{ SessionID: "inactive-session", ACPServer: "test", @@ -258,34 +258,34 @@ func TestPeriodicRunner_RunOnceAutoResumesInactiveSession(t *testing.T) { t.Fatalf("Create() error = %v", err) } - // Create periodic config - it will compute NextScheduledAt in the future + // Create loop config - it will compute NextScheduledAt in the future // So we need to simulate a prompt that was created but its time has come - periodicStore := store.Periodic("inactive-session") - p := &session.PeriodicPrompt{ + loopStore := store.Loop("inactive-session") + p := &session.LoopPrompt{ Prompt: "Test prompt", Frequency: session.Frequency{Value: 5, Unit: session.FrequencyMinutes}, // Minimum interval Enabled: true, } - if err := periodicStore.Set(p); err != nil { + if err := loopStore.Set(p); err != nil { t.Fatalf("Set() error = %v", err) } - // Now we need to manually update the periodic file to have a past NextScheduledAt + // Now we need to manually update the loop file to have a past NextScheduledAt // This simulates time passing since the prompt was created - got, _ := periodicStore.Get() + got, _ := loopStore.Get() past := time.Now().UTC().Add(-1 * time.Hour) got.NextScheduledAt = &past // Write directly to the file using fileutil - periodicPath := store.SessionDir("inactive-session") + "/periodic.json" - if err := writeTestPeriodicFile(periodicPath, got); err != nil { - t.Fatalf("writeTestPeriodicFile() error = %v", err) + loopPath := store.SessionDir("inactive-session") + "/loop.json" + if err := writeTestLoopFile(loopPath, got); err != nil { + t.Fatalf("writeTestLoopFile() error = %v", err) } // Create a session manager with no active sessions and no ACP configured // When ResumeSession is called, it will fail because no ACP command is configured sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) delivered, skipped, errored := runner.RunOnce() // The runner will attempt to resume the session, but it will fail @@ -302,8 +302,8 @@ func TestPeriodicRunner_RunOnceAutoResumesInactiveSession(t *testing.T) { } } -func TestPeriodicRunner_NilStore(t *testing.T) { - runner := NewPeriodicRunner(nil, nil, nil) +func TestLoopRunner_NilStore(t *testing.T) { + runner := NewLoopRunner(nil, nil, nil) delivered, skipped, errored := runner.RunOnce() if delivered != 0 || skipped != 0 || errored != 0 { @@ -333,36 +333,36 @@ func TestTruncatePrompt(t *testing.T) { } } -func TestPeriodicRunner_TriggerNow_NoStore(t *testing.T) { - runner := NewPeriodicRunner(nil, nil, nil) +func TestLoopRunner_TriggerNow_NoStore(t *testing.T) { + runner := NewLoopRunner(nil, nil, nil) err := runner.TriggerNow("test-session", true) if err != ErrSessionStoreNotAvailable { t.Errorf("TriggerNow() error = %v, want %v", err, ErrSessionStoreNotAvailable) } } -func TestPeriodicRunner_TriggerNow_SessionNotFound(t *testing.T) { +func TestLoopRunner_TriggerNow_SessionNotFound(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) err = runner.TriggerNow("nonexistent-session", true) if err != session.ErrSessionNotFound { t.Errorf("TriggerNow() error = %v, want %v", err, session.ErrSessionNotFound) } } -func TestPeriodicRunner_TriggerNow_NoPeriodicConfig(t *testing.T) { +func TestLoopRunner_TriggerNow_NoLoopConfig(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a session without periodic config + // Create a session without loop config meta := session.Metadata{ SessionID: "test-session-1", ACPServer: "test", @@ -372,14 +372,14 @@ func TestPeriodicRunner_TriggerNow_NoPeriodicConfig(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) err = runner.TriggerNow(meta.SessionID, true) - if err != session.ErrPeriodicNotFound { - t.Errorf("TriggerNow() error = %v, want %v", err, session.ErrPeriodicNotFound) + if err != session.ErrLoopNotFound { + t.Errorf("TriggerNow() error = %v, want %v", err, session.ErrLoopNotFound) } } -func TestPeriodicRunner_TriggerNow_NotEnabled(t *testing.T) { +func TestLoopRunner_TriggerNow_NotEnabled(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -396,25 +396,25 @@ func TestPeriodicRunner_TriggerNow_NotEnabled(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - // Create a periodic config with enabled=false - periodicStore := store.Periodic(meta.SessionID) - err = periodicStore.Set(&session.PeriodicPrompt{ + // Create a loop config with enabled=false + loopStore := store.Loop(meta.SessionID) + err = loopStore.Set(&session.LoopPrompt{ Prompt: "Test prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: false, }) if err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) err = runner.TriggerNow(meta.SessionID, true) - if err != ErrPeriodicNotEnabled { - t.Errorf("TriggerNow() error = %v, want %v", err, ErrPeriodicNotEnabled) + if err != ErrLoopNotEnabled { + t.Errorf("TriggerNow() error = %v, want %v", err, ErrLoopNotEnabled) } } -func TestPeriodicRunner_TriggerNow_NoSessionManager(t *testing.T) { +func TestLoopRunner_TriggerNow_NoSessionManager(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -431,39 +431,39 @@ func TestPeriodicRunner_TriggerNow_NoSessionManager(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - // Create an enabled periodic config - periodicStore := store.Periodic(meta.SessionID) - err = periodicStore.Set(&session.PeriodicPrompt{ + // Create an enabled loop config + loopStore := store.Loop(meta.SessionID) + err = loopStore.Set(&session.LoopPrompt{ Prompt: "Test prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, }) if err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } // Runner without session manager - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) err = runner.TriggerNow(meta.SessionID, true) if err != ErrSessionManagerNotAvailable { t.Errorf("TriggerNow() error = %v, want %v", err, ErrSessionManagerNotAvailable) } } -// TestPeriodicRunner_TriggerNow_NoResetTimer verifies that TriggerNow accepts +// TestLoopRunner_TriggerNow_NoResetTimer verifies that TriggerNow accepts // resetTimer=false and follows the same code path as resetTimer=true up to the // point where the session manager is needed. This ensures the flag is correctly // threaded through the call stack without being rejected early or panicking. // (Full end-to-end verification that RecordSent is skipped requires an active // ACP session and is covered by integration tests.) -func TestPeriodicRunner_TriggerNow_NoResetTimer(t *testing.T) { +func TestLoopRunner_TriggerNow_NoResetTimer(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a session with an enabled periodic config + // Create a session with an enabled loop config meta := session.Metadata{ SessionID: "test-no-reset-timer", ACPServer: "test", @@ -473,109 +473,109 @@ func TestPeriodicRunner_TriggerNow_NoResetTimer(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic(meta.SessionID) - err = periodicStore.Set(&session.PeriodicPrompt{ + loopStore := store.Loop(meta.SessionID) + err = loopStore.Set(&session.LoopPrompt{ Prompt: "Test prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, }) if err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } // Capture the initial schedule so we can verify it is not modified on error. - initialPeriodic, err := periodicStore.Get() + initialLoop, err := loopStore.Get() if err != nil { - t.Fatalf("periodicStore.Get() error = %v", err) + t.Fatalf("loopStore.Get() error = %v", err) } - initialNextScheduled := initialPeriodic.NextScheduledAt + initialNextScheduled := initialLoop.NextScheduledAt // Runner without session manager — should fail at ErrSessionManagerNotAvailable, // identical to the resetTimer=true case. This verifies that resetTimer=false is // accepted and reaches the same validation step without any early failure. - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) err = runner.TriggerNow(meta.SessionID, false) if err != ErrSessionManagerNotAvailable { t.Errorf("TriggerNow() error = %v, want %v", err, ErrSessionManagerNotAvailable) } // Verify the schedule was not modified (error occurred before any delivery). - afterPeriodic, err := periodicStore.Get() + afterLoop, err := loopStore.Get() if err != nil { - t.Fatalf("periodicStore.Get() after error = %v", err) + t.Fatalf("loopStore.Get() after error = %v", err) } switch { - case initialNextScheduled == nil && afterPeriodic.NextScheduledAt != nil: + case initialNextScheduled == nil && afterLoop.NextScheduledAt != nil: t.Error("NextScheduledAt was unexpectedly set after error") - case initialNextScheduled != nil && afterPeriodic.NextScheduledAt == nil: + case initialNextScheduled != nil && afterLoop.NextScheduledAt == nil: t.Error("NextScheduledAt was unexpectedly cleared after error") - case initialNextScheduled != nil && afterPeriodic.NextScheduledAt != nil: - if !initialNextScheduled.Equal(*afterPeriodic.NextScheduledAt) { + case initialNextScheduled != nil && afterLoop.NextScheduledAt != nil: + if !initialNextScheduled.Equal(*afterLoop.NextScheduledAt) { t.Errorf("NextScheduledAt changed unexpectedly: before=%v after=%v", - *initialNextScheduled, *afterPeriodic.NextScheduledAt) + *initialNextScheduled, *afterLoop.NextScheduledAt) } } } -func TestPeriodicRunner_AutoArchiveSkipsPeriodicSessions(t *testing.T) { +func TestLoopRunner_AutoArchiveSkipsLoopSessions(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a session with enabled periodic config + // Create a session with enabled loop config oldTime := time.Now().UTC().Add(-48 * time.Hour) meta := session.Metadata{ - SessionID: "periodic-session", + SessionID: "loop-session", ACPServer: "test", WorkingDir: "/tmp", } if err := store.Create(meta); err != nil { t.Fatalf("Create() error = %v", err) } - setSessionUpdatedAt(t, store, "periodic-session", oldTime) + setSessionUpdatedAt(t, store, "loop-session", oldTime) - // Add enabled periodic config - periodicStore := store.Periodic("periodic-session") - p := &session.PeriodicPrompt{ - Prompt: "Test periodic prompt", + // Add enabled loop config + loopStore := store.Loop("loop-session") + p := &session.LoopPrompt{ + Prompt: "Test loop prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, } - if err := periodicStore.Set(p); err != nil { + if err := loopStore.Set(p); err != nil { t.Fatalf("Set() error = %v", err) } // Create runner with auto-archive threshold of 24 hours sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) runner.SetAutoArchiveAfter(24 * time.Hour) // Run auto-archive check runner.RunOnce() // Verify session was NOT archived - updatedMeta, err := store.GetMetadata("periodic-session") + updatedMeta, err := store.GetMetadata("loop-session") if err != nil { t.Fatalf("GetMetadata() error = %v", err) } if updatedMeta.Archived { - t.Error("Session with enabled periodic config should NOT be auto-archived") + t.Error("Session with enabled loop config should NOT be auto-archived") } } -func TestPeriodicRunner_AutoArchiveSkipsPausedPeriodicSessions(t *testing.T) { +func TestLoopRunner_AutoArchiveSkipsPausedLoopSessions(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a session with disabled periodic config + // Create a session with disabled loop config oldTime := time.Now().UTC().Add(-48 * time.Hour) meta := session.Metadata{ - SessionID: "disabled-periodic-session", + SessionID: "disabled-loop-session", ACPServer: "test", WorkingDir: "/tmp", } @@ -584,16 +584,16 @@ func TestPeriodicRunner_AutoArchiveSkipsPausedPeriodicSessions(t *testing.T) { } // Manually set UpdatedAt to 48 hours ago by writing metadata file directly // (store.Create and UpdateMetadata both overwrite UpdatedAt with time.Now()) - setSessionUpdatedAt(t, store, "disabled-periodic-session", oldTime) + setSessionUpdatedAt(t, store, "disabled-loop-session", oldTime) - // Add disabled periodic config - periodicStore := store.Periodic("disabled-periodic-session") - p := &session.PeriodicPrompt{ - Prompt: "Test periodic prompt", + // Add disabled loop config + loopStore := store.Loop("disabled-loop-session") + p := &session.LoopPrompt{ + Prompt: "Test loop prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: false, // Disabled } - if err := periodicStore.Set(p); err != nil { + if err := loopStore.Set(p); err != nil { t.Fatalf("Set() error = %v", err) } @@ -601,33 +601,33 @@ func TestPeriodicRunner_AutoArchiveSkipsPausedPeriodicSessions(t *testing.T) { sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) // Create runner with auto-archive threshold of 24 hours - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) runner.SetAutoArchiveAfter(24 * time.Hour) // Run auto-archive check runner.RunOnce() - // Verify session was NOT archived (paused periodic config should prevent archiving) - updatedMeta, err := store.GetMetadata("disabled-periodic-session") + // Verify session was NOT archived (paused loop config should prevent archiving) + updatedMeta, err := store.GetMetadata("disabled-loop-session") if err != nil { t.Fatalf("GetMetadata() error = %v", err) } if updatedMeta.Archived { - t.Error("Session with paused periodic config should NOT be auto-archived") + t.Error("Session with paused loop config should NOT be auto-archived") } } -func TestPeriodicRunner_AutoArchiveNoPeriodicConfig(t *testing.T) { +func TestLoopRunner_AutoArchiveNoLoopConfig(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a session without periodic config + // Create a session without loop config oldTime := time.Now().UTC().Add(-48 * time.Hour) meta := session.Metadata{ - SessionID: "no-periodic-session", + SessionID: "no-loop-session", ACPServer: "test", WorkingDir: "/tmp", } @@ -636,32 +636,32 @@ func TestPeriodicRunner_AutoArchiveNoPeriodicConfig(t *testing.T) { } // Manually set UpdatedAt to 48 hours ago by writing metadata file directly // (store.Create and UpdateMetadata both overwrite UpdatedAt with time.Now()) - setSessionUpdatedAt(t, store, "no-periodic-session", oldTime) + setSessionUpdatedAt(t, store, "no-loop-session", oldTime) // Create session manager sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) // Create runner with auto-archive threshold of 24 hours - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) runner.SetAutoArchiveAfter(24 * time.Hour) // Run auto-archive check runner.RunOnce() // Verify session WAS archived - updatedMeta, err := store.GetMetadata("no-periodic-session") + updatedMeta, err := store.GetMetadata("no-loop-session") if err != nil { t.Fatalf("GetMetadata() error = %v", err) } if !updatedMeta.Archived { - t.Error("Session without periodic config SHOULD be auto-archived when inactive") + t.Error("Session without loop config SHOULD be auto-archived when inactive") } } -// TestPeriodicRunner_ConfigCapAutoStop verifies that a periodic conversation with no +// TestLoopRunner_ConfigCapAutoStop verifies that a loop conversation with no // per-prompt cap (MaxIterations=0) auto-stops when the runner's configured default cap // is reached. This tests the global safeguard layer independently of the per-prompt cap. -func TestPeriodicRunner_ConfigCapAutoStop(t *testing.T) { +func TestLoopRunner_ConfigCapAutoStop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -678,41 +678,41 @@ func TestPeriodicRunner_ConfigCapAutoStop(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic(meta.SessionID) - if err := periodicStore.Set(&session.PeriodicPrompt{ + loopStore := store.Loop(meta.SessionID) + if err := loopStore.Set(&session.LoopPrompt{ Prompt: "Test prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, MaxIterations: 0, // No per-prompt cap }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } // Set up runner with a small config cap (3 iterations) const configCap = 3 - runner := NewPeriodicRunner(store, nil, nil) - runner.SetMaxPeriodicIterations(configCap) + runner := NewLoopRunner(store, nil, nil) + runner.SetMaxLoopIterations(configCap) - // Verify that SetMaxPeriodicIterations stored the value + // Verify that SetMaxLoopIterations stored the value runner.mu.Lock() - stored := runner.maxPeriodicIterations + stored := runner.maxLoopIterations runner.mu.Unlock() if stored != configCap { - t.Fatalf("maxPeriodicIterations = %d, want %d", stored, configCap) + t.Fatalf("maxLoopIterations = %d, want %d", stored, configCap) } // Simulate configCap successful deliveries by calling RecordSent directly. // This mirrors what OnComplete does after each successful PromptWithMeta call. for i := 0; i < configCap; i++ { - if err := periodicStore.RecordSent(); err != nil { + if err := loopStore.RecordSent(); err != nil { t.Fatalf("RecordSent() [%d] error = %v", i+1, err) } } // Read the updated state and check the effective cap condition - updated, err := periodicStore.Get() + updated, err := loopStore.Get() if err != nil { - t.Fatalf("periodicStore.Get() error = %v", err) + t.Fatalf("loopStore.Get() error = %v", err) } // Verify IterationCount was correctly incremented @@ -727,9 +727,9 @@ func TestPeriodicRunner_ConfigCapAutoStop(t *testing.T) { // Compute effective cap as the OnComplete callback would runner.mu.Lock() - cfgCap := runner.maxPeriodicIterations + cfgCap := runner.maxLoopIterations runner.mu.Unlock() - effective := config.EffectiveMaxPeriodicIterations(updated.MaxIterations, cfgCap) + effective := config.EffectiveMaxLoopIterations(updated.MaxIterations, cfgCap) // Verify effective cap matches the configured cap (since per-prompt cap is 0) if effective != configCap { @@ -742,117 +742,117 @@ func TestPeriodicRunner_ConfigCapAutoStop(t *testing.T) { updated.IterationCount, effective) } - // Simulate what OnComplete does: disable the periodic prompt + // Simulate what OnComplete does: disable the loop prompt autoStopCalled := false - runner.SetOnPeriodicAutoStopped(func(sessionID string, p *session.PeriodicPrompt) { + runner.SetOnLoopAutoStopped(func(sessionID string, p *session.LoopPrompt) { autoStopCalled = true if sessionID != meta.SessionID { - t.Errorf("onPeriodicAutoStopped sessionID = %q, want %q", sessionID, meta.SessionID) + t.Errorf("onLoopAutoStopped sessionID = %q, want %q", sessionID, meta.SessionID) } if p.Enabled { - t.Error("onPeriodicAutoStopped: periodic.Enabled = true, want false") + t.Error("onLoopAutoStopped: loop.Enabled = true, want false") } }) disabled := false - if err := periodicStore.Update(nil, nil, nil, &disabled, nil, nil, nil, nil, nil, nil, nil, nil, nil); err != nil { - t.Fatalf("periodicStore.Update(disable) error = %v", err) + if err := loopStore.Update(nil, nil, nil, &disabled, nil, nil, nil, nil, nil, nil, nil, nil, nil); err != nil { + t.Fatalf("loopStore.Update(disable) error = %v", err) } // Invoke the callback as OnComplete does - if final, err := periodicStore.Get(); err == nil && runner.onPeriodicAutoStopped != nil { - runner.onPeriodicAutoStopped(meta.SessionID, final) + if final, err := loopStore.Get(); err == nil && runner.onLoopAutoStopped != nil { + runner.onLoopAutoStopped(meta.SessionID, final) } // Verify the callback was invoked if !autoStopCalled { - t.Error("onPeriodicAutoStopped was not called") + t.Error("onLoopAutoStopped was not called") } - // Verify the periodic prompt is now disabled on disk - final, err := periodicStore.Get() + // Verify the loop prompt is now disabled on disk + final, err := loopStore.Get() if err != nil { - t.Fatalf("periodicStore.Get() after disable error = %v", err) + t.Fatalf("loopStore.Get() after disable error = %v", err) } if final.Enabled { - t.Error("periodic.Enabled = true after auto-stop, want false") + t.Error("loop.Enabled = true after auto-stop, want false") } } -// TestPeriodicRunner_DefaultMaxPeriodicIterations verifies that the runner +// TestLoopRunner_DefaultMaxLoopIterations verifies that the runner // is initialized with the correct default config cap. -func TestPeriodicRunner_DefaultMaxPeriodicIterations(t *testing.T) { +func TestLoopRunner_DefaultMaxLoopIterations(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.mu.Lock() - got := runner.maxPeriodicIterations + got := runner.maxLoopIterations runner.mu.Unlock() - if got != config.DefaultMaxPeriodicIterations { - t.Errorf("initial maxPeriodicIterations = %d, want %d (DefaultMaxPeriodicIterations)", - got, config.DefaultMaxPeriodicIterations) + if got != config.DefaultMaxLoopIterations { + t.Errorf("initial maxLoopIterations = %d, want %d (DefaultMaxLoopIterations)", + got, config.DefaultMaxLoopIterations) } } -// TestPeriodicRunner_MinCompletionDelaySeconds verifies the setter/getter and +// TestLoopRunner_MinCompletionDelaySeconds verifies the setter/getter and // that the runner is initialized with the correct default. -func TestPeriodicRunner_MinCompletionDelaySeconds(t *testing.T) { +func TestLoopRunner_MinCompletionDelaySeconds(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) - t.Run("default is DefaultMinPeriodicCompletionDelaySeconds", func(t *testing.T) { - got := runner.MinPeriodicCompletionDelaySeconds() - if got != config.DefaultMinPeriodicCompletionDelaySeconds { - t.Errorf("initial minCompletionDelaySeconds = %d, want %d (DefaultMinPeriodicCompletionDelaySeconds)", - got, config.DefaultMinPeriodicCompletionDelaySeconds) + t.Run("default is DefaultMinLoopCompletionDelaySeconds", func(t *testing.T) { + got := runner.MinLoopCompletionDelaySeconds() + if got != config.DefaultMinLoopCompletionDelaySeconds { + t.Errorf("initial minCompletionDelaySeconds = %d, want %d (DefaultMinLoopCompletionDelaySeconds)", + got, config.DefaultMinLoopCompletionDelaySeconds) } }) t.Run("set and get round-trip", func(t *testing.T) { - runner.SetMinPeriodicCompletionDelaySeconds(30) - got := runner.MinPeriodicCompletionDelaySeconds() + runner.SetMinLoopCompletionDelaySeconds(30) + got := runner.MinLoopCompletionDelaySeconds() if got != 30 { - t.Errorf("MinPeriodicCompletionDelaySeconds() = %d, want 30", got) + t.Errorf("MinLoopCompletionDelaySeconds() = %d, want 30", got) } }) t.Run("negative value clamped to zero", func(t *testing.T) { - runner.SetMinPeriodicCompletionDelaySeconds(-5) - got := runner.MinPeriodicCompletionDelaySeconds() + runner.SetMinLoopCompletionDelaySeconds(-5) + got := runner.MinLoopCompletionDelaySeconds() if got != 0 { - t.Errorf("MinPeriodicCompletionDelaySeconds() = %d after negative set, want 0", got) + t.Errorf("MinLoopCompletionDelaySeconds() = %d after negative set, want 0", got) } }) t.Run("zero is accepted", func(t *testing.T) { - runner.SetMinPeriodicCompletionDelaySeconds(0) - got := runner.MinPeriodicCompletionDelaySeconds() + runner.SetMinLoopCompletionDelaySeconds(0) + got := runner.MinLoopCompletionDelaySeconds() if got != 0 { - t.Errorf("MinPeriodicCompletionDelaySeconds() = %d, want 0", got) + t.Errorf("MinLoopCompletionDelaySeconds() = %d, want 0", got) } }) } // countCompletionTimers returns the number of armed on-completion timers, read // under the runner's timer mutex so it is safe against concurrent AfterFunc callbacks. -func countCompletionTimers(r *PeriodicRunner) int { +func countCompletionTimers(r *LoopRunner) int { r.completionTimersMu.Lock() defer r.completionTimersMu.Unlock() return len(r.completionTimers) } -// newOnCompletionSession creates a session with an enabled onCompletion periodic +// newOnCompletionSession creates a session with an enabled onCompletion loop // prompt configured with the given delay. func newOnCompletionSession(t *testing.T, store *session.Store, sessionID string, delaySeconds int) { t.Helper() @@ -860,17 +860,17 @@ func newOnCompletionSession(t *testing.T, store *session.Store, sessionID string if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - if err := store.Periodic(sessionID).Set(&session.PeriodicPrompt{ + if err := store.Loop(sessionID).Set(&session.LoopPrompt{ Prompt: "iterate", Enabled: true, Trigger: session.TriggerOnCompletion, DelaySeconds: delaySeconds, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } } -func TestPeriodicRunner_OnConversationIdle_ArmsForOnCompletion(t *testing.T) { +func TestLoopRunner_OnConversationIdle_ArmsForOnCompletion(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -880,7 +880,7 @@ func TestPeriodicRunner_OnConversationIdle_ArmsForOnCompletion(t *testing.T) { // Long delay so the timer does not fire during the test. newOnCompletionSession(t, store, "s1", 3600) - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.OnConversationIdle("s1") defer runner.cancelCompletionTimer("s1") @@ -889,7 +889,7 @@ func TestPeriodicRunner_OnConversationIdle_ArmsForOnCompletion(t *testing.T) { } } -func TestPeriodicRunner_OnConversationIdle_IgnoresScheduleTrigger(t *testing.T) { +func TestLoopRunner_OnConversationIdle_IgnoresScheduleTrigger(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -900,16 +900,16 @@ func TestPeriodicRunner_OnConversationIdle_IgnoresScheduleTrigger(t *testing.T) if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - if err := store.Periodic("s1").Set(&session.PeriodicPrompt{ + if err := store.Loop("s1").Set(&session.LoopPrompt{ Prompt: "x", Enabled: true, Trigger: session.TriggerSchedule, Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.OnConversationIdle("s1") if got := countCompletionTimers(runner); got != 0 { @@ -917,20 +917,20 @@ func TestPeriodicRunner_OnConversationIdle_IgnoresScheduleTrigger(t *testing.T) } } -func TestPeriodicRunner_OnConversationIdle_CancelsStaleTimer(t *testing.T) { +func TestLoopRunner_OnConversationIdle_CancelsStaleTimer(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Session without any periodic config. + // Session without any loop config. meta := session.Metadata{SessionID: "s1", ACPServer: "test", WorkingDir: "/tmp"} if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) // Arm a stale timer, then verify an idle event with no config clears it. runner.armCompletionTimer("s1", time.Hour) if got := countCompletionTimers(runner); got != 1 { @@ -943,7 +943,7 @@ func TestPeriodicRunner_OnConversationIdle_CancelsStaleTimer(t *testing.T) { } } -func TestPeriodicRunner_OnConversationIdle_ReArmReplaces(t *testing.T) { +func TestLoopRunner_OnConversationIdle_ReArmReplaces(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -952,7 +952,7 @@ func TestPeriodicRunner_OnConversationIdle_ReArmReplaces(t *testing.T) { newOnCompletionSession(t, store, "s1", 3600) - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) defer runner.cancelCompletionTimer("s1") runner.OnConversationIdle("s1") @@ -963,7 +963,7 @@ func TestPeriodicRunner_OnConversationIdle_ReArmReplaces(t *testing.T) { } } -func TestPeriodicRunner_OnConversationIdle_FiresAfterDelay(t *testing.T) { +func TestLoopRunner_OnConversationIdle_FiresAfterDelay(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -974,8 +974,8 @@ func TestPeriodicRunner_OnConversationIdle_FiresAfterDelay(t *testing.T) { // No session manager: firing reaches TriggerNow which errors out, but the // timer entry is cleared once it fires — which is what we assert here. - runner := NewPeriodicRunner(store, nil, nil) - runner.SetMinPeriodicCompletionDelaySeconds(0) + runner := NewLoopRunner(store, nil, nil) + runner.SetMinLoopCompletionDelaySeconds(0) runner.OnConversationIdle("s1") deadline := time.Now().Add(2 * time.Second) @@ -988,7 +988,7 @@ func TestPeriodicRunner_OnConversationIdle_FiresAfterDelay(t *testing.T) { t.Fatalf("on-completion timer did not fire within deadline") } -func TestPeriodicRunner_OnConversationIdle_FloorOverridesDelay(t *testing.T) { +func TestLoopRunner_OnConversationIdle_FloorOverridesDelay(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -998,8 +998,8 @@ func TestPeriodicRunner_OnConversationIdle_FloorOverridesDelay(t *testing.T) { // Tiny configured delay, but a large global floor must win. newOnCompletionSession(t, store, "s1", 0) - runner := NewPeriodicRunner(store, nil, nil) - runner.SetMinPeriodicCompletionDelaySeconds(3600) // 1h floor + runner := NewLoopRunner(store, nil, nil) + runner.SetMinLoopCompletionDelaySeconds(3600) // 1h floor runner.OnConversationIdle("s1") defer runner.cancelCompletionTimer("s1") @@ -1010,7 +1010,7 @@ func TestPeriodicRunner_OnConversationIdle_FloorOverridesDelay(t *testing.T) { } } -func TestPeriodicRunner_fireOnCompletion_ArchivedNoop(t *testing.T) { +func TestLoopRunner_fireOnCompletion_ArchivedNoop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1025,7 +1025,7 @@ func TestPeriodicRunner_fireOnCompletion_ArchivedNoop(t *testing.T) { t.Fatalf("UpdateMetadata() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) // Should return early without panicking or arming anything. runner.fireOnCompletion("s1") if got := countCompletionTimers(runner); got != 0 { @@ -1033,24 +1033,24 @@ func TestPeriodicRunner_fireOnCompletion_ArchivedNoop(t *testing.T) { } } -func TestPeriodicRunner_OnConversationIdle_NilStore(t *testing.T) { - runner := NewPeriodicRunner(nil, nil, nil) +func TestLoopRunner_OnConversationIdle_NilStore(t *testing.T) { + runner := NewLoopRunner(nil, nil, nil) // Must not panic with a nil store. runner.OnConversationIdle("x") runner.fireOnCompletion("x") } -// newDurationCappedSession creates a session with an enabled onCompletion periodic +// newDurationCappedSession creates a session with an enabled onCompletion loop // prompt anchored at firstRunAt, with the given maxDuration (seconds) and maxIterations. // firstRunAt may be nil to model a prompt that has not yet run (not yet anchored). -func newDurationCappedSession(t *testing.T, store *session.Store, sessionID string, firstRunAt *time.Time, maxDurationSeconds, maxIterations int) *session.PeriodicStore { +func newDurationCappedSession(t *testing.T, store *session.Store, sessionID string, firstRunAt *time.Time, maxDurationSeconds, maxIterations int) *session.LoopStore { t.Helper() meta := session.Metadata{SessionID: sessionID, ACPServer: "test", WorkingDir: "/tmp"} if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - ps := store.Periodic(sessionID) - if err := ps.Set(&session.PeriodicPrompt{ + ps := store.Loop(sessionID) + if err := ps.Set(&session.LoopPrompt{ Prompt: "iterate", Enabled: true, Trigger: session.TriggerOnCompletion, @@ -1058,12 +1058,12 @@ func newDurationCappedSession(t *testing.T, store *session.Store, sessionID stri MaxIterations: maxIterations, FirstRunAt: firstRunAt, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } return ps } -func TestPeriodicRunner_autoStopIfMaxDurationReached(t *testing.T) { +func TestLoopRunner_autoStopIfMaxDurationReached(t *testing.T) { t.Run("reached disables and broadcasts", func(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { @@ -1074,20 +1074,20 @@ func TestPeriodicRunner_autoStopIfMaxDurationReached(t *testing.T) { past := time.Now().Add(-2 * time.Hour) ps := newDurationCappedSession(t, store, "s1", &past, 60, 0) // 60s cap, anchored 2h ago - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) var gotID string var gotDisabled, called bool - runner.SetOnPeriodicAutoStopped(func(id string, p *session.PeriodicPrompt) { + runner.SetOnLoopAutoStopped(func(id string, p *session.LoopPrompt) { called = true gotID = id gotDisabled = !p.Enabled }) - periodic, err := ps.Get() + loop, err := ps.Get() if err != nil { t.Fatalf("ps.Get() error = %v", err) } - if !runner.autoStopIfMaxDurationReached("s1", periodic, ps, time.Now()) { + if !runner.autoStopIfMaxDurationReached("s1", loop, ps, time.Now()) { t.Fatal("autoStopIfMaxDurationReached() = false, want true (cap reached)") } if !called || gotID != "s1" || !gotDisabled { @@ -1098,7 +1098,7 @@ func TestPeriodicRunner_autoStopIfMaxDurationReached(t *testing.T) { t.Fatalf("ps.Get() after stop error = %v", err) } if final.Enabled { - t.Error("periodic still enabled after auto-stop, want disabled") + t.Error("loop still enabled after auto-stop, want disabled") } }) @@ -1112,14 +1112,14 @@ func TestPeriodicRunner_autoStopIfMaxDurationReached(t *testing.T) { past := time.Now().Add(-2 * time.Hour) ps := newDurationCappedSession(t, store, "s1", &past, 0, 0) // 0 = unlimited - runner := NewPeriodicRunner(store, nil, nil) - periodic, _ := ps.Get() - if runner.autoStopIfMaxDurationReached("s1", periodic, ps, time.Now()) { + runner := NewLoopRunner(store, nil, nil) + loop, _ := ps.Get() + if runner.autoStopIfMaxDurationReached("s1", loop, ps, time.Now()) { t.Fatal("autoStopIfMaxDurationReached() = true, want false (maxDuration=0 is unlimited)") } final, _ := ps.Get() if !final.Enabled { - t.Error("periodic disabled, want still enabled (unlimited)") + t.Error("loop disabled, want still enabled (unlimited)") } }) @@ -1131,9 +1131,9 @@ func TestPeriodicRunner_autoStopIfMaxDurationReached(t *testing.T) { defer store.Close() ps := newDurationCappedSession(t, store, "s1", nil, 60, 0) // FirstRunAt nil - runner := NewPeriodicRunner(store, nil, nil) - periodic, _ := ps.Get() - if runner.autoStopIfMaxDurationReached("s1", periodic, ps, time.Now()) { + runner := NewLoopRunner(store, nil, nil) + loop, _ := ps.Get() + if runner.autoStopIfMaxDurationReached("s1", loop, ps, time.Now()) { t.Fatal("autoStopIfMaxDurationReached() = true, want false (FirstRunAt nil)") } }) @@ -1147,17 +1147,17 @@ func TestPeriodicRunner_autoStopIfMaxDurationReached(t *testing.T) { recent := time.Now().Add(-1 * time.Second) ps := newDurationCappedSession(t, store, "s1", &recent, 3600, 0) // 1h cap, 1s elapsed - runner := NewPeriodicRunner(store, nil, nil) - periodic, _ := ps.Get() - if runner.autoStopIfMaxDurationReached("s1", periodic, ps, time.Now()) { + runner := NewLoopRunner(store, nil, nil) + loop, _ := ps.Get() + if runner.autoStopIfMaxDurationReached("s1", loop, ps, time.Now()) { t.Fatal("autoStopIfMaxDurationReached() = true, want false (within cap)") } }) - t.Run("nil periodic returns false", func(t *testing.T) { - runner := NewPeriodicRunner(nil, nil, nil) + t.Run("nil loop returns false", func(t *testing.T) { + runner := NewLoopRunner(nil, nil, nil) if runner.autoStopIfMaxDurationReached("s1", nil, nil, time.Now()) { - t.Fatal("autoStopIfMaxDurationReached() = true, want false (nil periodic)") + t.Fatal("autoStopIfMaxDurationReached() = true, want false (nil loop)") } }) @@ -1171,24 +1171,24 @@ func TestPeriodicRunner_autoStopIfMaxDurationReached(t *testing.T) { past := time.Now().Add(-2 * time.Hour) // maxIterations=10 (count=0, plenty left) but maxDuration=60s is exceeded. ps := newDurationCappedSession(t, store, "s1", &past, 60, 10) - runner := NewPeriodicRunner(store, nil, nil) - periodic, _ := ps.Get() - if periodic.ReachedMaxIterations() { + runner := NewLoopRunner(store, nil, nil) + loop, _ := ps.Get() + if loop.ReachedMaxIterations() { t.Fatal("precondition failed: ReachedMaxIterations() = true, want false") } - if !runner.autoStopIfMaxDurationReached("s1", periodic, ps, time.Now()) { + if !runner.autoStopIfMaxDurationReached("s1", loop, ps, time.Now()) { t.Fatal("autoStopIfMaxDurationReached() = false, want true (duration cap wins)") } final, _ := ps.Get() if final.Enabled { - t.Error("periodic still enabled, want disabled (duration cap reached first)") + t.Error("loop still enabled, want disabled (duration cap reached first)") } }) } -// TestPeriodicRunner_fireOnCompletion_MaxDurationAutoStops verifies the on-completion +// TestLoopRunner_fireOnCompletion_MaxDurationAutoStops verifies the on-completion // firing path auto-stops (without delivering) once the wall-clock cap is exceeded. -func TestPeriodicRunner_fireOnCompletion_MaxDurationAutoStops(t *testing.T) { +func TestLoopRunner_fireOnCompletion_MaxDurationAutoStops(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1198,9 +1198,9 @@ func TestPeriodicRunner_fireOnCompletion_MaxDurationAutoStops(t *testing.T) { past := time.Now().Add(-2 * time.Hour) ps := newDurationCappedSession(t, store, "s1", &past, 60, 0) - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) called := false - runner.SetOnPeriodicAutoStopped(func(id string, p *session.PeriodicPrompt) { called = true }) + runner.SetOnLoopAutoStopped(func(id string, p *session.LoopPrompt) { called = true }) runner.fireOnCompletion("s1") @@ -1209,20 +1209,20 @@ func TestPeriodicRunner_fireOnCompletion_MaxDurationAutoStops(t *testing.T) { t.Fatalf("ps.Get() error = %v", err) } if final.Enabled { - t.Error("fireOnCompletion did not auto-stop on maxDuration, periodic still enabled") + t.Error("fireOnCompletion did not auto-stop on maxDuration, loop still enabled") } if !called { - t.Error("onPeriodicAutoStopped not called from fireOnCompletion") + t.Error("onLoopAutoStopped not called from fireOnCompletion") } if got := countCompletionTimers(runner); got != 0 { t.Errorf("completionTimers = %d, want 0", got) } } -// TestPeriodicRunner_PromptResolveFailure_AutoPauses verifies that after -// MaxPromptResolveFailures consecutive resolve failures the periodic config is -// disabled on disk and onPeriodicAutoStopped is fired exactly once. -func TestPeriodicRunner_PromptResolveFailure_AutoPauses(t *testing.T) { +// TestLoopRunner_PromptResolveFailure_AutoPauses verifies that after +// MaxPromptResolveFailures consecutive resolve failures the loop config is +// disabled on disk and onLoopAutoStopped is fired exactly once. +func TestLoopRunner_PromptResolveFailure_AutoPauses(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1233,60 +1233,60 @@ func TestPeriodicRunner_PromptResolveFailure_AutoPauses(t *testing.T) { if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic("resolve-fail") - if err := periodicStore.Set(&session.PeriodicPrompt{ + loopStore := store.Loop("resolve-fail") + if err := loopStore.Set(&session.LoopPrompt{ PromptName: "nonexistent-prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } resolveErr := errors.New("prompt not found") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.SetPromptResolver(func(name, dir string) (string, error) { return "", resolveErr }) callCount := 0 - runner.SetOnPeriodicAutoStopped(func(id string, p *session.PeriodicPrompt) { + runner.SetOnLoopAutoStopped(func(id string, p *session.LoopPrompt) { callCount++ if id != "resolve-fail" { - t.Errorf("onPeriodicAutoStopped: id=%q, want resolve-fail", id) + t.Errorf("onLoopAutoStopped: id=%q, want resolve-fail", id) } if p.Enabled { - t.Error("onPeriodicAutoStopped: periodic.Enabled = true, want false") + t.Error("onLoopAutoStopped: loop.Enabled = true, want false") } }) - periodic, _ := periodicStore.Get() + loop, _ := loopStore.Get() // First MaxPromptResolveFailures-1 calls must not disable. for i := 1; i < MaxPromptResolveFailures; i++ { - runner.handlePromptResolveFailure("resolve-fail", meta.Name, periodic, periodicStore, resolveErr) - p, _ := periodicStore.Get() + runner.handlePromptResolveFailure("resolve-fail", meta.Name, loop, loopStore, resolveErr) + p, _ := loopStore.Get() if !p.Enabled { - t.Fatalf("periodic disabled after %d failures, want still enabled", i) + t.Fatalf("loop disabled after %d failures, want still enabled", i) } if callCount != 0 { - t.Fatalf("onPeriodicAutoStopped called after %d failures, want 0", i) + t.Fatalf("onLoopAutoStopped called after %d failures, want 0", i) } } // The MaxPromptResolveFailures-th call must disable and fire callback exactly once. - runner.handlePromptResolveFailure("resolve-fail", meta.Name, periodic, periodicStore, resolveErr) + runner.handlePromptResolveFailure("resolve-fail", meta.Name, loop, loopStore, resolveErr) if callCount != 1 { - t.Errorf("onPeriodicAutoStopped called %d times, want 1", callCount) + t.Errorf("onLoopAutoStopped called %d times, want 1", callCount) } - final, _ := periodicStore.Get() + final, _ := loopStore.Get() if final.Enabled { - t.Error("periodic still enabled after auto-pause, want disabled") + t.Error("loop still enabled after auto-pause, want disabled") } } -// TestPeriodicRunner_PromptResolveFailure_CounterReset verifies that a successful +// TestLoopRunner_PromptResolveFailure_CounterReset verifies that a successful // resolve resets the failure counter so prior failures don't accumulate. -func TestPeriodicRunner_PromptResolveFailure_CounterReset(t *testing.T) { +func TestLoopRunner_PromptResolveFailure_CounterReset(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1297,26 +1297,26 @@ func TestPeriodicRunner_PromptResolveFailure_CounterReset(t *testing.T) { if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic("reset-test") - if err := periodicStore.Set(&session.PeriodicPrompt{ + loopStore := store.Loop("reset-test") + if err := loopStore.Set(&session.LoopPrompt{ PromptName: "maybe-missing", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } resolveErr := errors.New("not found") - runner := NewPeriodicRunner(store, nil, nil) - runner.SetOnPeriodicAutoStopped(func(_ string, _ *session.PeriodicPrompt) { - t.Error("onPeriodicAutoStopped called unexpectedly after counter reset") + runner := NewLoopRunner(store, nil, nil) + runner.SetOnLoopAutoStopped(func(_ string, _ *session.LoopPrompt) { + t.Error("onLoopAutoStopped called unexpectedly after counter reset") }) - periodic, _ := periodicStore.Get() + loop, _ := loopStore.Get() // Accumulate MaxPromptResolveFailures-1 failures. for i := 1; i < MaxPromptResolveFailures; i++ { - runner.handlePromptResolveFailure("reset-test", meta.Name, periodic, periodicStore, resolveErr) + runner.handlePromptResolveFailure("reset-test", meta.Name, loop, loopStore, resolveErr) } // Simulate a successful resolution: reset the counter (mirrors checkSession success path). @@ -1326,21 +1326,21 @@ func TestPeriodicRunner_PromptResolveFailure_CounterReset(t *testing.T) { // Now accumulate MaxPromptResolveFailures-1 more failures — should not trigger auto-pause. for i := 1; i < MaxPromptResolveFailures; i++ { - runner.handlePromptResolveFailure("reset-test", meta.Name, periodic, periodicStore, resolveErr) + runner.handlePromptResolveFailure("reset-test", meta.Name, loop, loopStore, resolveErr) } - // Verify the periodic is still enabled (counter was reset, threshold not reached again). - final, _ := periodicStore.Get() + // Verify the loop is still enabled (counter was reset, threshold not reached again). + final, _ := loopStore.Get() if !final.Enabled { - t.Error("periodic disabled unexpectedly; counter reset did not clear failure count") + t.Error("loop disabled unexpectedly; counter reset did not clear failure count") } } -// TestPeriodicRunner_RunOnce_MaxDurationAutoStops verifies the schedule (poll) path -// auto-stops a due periodic once the wall-clock cap is exceeded, before any delivery +// TestLoopRunner_RunOnce_MaxDurationAutoStops verifies the schedule (poll) path +// auto-stops a due loop once the wall-clock cap is exceeded, before any delivery // or session resume. With a nil session manager, reaching the cap must neither deliver // nor error — it disables the config and broadcasts the auto-stop. -func TestPeriodicRunner_RunOnce_MaxDurationAutoStops(t *testing.T) { +func TestLoopRunner_RunOnce_MaxDurationAutoStops(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1352,45 +1352,45 @@ func TestPeriodicRunner_RunOnce_MaxDurationAutoStops(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic("sched") - if err := periodicStore.Set(&session.PeriodicPrompt{ + loopStore := store.Loop("sched") + if err := loopStore.Set(&session.LoopPrompt{ Prompt: "Test prompt", Frequency: session.Frequency{Value: 5, Unit: session.FrequencyMinutes}, Enabled: true, Trigger: session.TriggerSchedule, MaxDurationSeconds: 60, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } - // Force the periodic due (past NextScheduledAt) and anchored 2h ago so the cap is exceeded. - got, _ := periodicStore.Get() + // Force the loop due (past NextScheduledAt) and anchored 2h ago so the cap is exceeded. + got, _ := loopStore.Get() pastDue := time.Now().UTC().Add(-1 * time.Hour) anchor := time.Now().UTC().Add(-2 * time.Hour) got.NextScheduledAt = &pastDue got.FirstRunAt = &anchor - periodicPath := store.SessionDir("sched") + "/periodic.json" - if err := writeTestPeriodicFile(periodicPath, got); err != nil { - t.Fatalf("writeTestPeriodicFile() error = %v", err) + loopPath := store.SessionDir("sched") + "/loop.json" + if err := writeTestLoopFile(loopPath, got); err != nil { + t.Fatalf("writeTestLoopFile() error = %v", err) } // Empty session manager: GetSession returns nil safely. The duration check in // checkSession fires before any resume attempt, so nothing is delivered. sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) called := false - runner.SetOnPeriodicAutoStopped(func(id string, p *session.PeriodicPrompt) { called = true }) + runner.SetOnLoopAutoStopped(func(id string, p *session.LoopPrompt) { called = true }) delivered, skipped, errored := runner.RunOnce() if delivered != 0 || skipped != 0 || errored != 0 { t.Errorf("RunOnce() = (%d, %d, %d), want (0, 0, 0) (auto-stop, no delivery)", delivered, skipped, errored) } if !called { - t.Error("onPeriodicAutoStopped not called from schedule path") + t.Error("onLoopAutoStopped not called from schedule path") } - final, _ := periodicStore.Get() + final, _ := loopStore.Get() if final.Enabled { - t.Error("schedule-path periodic still enabled after maxDuration, want disabled") + t.Error("schedule-path loop still enabled after maxDuration, want disabled") } } @@ -1398,20 +1398,20 @@ func TestPeriodicRunner_RunOnce_MaxDurationAutoStops(t *testing.T) { // BootstrapOnCompletion Tests // ============================================================================= -// TestPeriodicRunner_BootstrapOnCompletion_NilStore verifies that BootstrapOnCompletion +// TestLoopRunner_BootstrapOnCompletion_NilStore verifies that BootstrapOnCompletion // is a no-op when the runner has no session store. -func TestPeriodicRunner_BootstrapOnCompletion_NilStore(t *testing.T) { - runner := NewPeriodicRunner(nil, nil, nil) +func TestLoopRunner_BootstrapOnCompletion_NilStore(t *testing.T) { + runner := NewLoopRunner(nil, nil, nil) // Must not panic. runner.BootstrapOnCompletion("any-session") } -// TestPeriodicRunner_BootstrapOnCompletion_FreshSession_AttemptsDelivery verifies that a +// TestLoopRunner_BootstrapOnCompletion_FreshSession_AttemptsDelivery verifies that a // fresh enabled onCompletion session (IterationCount==0, LastSentAt==nil) causes // BootstrapOnCompletion to attempt immediate delivery via TriggerNow with no timer delay. // With no session manager, TriggerNow fails gracefully; we assert no panic, no timer // is armed (delivery is synchronous, not timer-deferred), and the config stays enabled. -func TestPeriodicRunner_BootstrapOnCompletion_FreshSession_AttemptsDelivery(t *testing.T) { +func TestLoopRunner_BootstrapOnCompletion_FreshSession_AttemptsDelivery(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1420,7 +1420,7 @@ func TestPeriodicRunner_BootstrapOnCompletion_FreshSession_AttemptsDelivery(t *t newOnCompletionSession(t, store, "s1", 30) // delay_seconds=30, must NOT apply to first run - runner := NewPeriodicRunner(store, nil, nil) // nil SM → TriggerNow returns ErrSessionManagerNotAvailable + runner := NewLoopRunner(store, nil, nil) // nil SM → TriggerNow returns ErrSessionManagerNotAvailable runner.BootstrapOnCompletion("s1") // No timer should be armed — delivery is attempted synchronously, not via timer. @@ -1428,21 +1428,21 @@ func TestPeriodicRunner_BootstrapOnCompletion_FreshSession_AttemptsDelivery(t *t t.Errorf("completionTimers = %d, want 0 (bootstrap must not arm a timer)", got) } - // Periodic config must remain enabled — the failed TriggerNow must not disable it. - periodicStore := store.Periodic("s1") - p, err := periodicStore.Get() + // Loop config must remain enabled — the failed TriggerNow must not disable it. + loopStore := store.Loop("s1") + p, err := loopStore.Get() if err != nil { - t.Fatalf("periodicStore.Get() error = %v", err) + t.Fatalf("loopStore.Get() error = %v", err) } if !p.Enabled { - t.Error("periodic.Enabled = false after failed bootstrap, want true") + t.Error("loop.Enabled = false after failed bootstrap, want true") } } -// TestPeriodicRunner_BootstrapOnCompletion_AlreadyRan_Noop verifies that +// TestLoopRunner_BootstrapOnCompletion_AlreadyRan_Noop verifies that // BootstrapOnCompletion is a no-op when the session has already run at least once // (IterationCount > 0), preventing double delivery on restart. -func TestPeriodicRunner_BootstrapOnCompletion_AlreadyRan_Noop(t *testing.T) { +func TestLoopRunner_BootstrapOnCompletion_AlreadyRan_Noop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1452,22 +1452,22 @@ func TestPeriodicRunner_BootstrapOnCompletion_AlreadyRan_Noop(t *testing.T) { newOnCompletionSession(t, store, "s1", 0) // Simulate a completed first run by calling RecordSent. - periodicStore := store.Periodic("s1") - if err := periodicStore.RecordSent(); err != nil { + loopStore := store.Loop("s1") + if err := loopStore.RecordSent(); err != nil { t.Fatalf("RecordSent() error = %v", err) } // Verify IterationCount advanced. - p, err := periodicStore.Get() + p, err := loopStore.Get() if err != nil { - t.Fatalf("periodicStore.Get() error = %v", err) + t.Fatalf("loopStore.Get() error = %v", err) } if p.IterationCount == 0 { t.Fatal("IterationCount = 0 after RecordSent, expected > 0") } // BootstrapOnCompletion must be a no-op (session already ran). - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.BootstrapOnCompletion("s1") // No timer armed, no panic. @@ -1476,9 +1476,9 @@ func TestPeriodicRunner_BootstrapOnCompletion_AlreadyRan_Noop(t *testing.T) { } } -// TestPeriodicRunner_BootstrapOnCompletion_Disabled_Noop verifies that -// BootstrapOnCompletion is a no-op for a disabled periodic config. -func TestPeriodicRunner_BootstrapOnCompletion_Disabled_Noop(t *testing.T) { +// TestLoopRunner_BootstrapOnCompletion_Disabled_Noop verifies that +// BootstrapOnCompletion is a no-op for a disabled loop config. +func TestLoopRunner_BootstrapOnCompletion_Disabled_Noop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1489,15 +1489,15 @@ func TestPeriodicRunner_BootstrapOnCompletion_Disabled_Noop(t *testing.T) { if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - if err := store.Periodic("s1").Set(&session.PeriodicPrompt{ + if err := store.Loop("s1").Set(&session.LoopPrompt{ Prompt: "Test", Enabled: false, // disabled Trigger: session.TriggerOnCompletion, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.BootstrapOnCompletion("s1") // must be a no-op if got := countCompletionTimers(runner); got != 0 { @@ -1505,10 +1505,10 @@ func TestPeriodicRunner_BootstrapOnCompletion_Disabled_Noop(t *testing.T) { } } -// TestPeriodicRunner_BootstrapOnCompletion_ScheduleTrigger_Noop verifies that +// TestLoopRunner_BootstrapOnCompletion_ScheduleTrigger_Noop verifies that // BootstrapOnCompletion is a no-op for schedule-trigger configs (it targets // onCompletion only). -func TestPeriodicRunner_BootstrapOnCompletion_ScheduleTrigger_Noop(t *testing.T) { +func TestLoopRunner_BootstrapOnCompletion_ScheduleTrigger_Noop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1519,16 +1519,16 @@ func TestPeriodicRunner_BootstrapOnCompletion_ScheduleTrigger_Noop(t *testing.T) if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - if err := store.Periodic("s1").Set(&session.PeriodicPrompt{ + if err := store.Loop("s1").Set(&session.LoopPrompt{ Prompt: "Test", Enabled: true, Trigger: session.TriggerSchedule, // schedule, not onCompletion Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.BootstrapOnCompletion("s1") // must be a no-op if got := countCompletionTimers(runner); got != 0 { @@ -1536,10 +1536,10 @@ func TestPeriodicRunner_BootstrapOnCompletion_ScheduleTrigger_Noop(t *testing.T) } } -// TestPeriodicRunner_BootstrapOnCompletion_TimerPending_Noop verifies that +// TestLoopRunner_BootstrapOnCompletion_TimerPending_Noop verifies that // BootstrapOnCompletion is a no-op when an onCompletion timer is already pending, // preventing double-firing within the same process lifetime. -func TestPeriodicRunner_BootstrapOnCompletion_TimerPending_Noop(t *testing.T) { +func TestLoopRunner_BootstrapOnCompletion_TimerPending_Noop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1548,7 +1548,7 @@ func TestPeriodicRunner_BootstrapOnCompletion_TimerPending_Noop(t *testing.T) { newOnCompletionSession(t, store, "s1", 0) - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) // Arm a timer to simulate a pending on-completion run. runner.armCompletionTimer("s1", time.Hour) defer runner.cancelCompletionTimer("s1") @@ -1566,13 +1566,13 @@ func TestPeriodicRunner_BootstrapOnCompletion_TimerPending_Noop(t *testing.T) { } } -// TestPeriodicRunner_RunOnce_OnCompletion_BootstrapsFirstRun verifies that the +// TestLoopRunner_RunOnce_OnCompletion_BootstrapsFirstRun verifies that the // poll loop (RunOnce / checkSession) bootstraps a fresh onCompletion session by // calling BootstrapOnCompletion rather than skipping the session entirely. // With no session manager, TriggerNow fails gracefully and RunOnce returns (0,0,0). // The important assertion: no error is counted (bootstrap failure is not an error), // and no timer is armed (bootstrap is synchronous, not timer-deferred). -func TestPeriodicRunner_RunOnce_OnCompletion_BootstrapsFirstRun(t *testing.T) { +func TestLoopRunner_RunOnce_OnCompletion_BootstrapsFirstRun(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1581,7 +1581,7 @@ func TestPeriodicRunner_RunOnce_OnCompletion_BootstrapsFirstRun(t *testing.T) { newOnCompletionSession(t, store, "s1", 30) // delay_seconds=30 must NOT defer the first run - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) delivered, skipped, errored := runner.RunOnce() // bootstrap failures are best-effort and not counted as poll errors. @@ -1601,20 +1601,20 @@ func TestPeriodicRunner_RunOnce_OnCompletion_BootstrapsFirstRun(t *testing.T) { // newOnCompletionSessionWithRan creates an onCompletion session that has already // run at least once (IterationCount > 0), simulating a loop that is in-progress. -func newOnCompletionSessionWithRan(t *testing.T, store *session.Store, sessionID string, delaySeconds int) *session.PeriodicStore { +func newOnCompletionSessionWithRan(t *testing.T, store *session.Store, sessionID string, delaySeconds int) *session.LoopStore { t.Helper() newOnCompletionSession(t, store, sessionID, delaySeconds) - ps := store.Periodic(sessionID) + ps := store.Loop(sessionID) if err := ps.RecordSent(); err != nil { t.Fatalf("RecordSent() error = %v", err) } return ps } -// TestPeriodicRunner_RecoverStalledOnCompletion_ReArmsStalledLoop verifies that +// TestLoopRunner_RecoverStalledOnCompletion_ReArmsStalledLoop verifies that // recoverStalledOnCompletion arms a completion timer when the loop has run at // least once, no timer is currently pending, and the session is not prompting. -func TestPeriodicRunner_RecoverStalledOnCompletion_ReArmsStalledLoop(t *testing.T) { +func TestLoopRunner_RecoverStalledOnCompletion_ReArmsStalledLoop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1623,8 +1623,8 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_ReArmsStalledLoop(t *testing. ps := newOnCompletionSessionWithRan(t, store, "s1", 3600) // long delay so timer doesn't fire - runner := NewPeriodicRunner(store, nil, nil) - runner.SetMinPeriodicCompletionDelaySeconds(0) // no floor so we can assert timer presence easily + runner := NewLoopRunner(store, nil, nil) + runner.SetMinLoopCompletionDelaySeconds(0) // no floor so we can assert timer presence easily // Precondition: no timer pending. if got := countCompletionTimers(runner); got != 0 { @@ -1632,12 +1632,12 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_ReArmsStalledLoop(t *testing. } meta := session.Metadata{SessionID: "s1"} - periodic, err := ps.Get() + loop, err := ps.Get() if err != nil { t.Fatalf("ps.Get() error = %v", err) } - runner.recoverStalledOnCompletion(meta, periodic) + runner.recoverStalledOnCompletion(meta, loop) defer runner.cancelCompletionTimer("s1") // A timer must now be armed — the stall was detected and the loop re-armed. @@ -1646,10 +1646,10 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_ReArmsStalledLoop(t *testing. } } -// TestPeriodicRunner_RecoverStalledOnCompletion_TimerPending_Noop verifies that +// TestLoopRunner_RecoverStalledOnCompletion_TimerPending_Noop verifies that // recoverStalledOnCompletion is a no-op when a timer is already pending, i.e. the // loop is healthy and does not need recovery. -func TestPeriodicRunner_RecoverStalledOnCompletion_TimerPending_Noop(t *testing.T) { +func TestLoopRunner_RecoverStalledOnCompletion_TimerPending_Noop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1658,7 +1658,7 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_TimerPending_Noop(t *testing. ps := newOnCompletionSessionWithRan(t, store, "s1", 0) - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) // Pre-arm a timer (simulates a healthy loop). runner.armCompletionTimer("s1", time.Hour) @@ -1670,12 +1670,12 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_TimerPending_Noop(t *testing. runner.completionTimersMu.Unlock() meta := session.Metadata{SessionID: "s1"} - periodic, err := ps.Get() + loop, err := ps.Get() if err != nil { t.Fatalf("ps.Get() error = %v", err) } - runner.recoverStalledOnCompletion(meta, periodic) + runner.recoverStalledOnCompletion(meta, loop) // Timer must be unchanged — recover must not replace a healthy pending timer. runner.completionTimersMu.Lock() @@ -1690,10 +1690,10 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_TimerPending_Noop(t *testing. } } -// TestPeriodicRunner_RecoverStalledOnCompletion_FreshLoop_Noop verifies that +// TestLoopRunner_RecoverStalledOnCompletion_FreshLoop_Noop verifies that // recoverStalledOnCompletion is a no-op for a fresh loop (IterationCount==0, // LastSentAt==nil). Fresh loops are the responsibility of BootstrapOnCompletion. -func TestPeriodicRunner_RecoverStalledOnCompletion_FreshLoop_Noop(t *testing.T) { +func TestLoopRunner_RecoverStalledOnCompletion_FreshLoop_Noop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1702,22 +1702,22 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_FreshLoop_Noop(t *testing.T) // Fresh session: no RecordSent call, so IterationCount==0 and LastSentAt==nil. newOnCompletionSession(t, store, "s1", 0) - ps := store.Periodic("s1") + ps := store.Loop("s1") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) meta := session.Metadata{SessionID: "s1"} - periodic, err := ps.Get() + loop, err := ps.Get() if err != nil { t.Fatalf("ps.Get() error = %v", err) } // Precondition: IterationCount==0, LastSentAt==nil. - if periodic.IterationCount != 0 || periodic.LastSentAt != nil { - t.Fatalf("precondition failed: IterationCount=%d LastSentAt=%v", periodic.IterationCount, periodic.LastSentAt) + if loop.IterationCount != 0 || loop.LastSentAt != nil { + t.Fatalf("precondition failed: IterationCount=%d LastSentAt=%v", loop.IterationCount, loop.LastSentAt) } - runner.recoverStalledOnCompletion(meta, periodic) + runner.recoverStalledOnCompletion(meta, loop) // No timer must be armed — bootstrap, not recover, handles fresh loops. if got := countCompletionTimers(runner); got != 0 { @@ -1725,10 +1725,10 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_FreshLoop_Noop(t *testing.T) } } -// TestPeriodicRunner_RecoverStalledOnCompletion_ReachedMaxDuration_Noop verifies that +// TestLoopRunner_RecoverStalledOnCompletion_ReachedMaxDuration_Noop verifies that // recoverStalledOnCompletion does not re-arm a loop that has exceeded its wall-clock cap, // so the auto-stop logic in fireOnCompletion can gracefully terminate the loop. -func TestPeriodicRunner_RecoverStalledOnCompletion_ReachedMaxDuration_Noop(t *testing.T) { +func TestLoopRunner_RecoverStalledOnCompletion_ReachedMaxDuration_Noop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1744,20 +1744,20 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_ReachedMaxDuration_Noop(t *te t.Fatalf("RecordSent() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) meta := session.Metadata{SessionID: "s1"} - periodic, err := ps.Get() + loop, err := ps.Get() if err != nil { t.Fatalf("ps.Get() error = %v", err) } // Precondition: cap is reached. - if !periodic.ReachedMaxDuration(time.Now()) { + if !loop.ReachedMaxDuration(time.Now()) { t.Fatal("precondition failed: ReachedMaxDuration() = false, want true") } - runner.recoverStalledOnCompletion(meta, periodic) + runner.recoverStalledOnCompletion(meta, loop) // No timer must be armed — capped loops must not be kept alive. if got := countCompletionTimers(runner); got != 0 { @@ -1769,9 +1769,9 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_ReachedMaxDuration_Noop(t *te // StoppedReason tests // ============================================================================= -// TestPeriodicRunner_AutoStopMaxDuration_SetsStoppedReason verifies that reaching +// TestLoopRunner_AutoStopMaxDuration_SetsStoppedReason verifies that reaching // the maxDuration cap via the schedule path sets StoppedReason=maxDuration. -func TestPeriodicRunner_AutoStopMaxDuration_SetsStoppedReason(t *testing.T) { +func TestLoopRunner_AutoStopMaxDuration_SetsStoppedReason(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1783,32 +1783,32 @@ func TestPeriodicRunner_AutoStopMaxDuration_SetsStoppedReason(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic("dur-sched") - if err := periodicStore.Set(&session.PeriodicPrompt{ + loopStore := store.Loop("dur-sched") + if err := loopStore.Set(&session.LoopPrompt{ Prompt: "Test", Frequency: session.Frequency{Value: 5, Unit: session.FrequencyMinutes}, Enabled: true, MaxDurationSeconds: 60, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } // Force past-due and anchored 2h ago so the cap is exceeded. - got, _ := periodicStore.Get() + got, _ := loopStore.Get() pastDue := time.Now().UTC().Add(-1 * time.Hour) anchor := time.Now().UTC().Add(-2 * time.Hour) got.NextScheduledAt = &pastDue got.FirstRunAt = &anchor - periodicPath := store.SessionDir("dur-sched") + "/periodic.json" - if err := writeTestPeriodicFile(periodicPath, got); err != nil { - t.Fatalf("writeTestPeriodicFile() error = %v", err) + loopPath := store.SessionDir("dur-sched") + "/loop.json" + if err := writeTestLoopFile(loopPath, got); err != nil { + t.Fatalf("writeTestLoopFile() error = %v", err) } sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) runner.RunOnce() - final, _ := periodicStore.Get() + final, _ := loopStore.Get() if final.StoppedReason != session.StoppedReasonMaxDuration { t.Errorf("StoppedReason = %q, want %q", final.StoppedReason, session.StoppedReasonMaxDuration) } @@ -1817,9 +1817,9 @@ func TestPeriodicRunner_AutoStopMaxDuration_SetsStoppedReason(t *testing.T) { } } -// TestPeriodicRunner_AutoStopMaxIterations_SetsStoppedReason verifies the per-prompt +// TestLoopRunner_AutoStopMaxIterations_SetsStoppedReason verifies the per-prompt // MaxIterations cap sets StoppedReason=maxIterations. -func TestPeriodicRunner_AutoStopMaxIterations_SetsStoppedReason(t *testing.T) { +func TestLoopRunner_AutoStopMaxIterations_SetsStoppedReason(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1831,17 +1831,17 @@ func TestPeriodicRunner_AutoStopMaxIterations_SetsStoppedReason(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic("iter-cap") + loopStore := store.Loop("iter-cap") // MaxIterations=2, IterationCount=2 → already reached cap. - if err := periodicStore.Set(&session.PeriodicPrompt{ + if err := loopStore.Set(&session.LoopPrompt{ Prompt: "Test", Frequency: session.Frequency{Value: 5, Unit: session.FrequencyMinutes}, Enabled: true, MaxIterations: 2, IterationCount: 2, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } // Use the internal MarkStopped path directly (via autoStopIfMaxDurationReached is N/A here; @@ -1852,19 +1852,19 @@ func TestPeriodicRunner_AutoStopMaxIterations_SetsStoppedReason(t *testing.T) { if perPromptReached { stoppedReason = session.StoppedReasonMaxIterations } - if err := periodicStore.MarkStopped(stoppedReason); err != nil { + if err := loopStore.MarkStopped(stoppedReason); err != nil { t.Fatalf("MarkStopped() error = %v", err) } - final, _ := periodicStore.Get() + final, _ := loopStore.Get() if final.StoppedReason != session.StoppedReasonMaxIterations { t.Errorf("StoppedReason = %q, want %q", final.StoppedReason, session.StoppedReasonMaxIterations) } } -// TestPeriodicRunner_AutoStopIterationSafeguard_SetsStoppedReason verifies the global +// TestLoopRunner_AutoStopIterationSafeguard_SetsStoppedReason verifies the global // safeguard path sets StoppedReason=iterationSafeguard. -func TestPeriodicRunner_AutoStopIterationSafeguard_SetsStoppedReason(t *testing.T) { +func TestLoopRunner_AutoStopIterationSafeguard_SetsStoppedReason(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1876,30 +1876,30 @@ func TestPeriodicRunner_AutoStopIterationSafeguard_SetsStoppedReason(t *testing. t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic("safeguard") - if err := periodicStore.Set(&session.PeriodicPrompt{ + loopStore := store.Loop("safeguard") + if err := loopStore.Set(&session.LoopPrompt{ Prompt: "Test", Frequency: session.Frequency{Value: 5, Unit: session.FrequencyMinutes}, Enabled: true, // MaxIterations=0 (unlimited) → only the global backstop triggers. }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } // Simulate the safeguard path: perPromptReached=false → iterationSafeguard. - if err := periodicStore.MarkStopped(session.StoppedReasonIterationSafeguard); err != nil { + if err := loopStore.MarkStopped(session.StoppedReasonIterationSafeguard); err != nil { t.Fatalf("MarkStopped() error = %v", err) } - final, _ := periodicStore.Get() + final, _ := loopStore.Get() if final.StoppedReason != session.StoppedReasonIterationSafeguard { t.Errorf("StoppedReason = %q, want %q", final.StoppedReason, session.StoppedReasonIterationSafeguard) } } -// TestPeriodicRunner_AutoStopPromptUnresolved_SetsStoppedReason verifies that +// TestLoopRunner_AutoStopPromptUnresolved_SetsStoppedReason verifies that // handlePromptResolveFailure sets StoppedReason=promptUnresolved after MaxPromptResolveFailures. -func TestPeriodicRunner_AutoStopPromptUnresolved_SetsStoppedReason(t *testing.T) { +func TestLoopRunner_AutoStopPromptUnresolved_SetsStoppedReason(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1911,27 +1911,27 @@ func TestPeriodicRunner_AutoStopPromptUnresolved_SetsStoppedReason(t *testing.T) t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic("unresolved") - if err := periodicStore.Set(&session.PeriodicPrompt{ + loopStore := store.Loop("unresolved") + if err := loopStore.Set(&session.LoopPrompt{ PromptName: "missing-prompt", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) resolveErr := errors.New("prompt not found") - periodic, _ := periodicStore.Get() + loop, _ := loopStore.Get() // Trigger exactly MaxPromptResolveFailures failures to trip the auto-pause. for i := 0; i < MaxPromptResolveFailures; i++ { - runner.handlePromptResolveFailure("unresolved", meta.Name, periodic, periodicStore, resolveErr) + runner.handlePromptResolveFailure("unresolved", meta.Name, loop, loopStore, resolveErr) } - final, _ := periodicStore.Get() + final, _ := loopStore.Get() if final.Enabled { - t.Error("periodic still enabled after MaxPromptResolveFailures, want disabled") + t.Error("loop still enabled after MaxPromptResolveFailures, want disabled") } if final.StoppedReason != session.StoppedReasonPromptUnresolved { t.Errorf("StoppedReason = %q, want %q", final.StoppedReason, session.StoppedReasonPromptUnresolved) @@ -1941,9 +1941,9 @@ func TestPeriodicRunner_AutoStopPromptUnresolved_SetsStoppedReason(t *testing.T) } } -// TestPeriodicRunner_AutoStopResumeFailures_SetsStoppedReason verifies that the +// TestLoopRunner_AutoStopResumeFailures_SetsStoppedReason verifies that the // resume-failures path persists StoppedReason=resumeFailures before archiving. -func TestPeriodicRunner_AutoStopResumeFailures_SetsStoppedReason(t *testing.T) { +func TestLoopRunner_AutoStopResumeFailures_SetsStoppedReason(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1955,21 +1955,21 @@ func TestPeriodicRunner_AutoStopResumeFailures_SetsStoppedReason(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - periodicStore := store.Periodic("resume-fail") - if err := periodicStore.Set(&session.PeriodicPrompt{ + loopStore := store.Loop("resume-fail") + if err := loopStore.Set(&session.LoopPrompt{ Prompt: "Test", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } // Simulate the resume-failures path directly. - if err := periodicStore.MarkStopped(session.StoppedReasonResumeFailures); err != nil { + if err := loopStore.MarkStopped(session.StoppedReasonResumeFailures); err != nil { t.Fatalf("MarkStopped() error = %v", err) } - final, _ := periodicStore.Get() + final, _ := loopStore.Get() if final.StoppedReason != session.StoppedReasonResumeFailures { t.Errorf("StoppedReason = %q, want %q", final.StoppedReason, session.StoppedReasonResumeFailures) } @@ -1978,10 +1978,10 @@ func TestPeriodicRunner_AutoStopResumeFailures_SetsStoppedReason(t *testing.T) { } } -// TestPeriodicRunner_RecoverStalledOnCompletion_MaxDuration_AutoStops verifies that +// TestLoopRunner_RecoverStalledOnCompletion_MaxDuration_AutoStops verifies that // recoverStalledOnCompletion now routes through autoStopIfMaxDurationReached when the // cap is exceeded, ending with Enabled=false and StoppedReason=maxDuration. -func TestPeriodicRunner_RecoverStalledOnCompletion_MaxDuration_AutoStops(t *testing.T) { +func TestLoopRunner_RecoverStalledOnCompletion_MaxDuration_AutoStops(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -1996,36 +1996,36 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_MaxDuration_AutoStops(t *test } stopped := false - runner := NewPeriodicRunner(store, nil, nil) - runner.SetOnPeriodicAutoStopped(func(_ string, _ *session.PeriodicPrompt) { stopped = true }) + runner := NewLoopRunner(store, nil, nil) + runner.SetOnLoopAutoStopped(func(_ string, _ *session.LoopPrompt) { stopped = true }) meta := session.Metadata{SessionID: "s1"} - periodic, err := ps.Get() + loop, err := ps.Get() if err != nil { t.Fatalf("ps.Get() error = %v", err) } - runner.recoverStalledOnCompletion(meta, periodic) + runner.recoverStalledOnCompletion(meta, loop) if got := countCompletionTimers(runner); got != 0 { t.Errorf("completionTimers = %d, want 0 (capped loop must not be re-armed)", got) } if !stopped { - t.Error("onPeriodicAutoStopped not called, want it called for maxDuration auto-stop") + t.Error("onLoopAutoStopped not called, want it called for maxDuration auto-stop") } final, _ := ps.Get() if final.Enabled { - t.Error("periodic still enabled after maxDuration recoverStalledOnCompletion, want disabled") + t.Error("loop still enabled after maxDuration recoverStalledOnCompletion, want disabled") } if final.StoppedReason != session.StoppedReasonMaxDuration { t.Errorf("StoppedReason = %q, want %q", final.StoppedReason, session.StoppedReasonMaxDuration) } } -// TestPeriodicRunner_RecoverStalledOnCompletion_SessionPrompting_Noop verifies that +// TestLoopRunner_RecoverStalledOnCompletion_SessionPrompting_Noop verifies that // recoverStalledOnCompletion is a no-op when the session is currently prompting. // An in-flight turn will re-arm itself on idle completion; recover must not race it. -func TestPeriodicRunner_RecoverStalledOnCompletion_SessionPrompting_Noop(t *testing.T) { +func TestLoopRunner_RecoverStalledOnCompletion_SessionPrompting_Noop(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2039,16 +2039,16 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_SessionPrompting_Noop(t *test mockBS := conversation.NewMinimalBackgroundSessionPrompting("s1", true) sm.AddSessionForTest(mockBS) - runner := NewPeriodicRunner(store, sm, nil) - runner.SetMinPeriodicCompletionDelaySeconds(0) + runner := NewLoopRunner(store, sm, nil) + runner.SetMinLoopCompletionDelaySeconds(0) meta := session.Metadata{SessionID: "s1"} - periodic, err := ps.Get() + loop, err := ps.Get() if err != nil { t.Fatalf("ps.Get() error = %v", err) } - runner.recoverStalledOnCompletion(meta, periodic) + runner.recoverStalledOnCompletion(meta, loop) // No timer must be armed — the in-flight turn handles re-arm on completion. if got := countCompletionTimers(runner); got != 0 { @@ -2060,16 +2060,16 @@ func TestPeriodicRunner_RecoverStalledOnCompletion_SessionPrompting_Noop(t *test // Arguments substitution tests // ============================================================================= -// TestPeriodicRunner_DeliverPrompt_ArgumentsForwardedAndSubstituted verifies that -// the periodic runner correctly resolves a named prompt via promptResolver and -// that the Arguments stored in the periodic config would produce the expected +// TestLoopRunner_DeliverPrompt_ArgumentsForwardedAndSubstituted verifies that +// the loop runner correctly resolves a named prompt via promptResolver and +// that the Arguments stored in the loop config would produce the expected // rendered text when passed through Go-template rendering — the same path taken // by PromptWithMeta before dispatching to ACP. // // The test does NOT require a real ACP connection. deliverPrompt is called // but expected to fail with an ACP-unavailable error (the resolver has already // been invoked by that point, proving the full argument pipeline is wired up). -func TestPeriodicRunner_DeliverPrompt_ArgumentsForwardedAndSubstituted(t *testing.T) { +func TestLoopRunner_DeliverPrompt_ArgumentsForwardedAndSubstituted(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2086,22 +2086,22 @@ func TestPeriodicRunner_DeliverPrompt_ArgumentsForwardedAndSubstituted(t *testin var resolverCalled bool var resolvedName string - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.SetPromptResolver(func(name, dir string) (string, error) { resolverCalled = true resolvedName = name return templateText, nil }) - periodic := &session.PeriodicPrompt{ + loop := &session.LoopPrompt{ PromptName: "check-status", Arguments: map[string]string{"ISSUE_ID": "mitto-42"}, // ENV intentionally absent Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, } - periodicStore := store.Periodic("arg-dispatch") - if err := periodicStore.Set(periodic); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + loopStore := store.Loop("arg-dispatch") + if err := loopStore.Set(loop); err != nil { + t.Fatalf("loopStore.Set() error = %v", err) } // Use a BackgroundSession with a valid context but no ACP connection. @@ -2113,10 +2113,10 @@ func TestPeriodicRunner_DeliverPrompt_ArgumentsForwardedAndSubstituted(t *testin defer cancel() bs := conversation.NewTestBackgroundSessionWithCtx("arg-dispatch", ctx, cancel) - deliverErr := runner.deliverPrompt(bs, "test-session", periodic, periodicStore, false, false) + deliverErr := runner.deliverPrompt(bs, "test-session", loop, loopStore, false, false) // The resolver must have been called even though PromptWithMeta failed. if !resolverCalled { - t.Error("promptResolver was not called; periodic.PromptName not forwarded to deliverPrompt") + t.Error("promptResolver was not called; loop.PromptName not forwarded to deliverPrompt") } if resolvedName != "check-status" { t.Errorf("resolved name = %q, want %q", resolvedName, "check-status") @@ -2131,15 +2131,15 @@ func TestPeriodicRunner_DeliverPrompt_ArgumentsForwardedAndSubstituted(t *testin // Verify that Go-template rendering with the stored arguments produces the // correct substituted text. ENV is absent so the Arg helper must use the // default "prod". - substituted := substituteTestArgs(templateText, periodic.Arguments) + substituted := substituteTestArgs(templateText, loop.Arguments) if want := "Check mitto-42 in prod"; substituted != want { t.Errorf("substituted text = %q, want %q", substituted, want) } } -// TestPeriodicRunner_DeliverPrompt_DefaultRendered verifies that the Arg helper +// TestLoopRunner_DeliverPrompt_DefaultRendered verifies that the Arg helper // in a named prompt renders the default string when the key is absent from Arguments. -func TestPeriodicRunner_DeliverPrompt_DefaultRendered(t *testing.T) { +func TestLoopRunner_DeliverPrompt_DefaultRendered(t *testing.T) { const tmpl = `run {{ Arg "CMD" "lint" }} on {{ Arg "TARGET" "all" }}` args := map[string]string{"CMD": "test"} // TARGET absent — default must apply got := substituteTestArgs(tmpl, args) @@ -2149,19 +2149,19 @@ func TestPeriodicRunner_DeliverPrompt_DefaultRendered(t *testing.T) { } } -// TestPeriodicRunner_DeliverPrompt_FreeTextUnaffected verifies that a periodic +// TestLoopRunner_DeliverPrompt_FreeTextUnaffected verifies that a loop // prompt using only the Prompt field (no PromptName, no Arguments) leaves a // literal ${...} placeholder in the text untouched. With nil Arguments the // substituteTestArgs helper must not modify the text because the early-return // guard fires on len(args)==0. -func TestPeriodicRunner_DeliverPrompt_FreeTextUnaffected(t *testing.T) { +func TestLoopRunner_DeliverPrompt_FreeTextUnaffected(t *testing.T) { const freeText = "Check ${SOMETHING} now" - periodic := &session.PeriodicPrompt{ + loop := &session.LoopPrompt{ Prompt: freeText, - Arguments: nil, // free-text periodic has no arguments + Arguments: nil, // free-text loop has no arguments } // With nil Arguments the text must be returned verbatim. - substituted := substituteTestArgs(freeText, periodic.Arguments) + substituted := substituteTestArgs(freeText, loop.Arguments) if substituted != freeText { t.Errorf("free-text substitution changed text: got %q, want %q", substituted, freeText) } @@ -2181,27 +2181,27 @@ func substituteTestArgs(text string, args map[string]string) string { } // ============================================================================= -// StopPeriodicForArchive tests (mitto-efnb) +// StopLoopForArchive tests (mitto-efnb) // ============================================================================= -// TestStopPeriodicForArchive_ScheduleBased verifies that StopPeriodicForArchive disables -// an enabled schedule-based periodic config, sets StoppedReason="archived", clears -// NextScheduledAt, and fires the onPeriodicAutoStopped callback. -func TestStopPeriodicForArchive_ScheduleBased(t *testing.T) { +// TestStopLoopForArchive_ScheduleBased verifies that StopLoopForArchive disables +// an enabled schedule-based loop config, sets StoppedReason="archived", clears +// NextScheduledAt, and fires the onLoopAutoStopped callback. +func TestStopLoopForArchive_ScheduleBased(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - // Create a schedule-based periodic session. + // Create a schedule-based loop session. meta := session.Metadata{SessionID: "arch-sched", ACPServer: "test", WorkingDir: "/tmp"} if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - ps := store.Periodic("arch-sched") + ps := store.Loop("arch-sched") nextAt := time.Now().Add(time.Hour).UTC() - if err := ps.Set(&session.PeriodicPrompt{ + if err := ps.Set(&session.LoopPrompt{ Prompt: "check", Enabled: true, Trigger: session.TriggerSchedule, @@ -2211,23 +2211,23 @@ func TestStopPeriodicForArchive_ScheduleBased(t *testing.T) { t.Fatalf("ps.Set() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) var callbackSessionID string - var callbackPeriodic *session.PeriodicPrompt - runner.SetOnPeriodicAutoStopped(func(sid string, p *session.PeriodicPrompt) { + var callbackLoop *session.LoopPrompt + runner.SetOnLoopAutoStopped(func(sid string, p *session.LoopPrompt) { callbackSessionID = sid - callbackPeriodic = p + callbackLoop = p }) - runner.StopPeriodicForArchive("arch-sched", session.StoppedReasonArchived) + runner.StopLoopForArchive("arch-sched", session.StoppedReasonArchived) final, err := ps.Get() if err != nil { - t.Fatalf("ps.Get() after StopPeriodicForArchive: %v", err) + t.Fatalf("ps.Get() after StopLoopForArchive: %v", err) } if final.Enabled { - t.Error("Enabled = true after StopPeriodicForArchive, want false") + t.Error("Enabled = true after StopLoopForArchive, want false") } if final.StoppedReason != session.StoppedReasonArchived { t.Errorf("StoppedReason = %q, want %q", final.StoppedReason, session.StoppedReasonArchived) @@ -2236,17 +2236,17 @@ func TestStopPeriodicForArchive_ScheduleBased(t *testing.T) { t.Errorf("NextScheduledAt = %v, want nil", final.NextScheduledAt) } if callbackSessionID != "arch-sched" { - t.Errorf("onPeriodicAutoStopped called with session %q, want %q", callbackSessionID, "arch-sched") + t.Errorf("onLoopAutoStopped called with session %q, want %q", callbackSessionID, "arch-sched") } - if callbackPeriodic == nil || callbackPeriodic.Enabled { - t.Error("onPeriodicAutoStopped received nil or still-enabled periodic") + if callbackLoop == nil || callbackLoop.Enabled { + t.Error("onLoopAutoStopped received nil or still-enabled loop") } } -// TestStopPeriodicForArchive_OnCompletion verifies that StopPeriodicForArchive disables +// TestStopLoopForArchive_OnCompletion verifies that StopLoopForArchive disables // an enabled onCompletion config, cancels any armed completion timer, and is a no-op -// (no panic, no broadcast) when there is no periodic config at all. -func TestStopPeriodicForArchive_OnCompletion(t *testing.T) { +// (no panic, no broadcast) when there is no loop config at all. +func TestStopLoopForArchive_OnCompletion(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2256,9 +2256,9 @@ func TestStopPeriodicForArchive_OnCompletion(t *testing.T) { // Create an onCompletion session with a very long timer so it won't fire. newOnCompletionSession(t, store, "arch-oc", 3600) - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) callbackFired := false - runner.SetOnPeriodicAutoStopped(func(_ string, _ *session.PeriodicPrompt) { + runner.SetOnLoopAutoStopped(func(_ string, _ *session.LoopPrompt) { callbackFired = true }) @@ -2268,44 +2268,44 @@ func TestStopPeriodicForArchive_OnCompletion(t *testing.T) { t.Fatalf("precondition: completionTimers = %d, want 1", got) } - runner.StopPeriodicForArchive("arch-oc", session.StoppedReasonArchived) + runner.StopLoopForArchive("arch-oc", session.StoppedReasonArchived) // Timer must be cancelled. if got := countCompletionTimers(runner); got != 0 { - t.Errorf("completionTimers = %d after StopPeriodicForArchive, want 0", got) + t.Errorf("completionTimers = %d after StopLoopForArchive, want 0", got) } // Config must be disabled. - final, err := store.Periodic("arch-oc").Get() + final, err := store.Loop("arch-oc").Get() if err != nil { - t.Fatalf("ps.Get() after StopPeriodicForArchive: %v", err) + t.Fatalf("ps.Get() after StopLoopForArchive: %v", err) } if final.Enabled { - t.Error("Enabled = true after StopPeriodicForArchive, want false") + t.Error("Enabled = true after StopLoopForArchive, want false") } if final.StoppedReason != session.StoppedReasonArchived { t.Errorf("StoppedReason = %q, want %q", final.StoppedReason, session.StoppedReasonArchived) } if !callbackFired { - t.Error("onPeriodicAutoStopped not called") + t.Error("onLoopAutoStopped not called") } - // No-op for a session with no periodic config (must not panic). - meta2 := session.Metadata{SessionID: "no-periodic", ACPServer: "test", WorkingDir: "/tmp"} + // No-op for a session with no loop config (must not panic). + meta2 := session.Metadata{SessionID: "no-loop", ACPServer: "test", WorkingDir: "/tmp"} if err := store.Create(meta2); err != nil { - t.Fatalf("store.Create(no-periodic): %v", err) + t.Fatalf("store.Create(no-loop): %v", err) } broadcastCount := 0 - runner.SetOnPeriodicAutoStopped(func(_ string, _ *session.PeriodicPrompt) { broadcastCount++ }) - runner.StopPeriodicForArchive("no-periodic", session.StoppedReasonArchived) // must not panic + runner.SetOnLoopAutoStopped(func(_ string, _ *session.LoopPrompt) { broadcastCount++ }) + runner.StopLoopForArchive("no-loop", session.StoppedReasonArchived) // must not panic if broadcastCount != 0 { - t.Errorf("onPeriodicAutoStopped called %d times for session without periodic config, want 0", broadcastCount) + t.Errorf("onLoopAutoStopped called %d times for session without loop config, want 0", broadcastCount) } } -// TestStopPeriodicForArchive_Idempotent verifies that StopPeriodicForArchive is a no-op +// TestStopLoopForArchive_Idempotent verifies that StopLoopForArchive is a no-op // (no second broadcast, reason unchanged) when the config is already disabled. -func TestStopPeriodicForArchive_Idempotent(t *testing.T) { +func TestStopLoopForArchive_Idempotent(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2313,20 +2313,20 @@ func TestStopPeriodicForArchive_Idempotent(t *testing.T) { defer store.Close() newOnCompletionSession(t, store, "s1", 3600) - ps := store.Periodic("s1") + ps := store.Loop("s1") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) broadcastCount := 0 - runner.SetOnPeriodicAutoStopped(func(_ string, _ *session.PeriodicPrompt) { broadcastCount++ }) + runner.SetOnLoopAutoStopped(func(_ string, _ *session.LoopPrompt) { broadcastCount++ }) // First call disables. - runner.StopPeriodicForArchive("s1", session.StoppedReasonArchived) + runner.StopLoopForArchive("s1", session.StoppedReasonArchived) if broadcastCount != 1 { t.Fatalf("broadcastCount = %d after first call, want 1", broadcastCount) } // Second call must be idempotent. - runner.StopPeriodicForArchive("s1", session.StoppedReasonArchived) + runner.StopLoopForArchive("s1", session.StoppedReasonArchived) if broadcastCount != 1 { t.Errorf("broadcastCount = %d after second call, want 1 (idempotent)", broadcastCount) } @@ -2338,9 +2338,9 @@ func TestStopPeriodicForArchive_Idempotent(t *testing.T) { } } -// TestStopPeriodicForArchive_NoFurtherDelivery verifies that after archiving (via -// StopPeriodicForArchive + UpdateMetadata), RunOnce delivers nothing. -func TestStopPeriodicForArchive_NoFurtherDelivery(t *testing.T) { +// TestStopLoopForArchive_NoFurtherDelivery verifies that after archiving (via +// StopLoopForArchive + UpdateMetadata), RunOnce delivers nothing. +func TestStopLoopForArchive_NoFurtherDelivery(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2352,9 +2352,9 @@ func TestStopPeriodicForArchive_NoFurtherDelivery(t *testing.T) { if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - ps := store.Periodic("arch-nodelay") + ps := store.Loop("arch-nodelay") pastDue := time.Now().UTC().Add(-time.Hour) - if err := ps.Set(&session.PeriodicPrompt{ + if err := ps.Set(&session.LoopPrompt{ Prompt: "check", Enabled: true, Trigger: session.TriggerSchedule, @@ -2365,10 +2365,10 @@ func TestStopPeriodicForArchive_NoFurtherDelivery(t *testing.T) { } sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) - // Archive the session: stop periodic and mark metadata archived. - runner.StopPeriodicForArchive("arch-nodelay", session.StoppedReasonArchived) + // Archive the session: stop loop and mark metadata archived. + runner.StopLoopForArchive("arch-nodelay", session.StoppedReasonArchived) if err := store.UpdateMetadata("arch-nodelay", func(m *session.Metadata) { m.Archived = true m.ArchivedAt = time.Now() @@ -2382,10 +2382,10 @@ func TestStopPeriodicForArchive_NoFurtherDelivery(t *testing.T) { t.Errorf("RunOnce() = (%d, %d, %d), want (0, *, 0) for archived session", delivered, skipped, errored) } - // Periodic config must remain disabled. + // Loop config must remain disabled. final, _ := ps.Get() if final.Enabled { - t.Error("periodic still enabled after archive + RunOnce") + t.Error("loop still enabled after archive + RunOnce") } } @@ -2406,7 +2406,7 @@ func TestOnConversationIdle_ArchivedNoop(t *testing.T) { t.Fatalf("UpdateMetadata() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.OnConversationIdle("s1") if got := countCompletionTimers(runner); got != 0 { @@ -2430,7 +2430,7 @@ func TestOnConversationIdle_ArchivedCancelsExistingTimer(t *testing.T) { t.Fatalf("UpdateMetadata() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) // Pre-arm a stale timer. runner.armCompletionTimer("s1", time.Hour) if got := countCompletionTimers(runner); got != 1 { @@ -2444,73 +2444,73 @@ func TestOnConversationIdle_ArchivedCancelsExistingTimer(t *testing.T) { } } -// TestDeliverPrompt_PeriodicKind verifies that deliverPrompt sets PeriodicKind correctly -// on the PromptMeta: PeriodicKindScheduled for normal runs, PeriodicKindForced for "run now". +// TestDeliverPrompt_LoopKind verifies that deliverPrompt sets LoopKind correctly +// on the PromptMeta: LoopKindScheduled for normal runs, LoopKindForced for "run now". // This is a logic-level test — we verify the enum derivation logic independently (mitto-5xjn). -func TestDeliverPrompt_PeriodicKind(t *testing.T) { +func TestDeliverPrompt_LoopKind(t *testing.T) { // Scheduled (forced=false) { forced := false - kind := conversation.PeriodicKindScheduled + kind := conversation.LoopKindScheduled if forced { - kind = conversation.PeriodicKindForced + kind = conversation.LoopKindForced } - if kind != conversation.PeriodicKindScheduled { - t.Errorf("forced=false: got PeriodicKind=%v, want PeriodicKindScheduled", kind) + if kind != conversation.LoopKindScheduled { + t.Errorf("forced=false: got LoopKind=%v, want LoopKindScheduled", kind) } } // Forced (forced=true) { forced := true - kind := conversation.PeriodicKindScheduled + kind := conversation.LoopKindScheduled if forced { - kind = conversation.PeriodicKindForced + kind = conversation.LoopKindForced } - if kind != conversation.PeriodicKindForced { - t.Errorf("forced=true: got PeriodicKind=%v, want PeriodicKindForced", kind) + if kind != conversation.LoopKindForced { + t.Errorf("forced=true: got LoopKind=%v, want LoopKindForced", kind) } } - // Enum zero value must be PeriodicKindNone (not a periodic run). - if conversation.PeriodicKindNone != 0 { - t.Errorf("PeriodicKindNone must be 0 (zero value), got %d", conversation.PeriodicKindNone) + // Enum zero value must be LoopKindNone (not a loop run). + if conversation.LoopKindNone != 0 { + t.Errorf("LoopKindNone must be 0 (zero value), got %d", conversation.LoopKindNone) } } -func TestPeriodicScheduleBackoff(t *testing.T) { +func TestLoopScheduleBackoff(t *testing.T) { tests := []struct { name string failures int want time.Duration }{ - {"zero clamps to first attempt", 0, periodicScheduleBackoffBase}, - {"first failure is base", 1, periodicScheduleBackoffBase}, - {"second failure doubles", 2, 2 * periodicScheduleBackoffBase}, - {"third failure quadruples", 3, 4 * periodicScheduleBackoffBase}, - {"fourth failure x8", 4, 8 * periodicScheduleBackoffBase}, - {"large failure count is capped", 100, periodicScheduleBackoffCap}, + {"zero clamps to first attempt", 0, loopScheduleBackoffBase}, + {"first failure is base", 1, loopScheduleBackoffBase}, + {"second failure doubles", 2, 2 * loopScheduleBackoffBase}, + {"third failure quadruples", 3, 4 * loopScheduleBackoffBase}, + {"fourth failure x8", 4, 8 * loopScheduleBackoffBase}, + {"large failure count is capped", 100, loopScheduleBackoffCap}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := periodicScheduleBackoff(tt.failures) + got := loopScheduleBackoff(tt.failures) if got != tt.want { - t.Errorf("periodicScheduleBackoff(%d) = %v, want %v", tt.failures, got, tt.want) + t.Errorf("loopScheduleBackoff(%d) = %v, want %v", tt.failures, got, tt.want) } }) } } -func TestPeriodicScheduleBackoff_MonotonicAndCapped(t *testing.T) { +func TestLoopScheduleBackoff_MonotonicAndCapped(t *testing.T) { var prev time.Duration for f := 1; f <= 50; f++ { - got := periodicScheduleBackoff(f) + got := loopScheduleBackoff(f) if got < prev { t.Errorf("backoff decreased: failures=%d got=%v prev=%v", f, got, prev) } - if got > periodicScheduleBackoffCap { - t.Errorf("backoff exceeded cap: failures=%d got=%v cap=%v", f, got, periodicScheduleBackoffCap) + if got > loopScheduleBackoffCap { + t.Errorf("backoff exceeded cap: failures=%d got=%v cap=%v", f, got, loopScheduleBackoffCap) } prev = got } @@ -2566,6 +2566,12 @@ func (c *fakeTasksBeadsClient) Update(context.Context, string, beads.UpdateParam } func (c *fakeTasksBeadsClient) Comment(context.Context, string, string, string) error { return nil } func (c *fakeTasksBeadsClient) Dep(context.Context, string, beads.DepParams) error { return nil } +func (c *fakeTasksBeadsClient) Label(context.Context, string, beads.LabelParams) error { + return nil +} +func (c *fakeTasksBeadsClient) ListAllLabels(context.Context, string) ([]byte, error) { + return []byte(`[]`), nil +} func (c *fakeTasksBeadsClient) ConfigShow(context.Context, string) (map[string]string, error) { return nil, nil } @@ -2576,23 +2582,23 @@ func (c *fakeTasksBeadsClient) Sync(context.Context, string, string, string) (st return "", nil } -// newOnTasksSession creates a session with an enabled onTasks periodic prompt +// newOnTasksSession creates a session with an enabled onTasks loop prompt // configured with the given working dir and CEL condition (empty = fire on any change). -func newOnTasksSession(t *testing.T, store *session.Store, sessionID, workingDir, condition string) *session.PeriodicStore { +func newOnTasksSession(t *testing.T, store *session.Store, sessionID, workingDir, condition string) *session.LoopStore { t.Helper() meta := session.Metadata{SessionID: sessionID, ACPServer: "test", WorkingDir: workingDir} if err := store.Create(meta); err != nil { t.Fatalf("store.Create() error = %v", err) } - if err := store.Periodic(sessionID).Set(&session.PeriodicPrompt{ + if err := store.Loop(sessionID).Set(&session.LoopPrompt{ Prompt: "iterate", Enabled: true, Trigger: session.TriggerOnTasks, Condition: condition, }); err != nil { - t.Fatalf("periodicStore.Set() error = %v", err) + t.Fatalf("loopStore.Set() error = %v", err) } - return store.Periodic(sessionID) + return store.Loop(sessionID) } func TestTasksDeltaIsMaterial(t *testing.T) { @@ -2639,18 +2645,18 @@ func TestTasksTouchedIDsAndSubset(t *testing.T) { } } -func TestPeriodicRunner_TasksCooldownActive(t *testing.T) { +func TestLoopRunner_TasksCooldownActive(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - runner := NewPeriodicRunner(store, nil, nil) - runner.SetMinPeriodicTasksCooldownSeconds(60) + runner := NewLoopRunner(store, nil, nil) + runner.SetMinLoopTasksCooldownSeconds(60) // Never sent — never on cooldown. - p := &session.PeriodicPrompt{Trigger: session.TriggerOnTasks} + p := &session.LoopPrompt{Trigger: session.TriggerOnTasks} if runner.tasksCooldownActive(p) { t.Error("never-sent prompt should not be on cooldown") } @@ -2678,7 +2684,7 @@ func TestPeriodicRunner_TasksCooldownActive(t *testing.T) { } } -func TestPeriodicRunner_IsTasksSubtreeBusy(t *testing.T) { +func TestLoopRunner_IsTasksSubtreeBusy(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2693,7 +2699,7 @@ func TestPeriodicRunner_IsTasksSubtreeBusy(t *testing.T) { } sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) // No sessions registered, nothing waiting => idle. if runner.isTasksSubtreeBusy("parent") { @@ -2758,7 +2764,7 @@ func jsonBytesEqual(t *testing.T, a, b []byte) bool { return string(ja) == string(jb) } -func TestPeriodicRunner_EvaluateTasksChange_InitializesBaselineWithoutFiring(t *testing.T) { +func TestLoopRunner_EvaluateTasksChange_InitializesBaselineWithoutFiring(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2766,13 +2772,13 @@ func TestPeriodicRunner_EvaluateTasksChange_InitializesBaselineWithoutFiring(t * defer store.Close() newOnTasksSession(t, store, "s1", "/proj", "") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) meta, _ := store.GetMetadata("s1") - periodic, _ := store.Periodic("s1").Get() + loop, _ := store.Loop("s1").Get() raw := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) - decision := runner.evaluateTasksChange(meta, periodic, raw) + decision := runner.evaluateTasksChange(meta, loop, raw) if decision.action != tasksActionInitBaseline { t.Fatalf("action = %v, want tasksActionInitBaseline", decision.action) } @@ -2785,7 +2791,7 @@ func TestPeriodicRunner_EvaluateTasksChange_InitializesBaselineWithoutFiring(t * // Driving it through processTasksChange persists the baseline and does NOT fire // (no session manager is configured, so a firing attempt would be observable // only via baseline movement, which must not happen here). - runner.processTasksChange(meta, periodic, store.Periodic("s1"), raw) + runner.processTasksChange(meta, loop, store.Loop("s1"), raw) baseline, err := NewTasksBaselineStore(store.SessionDir("s1")).Get() if err != nil { t.Fatalf("baseline should exist after processTasksChange, error = %v", err) @@ -2795,7 +2801,7 @@ func TestPeriodicRunner_EvaluateTasksChange_InitializesBaselineWithoutFiring(t * } } -func TestPeriodicRunner_EvaluateTasksChange_NoMaterialChange_Skip(t *testing.T) { +func TestLoopRunner_EvaluateTasksChange_NoMaterialChange_Skip(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2803,7 +2809,7 @@ func TestPeriodicRunner_EvaluateTasksChange_NoMaterialChange_Skip(t *testing.T) defer store.Close() newOnTasksSession(t, store, "s1", "/proj", "") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) raw := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) if err := NewTasksBaselineStore(store.SessionDir("s1")).Set(raw); err != nil { @@ -2811,16 +2817,16 @@ func TestPeriodicRunner_EvaluateTasksChange_NoMaterialChange_Skip(t *testing.T) } meta, _ := store.GetMetadata("s1") - periodic, _ := store.Periodic("s1").Get() + loop, _ := store.Loop("s1").Get() // Identical snapshot — no material change. - decision := runner.evaluateTasksChange(meta, periodic, raw) + decision := runner.evaluateTasksChange(meta, loop, raw) if decision.action != tasksActionSkip { t.Errorf("action = %v, want tasksActionSkip for an unchanged snapshot", decision.action) } } -func TestPeriodicRunner_EvaluateTasksChange_EmptyConditionFiresOnAnyChange(t *testing.T) { +func TestLoopRunner_EvaluateTasksChange_EmptyConditionFiresOnAnyChange(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2828,7 +2834,7 @@ func TestPeriodicRunner_EvaluateTasksChange_EmptyConditionFiresOnAnyChange(t *te defer store.Close() newOnTasksSession(t, store, "s1", "/proj", "") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) rawBefore := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) if err := NewTasksBaselineStore(store.SessionDir("s1")).Set(rawBefore); err != nil { @@ -2837,9 +2843,9 @@ func TestPeriodicRunner_EvaluateTasksChange_EmptyConditionFiresOnAnyChange(t *te rawAfter := mustMarshalRows(t, beadsRow("mitto-1", "closed", "2026-01-02T00:00:00Z")) meta, _ := store.GetMetadata("s1") - periodic, _ := store.Periodic("s1").Get() + loop, _ := store.Loop("s1").Get() - decision := runner.evaluateTasksChange(meta, periodic, rawAfter) + decision := runner.evaluateTasksChange(meta, loop, rawAfter) if decision.action != tasksActionFire { t.Fatalf("action = %v, want tasksActionFire for an empty condition with a material change", decision.action) } @@ -2848,7 +2854,7 @@ func TestPeriodicRunner_EvaluateTasksChange_EmptyConditionFiresOnAnyChange(t *te } } -func TestPeriodicRunner_EvaluateTasksChange_ConditionFalse_Skip(t *testing.T) { +func TestLoopRunner_EvaluateTasksChange_ConditionFalse_Skip(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2857,7 +2863,7 @@ func TestPeriodicRunner_EvaluateTasksChange_ConditionFalse_Skip(t *testing.T) { // Condition only fires when an issue is closed; here we only add a new open one. newOnTasksSession(t, store, "s1", "/proj", "Changes.Closed.size() > 0") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) rawBefore := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) if err := NewTasksBaselineStore(store.SessionDir("s1")).Set(rawBefore); err != nil { @@ -2868,15 +2874,15 @@ func TestPeriodicRunner_EvaluateTasksChange_ConditionFalse_Skip(t *testing.T) { beadsRow("mitto-2", "open", "2026-01-02T00:00:00Z")) meta, _ := store.GetMetadata("s1") - periodic, _ := store.Periodic("s1").Get() + loop, _ := store.Loop("s1").Get() - decision := runner.evaluateTasksChange(meta, periodic, rawAfter) + decision := runner.evaluateTasksChange(meta, loop, rawAfter) if decision.action != tasksActionSkip { t.Fatalf("action = %v, want tasksActionSkip when the condition evaluates false", decision.action) } } -func TestPeriodicRunner_EvaluateTasksChange_ConditionTrue_Fires(t *testing.T) { +func TestLoopRunner_EvaluateTasksChange_ConditionTrue_Fires(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2884,7 +2890,7 @@ func TestPeriodicRunner_EvaluateTasksChange_ConditionTrue_Fires(t *testing.T) { defer store.Close() newOnTasksSession(t, store, "s1", "/proj", "Changes.Closed.size() > 0") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) rawBefore := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) if err := NewTasksBaselineStore(store.SessionDir("s1")).Set(rawBefore); err != nil { @@ -2893,15 +2899,15 @@ func TestPeriodicRunner_EvaluateTasksChange_ConditionTrue_Fires(t *testing.T) { rawAfter := mustMarshalRows(t, beadsRow("mitto-1", "closed", "2026-01-02T00:00:00Z")) meta, _ := store.GetMetadata("s1") - periodic, _ := store.Periodic("s1").Get() + loop, _ := store.Loop("s1").Get() - decision := runner.evaluateTasksChange(meta, periodic, rawAfter) + decision := runner.evaluateTasksChange(meta, loop, rawAfter) if decision.action != tasksActionFire { t.Fatalf("action = %v, want tasksActionFire when the condition evaluates true", decision.action) } } -func TestPeriodicRunner_EvaluateTasksChange_InvalidCondition_FailClosed(t *testing.T) { +func TestLoopRunner_EvaluateTasksChange_InvalidCondition_FailClosed(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2912,16 +2918,16 @@ func TestPeriodicRunner_EvaluateTasksChange_InvalidCondition_FailClosed(t *testi // runtime fail-closed path directly: a condition that compiles but does // not evaluate to a bool. newOnTasksSession(t, store, "s1", "/proj", "") - if err := writeTestPeriodicFile(filepath.Join(store.SessionDir("s1"), "periodic.json"), &session.PeriodicPrompt{ + if err := writeTestLoopFile(filepath.Join(store.SessionDir("s1"), "loop.json"), &session.LoopPrompt{ Prompt: "iterate", Enabled: true, Trigger: session.TriggerOnTasks, Condition: "Changes.Touched.size()", // not a bool }); err != nil { - t.Fatalf("writeTestPeriodicFile() error = %v", err) + t.Fatalf("writeTestLoopFile() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) rawBefore := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) if err := NewTasksBaselineStore(store.SessionDir("s1")).Set(rawBefore); err != nil { @@ -2930,15 +2936,15 @@ func TestPeriodicRunner_EvaluateTasksChange_InvalidCondition_FailClosed(t *testi rawAfter := mustMarshalRows(t, beadsRow("mitto-1", "closed", "2026-01-02T00:00:00Z")) meta, _ := store.GetMetadata("s1") - periodic, _ := store.Periodic("s1").Get() + loop, _ := store.Loop("s1").Get() - decision := runner.evaluateTasksChange(meta, periodic, rawAfter) + decision := runner.evaluateTasksChange(meta, loop, rawAfter) if decision.action != tasksActionSkip { t.Fatalf("action = %v, want tasksActionSkip (fail-closed) for a non-bool condition result", decision.action) } } -func TestPeriodicRunner_EvaluateTasksChange_BusySubtree_DefersRebase(t *testing.T) { +func TestLoopRunner_EvaluateTasksChange_BusySubtree_DefersRebase(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2949,7 +2955,7 @@ func TestPeriodicRunner_EvaluateTasksChange_BusySubtree_DefersRebase(t *testing. sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) sm.AddSessionForTest(conversation.NewMinimalBackgroundSessionPrompting("s1", true)) - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) rawBefore := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) if err := NewTasksBaselineStore(store.SessionDir("s1")).Set(rawBefore); err != nil { @@ -2958,16 +2964,16 @@ func TestPeriodicRunner_EvaluateTasksChange_BusySubtree_DefersRebase(t *testing. rawAfter := mustMarshalRows(t, beadsRow("mitto-1", "closed", "2026-01-02T00:00:00Z")) meta, _ := store.GetMetadata("s1") - periodic, _ := store.Periodic("s1").Get() + loop, _ := store.Loop("s1").Get() - decision := runner.evaluateTasksChange(meta, periodic, rawAfter) + decision := runner.evaluateTasksChange(meta, loop, rawAfter) if decision.action != tasksActionDeferBusy { t.Fatalf("action = %v, want tasksActionDeferBusy while the session is prompting", decision.action) } // Driving it through processTasksChange must arm a rebase timer and leave // the baseline untouched (the change must be absorbed later, not fired on now). - runner.processTasksChange(meta, periodic, store.Periodic("s1"), rawAfter) + runner.processTasksChange(meta, loop, store.Loop("s1"), rawAfter) if got := countTasksRebaseTimers(runner); got != 1 { t.Errorf("tasksRebaseTimers = %d, want 1 after a busy-subtree event", got) } @@ -2981,7 +2987,7 @@ func TestPeriodicRunner_EvaluateTasksChange_BusySubtree_DefersRebase(t *testing. runner.cancelTasksRebaseTimerForTest("s1") } -func TestPeriodicRunner_EvaluateTasksChange_MaxDurationReached_Skip(t *testing.T) { +func TestLoopRunner_EvaluateTasksChange_MaxDurationReached_Skip(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -2990,17 +2996,17 @@ func TestPeriodicRunner_EvaluateTasksChange_MaxDurationReached_Skip(t *testing.T ps := newOnTasksSession(t, store, "s1", "/proj", "") firstRun := time.Now().Add(-2 * time.Hour) - if err := writeTestPeriodicFile(filepath.Join(store.SessionDir("s1"), "periodic.json"), &session.PeriodicPrompt{ + if err := writeTestLoopFile(filepath.Join(store.SessionDir("s1"), "loop.json"), &session.LoopPrompt{ Prompt: "iterate", Enabled: true, Trigger: session.TriggerOnTasks, MaxDurationSeconds: 3600, FirstRunAt: &firstRun, }); err != nil { - t.Fatalf("writeTestPeriodicFile() error = %v", err) + t.Fatalf("writeTestLoopFile() error = %v", err) } - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) rawBefore := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) if err := NewTasksBaselineStore(store.SessionDir("s1")).Set(rawBefore); err != nil { t.Fatalf("Set() baseline error = %v", err) @@ -3008,12 +3014,12 @@ func TestPeriodicRunner_EvaluateTasksChange_MaxDurationReached_Skip(t *testing.T rawAfter := mustMarshalRows(t, beadsRow("mitto-1", "closed", "2026-01-02T00:00:00Z")) meta, _ := store.GetMetadata("s1") - periodic, err := ps.Get() + loop, err := ps.Get() if err != nil { t.Fatalf("Get() error = %v", err) } - decision := runner.evaluateTasksChange(meta, periodic, rawAfter) + decision := runner.evaluateTasksChange(meta, loop, rawAfter) if decision.action != tasksActionSkip { t.Fatalf("action = %v, want tasksActionSkip once maxDuration is reached", decision.action) } @@ -3024,7 +3030,7 @@ func TestPeriodicRunner_EvaluateTasksChange_MaxDurationReached_Skip(t *testing.T t.Fatalf("Get() error = %v", err) } if got.Enabled { - t.Error("periodic should be disabled after reaching max duration") + t.Error("loop should be disabled after reaching max duration") } if got.StoppedReason != session.StoppedReasonMaxDuration { t.Errorf("StoppedReason = %q, want %q", got.StoppedReason, session.StoppedReasonMaxDuration) @@ -3032,7 +3038,7 @@ func TestPeriodicRunner_EvaluateTasksChange_MaxDurationReached_Skip(t *testing.T } // countTasksRebaseTimers returns the number of armed onTasks rebase timers. -func countTasksRebaseTimers(r *PeriodicRunner) int { +func countTasksRebaseTimers(r *LoopRunner) int { r.tasksRebaseTimersMu.Lock() defer r.tasksRebaseTimersMu.Unlock() return len(r.tasksRebaseTimers) @@ -3040,7 +3046,7 @@ func countTasksRebaseTimers(r *PeriodicRunner) int { // cancelTasksRebaseTimerForTest stops and removes a pending rebase timer so // tests don't leak background timers. -func (r *PeriodicRunner) cancelTasksRebaseTimerForTest(sessionID string) { +func (r *LoopRunner) cancelTasksRebaseTimerForTest(sessionID string) { r.tasksRebaseTimersMu.Lock() defer r.tasksRebaseTimersMu.Unlock() if existing, ok := r.tasksRebaseTimers[sessionID]; ok { @@ -3049,7 +3055,7 @@ func (r *PeriodicRunner) cancelTasksRebaseTimerForTest(sessionID string) { } } -func TestPeriodicRunner_FireTasksRebase_RebasesWhenIdle(t *testing.T) { +func TestLoopRunner_FireTasksRebase_RebasesWhenIdle(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -3063,7 +3069,7 @@ func TestPeriodicRunner_FireTasksRebase_RebasesWhenIdle(t *testing.T) { } sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) rawNow := mustMarshalRows(t, beadsRow("mitto-1", "closed", "2026-01-02T00:00:00Z")) fake := &fakeTasksBeadsClient{listFn: func(string) ([]byte, error) { return rawNow, nil }} runner.SetBeadsClient(fake) @@ -3084,7 +3090,7 @@ func TestPeriodicRunner_FireTasksRebase_RebasesWhenIdle(t *testing.T) { } } -func TestPeriodicRunner_FireTasksRebase_StillBusy_ReArms(t *testing.T) { +func TestLoopRunner_FireTasksRebase_StillBusy_ReArms(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -3095,7 +3101,7 @@ func TestPeriodicRunner_FireTasksRebase_StillBusy_ReArms(t *testing.T) { sm := conversation.NewSessionManagerWithOptions(conversation.SessionManagerOptions{}) sm.AddSessionForTest(conversation.NewMinimalBackgroundSessionPrompting("s1", true)) - runner := NewPeriodicRunner(store, sm, nil) + runner := NewLoopRunner(store, sm, nil) runner.SetTasksQuiescenceWindow(time.Hour) // long enough we can assert before it fires again runner.fireTasksRebase("s1", ps) @@ -3106,7 +3112,7 @@ func TestPeriodicRunner_FireTasksRebase_StillBusy_ReArms(t *testing.T) { runner.cancelTasksRebaseTimerForTest("s1") } -func TestPeriodicRunner_BootstrapTasksBaseline_CreatesWhenMissing(t *testing.T) { +func TestLoopRunner_BootstrapTasksBaseline_CreatesWhenMissing(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -3114,7 +3120,7 @@ func TestPeriodicRunner_BootstrapTasksBaseline_CreatesWhenMissing(t *testing.T) defer store.Close() newOnTasksSession(t, store, "s1", "/proj", "") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) raw := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) fake := &fakeTasksBeadsClient{listFn: func(string) ([]byte, error) { return raw, nil }} runner.SetBeadsClient(fake) @@ -3136,7 +3142,7 @@ func TestPeriodicRunner_BootstrapTasksBaseline_CreatesWhenMissing(t *testing.T) } } -func TestPeriodicRunner_BootstrapTasksBaseline_NoopWhenNotOnTasks(t *testing.T) { +func TestLoopRunner_BootstrapTasksBaseline_NoopWhenNotOnTasks(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -3144,7 +3150,7 @@ func TestPeriodicRunner_BootstrapTasksBaseline_NoopWhenNotOnTasks(t *testing.T) defer store.Close() newOnCompletionSession(t, store, "s1", 0) // a different trigger, not onTasks - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) fake := &fakeTasksBeadsClient{} runner.SetBeadsClient(fake) @@ -3158,7 +3164,7 @@ func TestPeriodicRunner_BootstrapTasksBaseline_NoopWhenNotOnTasks(t *testing.T) } } -func TestPeriodicRunner_OnBeadsChanged_RoutingAndCaching(t *testing.T) { +func TestLoopRunner_OnBeadsChanged_RoutingAndCaching(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -3172,14 +3178,14 @@ func TestPeriodicRunner_OnBeadsChanged_RoutingAndCaching(t *testing.T) { newOnTasksSession(t, store, "s2", "/proj-a", "") newOnTasksSession(t, store, "s3", "/proj-b", "") newOnTasksSession(t, store, "s4", "/proj-a", "") - if err := store.Periodic("s4").Update(nil, nil, nil, boolPtr(false), nil, nil, nil, nil, nil, nil, nil, nil, nil); err != nil { + if err := store.Loop("s4").Update(nil, nil, nil, boolPtr(false), nil, nil, nil, nil, nil, nil, nil, nil, nil); err != nil { t.Fatalf("Update(disable s4) error = %v", err) } raw := mustMarshalRows(t, beadsRow("mitto-1", "open", "2026-01-01T00:00:00Z")) fake := &fakeTasksBeadsClient{listFn: func(string) ([]byte, error) { return raw, nil }} - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) runner.SetBeadsClient(fake) runner.OnBeadsChanged(config.BeadsChangeEvent{WorkingDirs: []string{"/proj-a"}}) @@ -3202,7 +3208,7 @@ func TestPeriodicRunner_OnBeadsChanged_RoutingAndCaching(t *testing.T) { } } -func TestPeriodicRunner_RecordTasksFireOutcome_CircuitBreakerPausesNoProgress(t *testing.T) { +func TestLoopRunner_RecordTasksFireOutcome_CircuitBreakerPausesNoProgress(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -3210,7 +3216,7 @@ func TestPeriodicRunner_RecordTasksFireOutcome_CircuitBreakerPausesNoProgress(t defer store.Close() ps := newOnTasksSession(t, store, "s1", "/proj", "") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) // Same issue id touched repeatedly — no genuine new progress across fires. // The very first fire seeds tasksLastTouchedIDs (nothing to compare against @@ -3225,7 +3231,7 @@ func TestPeriodicRunner_RecordTasksFireOutcome_CircuitBreakerPausesNoProgress(t t.Fatalf("Get() error = %v", err) } if !got.Enabled { - t.Fatalf("periodic should remain enabled before reaching the no-progress limit (iteration %d)", i) + t.Fatalf("loop should remain enabled before reaching the no-progress limit (iteration %d)", i) } } @@ -3236,14 +3242,14 @@ func TestPeriodicRunner_RecordTasksFireOutcome_CircuitBreakerPausesNoProgress(t t.Fatalf("Get() error = %v", err) } if got.Enabled { - t.Error("periodic should be auto-paused after tasksNoProgressLimit consecutive no-progress fires") + t.Error("loop should be auto-paused after tasksNoProgressLimit consecutive no-progress fires") } if got.StoppedReason != session.StoppedReasonNoProgress { t.Errorf("StoppedReason = %q, want %q", got.StoppedReason, session.StoppedReasonNoProgress) } } -func TestPeriodicRunner_RecordTasksFireOutcome_ResetsOnGenuineProgress(t *testing.T) { +func TestLoopRunner_RecordTasksFireOutcome_ResetsOnGenuineProgress(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) @@ -3251,7 +3257,7 @@ func TestPeriodicRunner_RecordTasksFireOutcome_ResetsOnGenuineProgress(t *testin defer store.Close() ps := newOnTasksSession(t, store, "s1", "/proj", "") - runner := NewPeriodicRunner(store, nil, nil) + runner := NewLoopRunner(store, nil, nil) sameDelta := &config.TasksDelta{Touched: []map[string]any{{"id": "mitto-1"}}} for i := 0; i < tasksNoProgressLimit-1; i++ { @@ -3272,27 +3278,27 @@ func TestPeriodicRunner_RecordTasksFireOutcome_ResetsOnGenuineProgress(t *testin t.Fatalf("Get() error = %v", err) } if !got.Enabled { - t.Error("periodic should still be enabled — the counter was reset by genuine progress") + t.Error("loop should still be enabled — the counter was reset by genuine progress") } } -func TestPeriodicRunner_TasksCooldownSettersGetters(t *testing.T) { +func TestLoopRunner_TasksCooldownSettersGetters(t *testing.T) { store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("NewStore() error = %v", err) } defer store.Close() - runner := NewPeriodicRunner(store, nil, nil) - if got := runner.MinPeriodicTasksCooldownSeconds(); got != DefaultMinPeriodicTasksCooldownSeconds { - t.Errorf("default MinPeriodicTasksCooldownSeconds = %d, want %d", got, DefaultMinPeriodicTasksCooldownSeconds) + runner := NewLoopRunner(store, nil, nil) + if got := runner.MinLoopTasksCooldownSeconds(); got != DefaultMinLoopTasksCooldownSeconds { + t.Errorf("default MinLoopTasksCooldownSeconds = %d, want %d", got, DefaultMinLoopTasksCooldownSeconds) } - runner.SetMinPeriodicTasksCooldownSeconds(120) - if got := runner.MinPeriodicTasksCooldownSeconds(); got != 120 { - t.Errorf("MinPeriodicTasksCooldownSeconds() = %d, want 120", got) + runner.SetMinLoopTasksCooldownSeconds(120) + if got := runner.MinLoopTasksCooldownSeconds(); got != 120 { + t.Errorf("MinLoopTasksCooldownSeconds() = %d, want 120", got) } - runner.SetMinPeriodicTasksCooldownSeconds(-5) - if got := runner.MinPeriodicTasksCooldownSeconds(); got != 0 { + runner.SetMinLoopTasksCooldownSeconds(-5) + if got := runner.MinLoopTasksCooldownSeconds(); got != 0 { t.Errorf("negative value should clamp to 0, got %d", got) } } diff --git a/internal/web/middleware/auth.go b/internal/web/middleware/auth.go index d1547009..3a518160 100644 --- a/internal/web/middleware/auth.go +++ b/internal/web/middleware/auth.go @@ -40,6 +40,16 @@ const ( // sessionCleanupInterval is how often to clean up expired sessions sessionCleanupInterval = 5 * time.Minute + + // splitIPWarnWindow is the minimum time between repeated "Split-IP login + // detected" WARN log lines for the same (network-prefix, user-agent) key. + // This keeps benign, recurring IP drift (mobile network handoff) from + // spamming the logs while still surfacing genuinely new anomalies. + splitIPWarnWindow = 10 * time.Minute + + // splitIPWarnDedupCap bounds the dedup map size; once exceeded, stale + // entries are pruned opportunistically to avoid unbounded growth. + splitIPWarnDedupCap = 1000 ) // Credential validation errors @@ -85,6 +95,10 @@ type AuthManager struct { cfVerifier *oidc.IDTokenVerifier // Cloudflare Access JWT verifier (nil if not configured) + // splitIPWarnMu guards splitIPWarnSeen (dedup for the Split-IP login WARN). + splitIPWarnMu sync.Mutex + splitIPWarnSeen map[string]time.Time + // Cleanup goroutine control stopCleanup chan struct{} cleanupDone chan struct{} @@ -93,11 +107,12 @@ type AuthManager struct { // NewAuthManager creates a new auth manager. func NewAuthManager(authConfig *config.WebAuth) *AuthManager { am := &AuthManager{ - config: authConfig, - sessions: make(map[string]*AuthSession), - rateLimiter: NewAuthRateLimiter(), - stopCleanup: make(chan struct{}), - cleanupDone: make(chan struct{}), + config: authConfig, + sessions: make(map[string]*AuthSession), + rateLimiter: NewAuthRateLimiter(), + splitIPWarnSeen: make(map[string]time.Time), + stopCleanup: make(chan struct{}), + cleanupDone: make(chan struct{}), } // Parse the allow list @@ -359,6 +374,48 @@ func (a *AuthManager) cleanupExpiredSessions() { if removed > 0 { a.saveSessionsLocked() } + + a.pruneSplitIPWarnSeen() +} + +// shouldWarnSplitIP reports whether the Split-IP login WARN should be emitted +// for the given dedup key (normalized network prefix + User-Agent), rate-limited +// to at most once per splitIPWarnWindow. Thread-safe. +func (a *AuthManager) shouldWarnSplitIP(key string) bool { + now := time.Now() + + a.splitIPWarnMu.Lock() + defer a.splitIPWarnMu.Unlock() + + if last, ok := a.splitIPWarnSeen[key]; ok && now.Sub(last) < splitIPWarnWindow { + return false + } + a.splitIPWarnSeen[key] = now + + // Opportunistically prune if the map has grown large, to avoid unbounded + // growth from many distinct keys between cleanup ticks. + if len(a.splitIPWarnSeen) > splitIPWarnDedupCap { + a.pruneSplitIPWarnSeenLocked(now) + } + + return true +} + +// pruneSplitIPWarnSeen removes stale entries from the Split-IP WARN dedup map. +func (a *AuthManager) pruneSplitIPWarnSeen() { + a.splitIPWarnMu.Lock() + defer a.splitIPWarnMu.Unlock() + a.pruneSplitIPWarnSeenLocked(time.Now()) +} + +// pruneSplitIPWarnSeenLocked removes entries older than splitIPWarnWindow. +// Must be called with a.splitIPWarnMu held. +func (a *AuthManager) pruneSplitIPWarnSeenLocked(now time.Time) { + for key, last := range a.splitIPWarnSeen { + if now.Sub(last) >= splitIPWarnWindow { + delete(a.splitIPWarnSeen, key) + } + } } // IsEnabled returns true if authentication is enabled and credentials are configured. @@ -1040,12 +1097,16 @@ func (a *AuthManager) HandleLogin(w http.ResponseWriter, r *http.Request) { // • A compromised token being reused from a different machine. // It can also occur legitimately (mobile network handoff, VPN/NAT changes) so we // only log — never block — on this signal alone. + userAgent := r.Header.Get("User-Agent") if cookie, err := r.Cookie(csrfCookieName); err == nil { - if !VerifyIPFromToken(cookie.Value, ipKey) { - logger.Warn("Split-IP login detected: CSRF token IP fingerprint mismatch", - "login_ip", ipKey, - "user_agent", r.Header.Get("User-Agent"), - ) + if !VerifyIPFromToken(cookie.Value, ipKey, userAgent) { + dedupKey := normalizeIPForFingerprint(ipKey) + "|" + userAgent + if a.shouldWarnSplitIP(dedupKey) { + logger.Warn("Split-IP login detected: CSRF token IP fingerprint mismatch", + "login_ip", ipKey, + "user_agent", userAgent, + ) + } } } diff --git a/internal/web/middleware/auth_test.go b/internal/web/middleware/auth_test.go index aed09424..e4e9c732 100644 --- a/internal/web/middleware/auth_test.go +++ b/internal/web/middleware/auth_test.go @@ -1179,3 +1179,59 @@ func TestAuthManager_CleanupExpiredSessions(t *testing.T) { t.Error("Session should still be valid after cleanup") } } + +func TestAuthManager_ShouldWarnSplitIP(t *testing.T) { + am := NewAuthManager(&config.WebAuth{ + Simple: &config.SimpleAuth{Username: "user", Password: "pass"}, + }) + defer am.Close() + + const key = "192.168.1.0|some-ua" + + if !am.shouldWarnSplitIP(key) { + t.Error("first call for a key should warn") + } + if am.shouldWarnSplitIP(key) { + t.Error("second call within the dedup window should not warn again") + } + + // A different key should warn independently. + if !am.shouldWarnSplitIP("10.0.0.0|other-ua") { + t.Error("a different key should warn") + } + + // Simulate the dedup window having elapsed by backdating the seen time. + am.splitIPWarnMu.Lock() + am.splitIPWarnSeen[key] = time.Now().Add(-splitIPWarnWindow - time.Second) + am.splitIPWarnMu.Unlock() + + if !am.shouldWarnSplitIP(key) { + t.Error("call after the dedup window elapsed should warn again") + } +} + +func TestAuthManager_PruneSplitIPWarnSeen(t *testing.T) { + am := NewAuthManager(&config.WebAuth{ + Simple: &config.SimpleAuth{Username: "user", Password: "pass"}, + }) + defer am.Close() + + am.splitIPWarnMu.Lock() + am.splitIPWarnSeen["stale"] = time.Now().Add(-splitIPWarnWindow - time.Second) + am.splitIPWarnSeen["fresh"] = time.Now() + am.splitIPWarnMu.Unlock() + + am.pruneSplitIPWarnSeen() + + am.splitIPWarnMu.Lock() + _, staleExists := am.splitIPWarnSeen["stale"] + _, freshExists := am.splitIPWarnSeen["fresh"] + am.splitIPWarnMu.Unlock() + + if staleExists { + t.Error("stale entry should have been pruned") + } + if !freshExists { + t.Error("fresh entry should not have been pruned") + } +} diff --git a/internal/web/middleware/csrf.go b/internal/web/middleware/csrf.go index 473d258a..bf860311 100644 --- a/internal/web/middleware/csrf.go +++ b/internal/web/middleware/csrf.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/hex" + "net" "net/http" "strings" @@ -66,42 +67,66 @@ func (c *CSRFManager) GenerateToken() (string, error) { return hex.EncodeToString(bytes), nil } -// embedIPInToken appends a non-reversible IP fingerprint to a bare CSRF token. +// normalizeIPForFingerprint coarsens ip to its containing network prefix +// (/24 for IPv4, /64 for IPv6) so that benign IP drift within the same +// network (mobile carrier NAT, Wi-Fi/cellular handoff) does not change the +// fingerprint. Falls back to returning ip unchanged if it is empty or +// cannot be parsed. +func normalizeIPForFingerprint(ip string) string { + if ip == "" { + return ip + } + parsed := net.ParseIP(ip) + if parsed == nil { + return ip + } + if ip4 := parsed.To4(); ip4 != nil { + return ip4.Mask(net.CIDRMask(24, 32)).String() + } + return parsed.Mask(net.CIDRMask(64, 128)).String() +} + +// embedFingerprint appends a non-reversible client fingerprint to a bare CSRF token. // -// Format: {64-char-hex-token}.{16-char-hex-sha256-prefix(token+ip)} +// Format: {64-char-hex-token}.{16-char-hex-sha256-prefix(token+network-prefix+user-agent)} // // The fingerprint lets the server detect split-IP login patterns (the token was -// issued to one IP and then the POST arrives from a different IP) without storing -// any server-side state. Because the fingerprint is derived from both the random -// token and the IP, an attacker cannot forge a valid fingerprint for a new IP -// without knowing the original token value. -func embedIPInToken(token, ip string) string { +// issued to one client and then the POST arrives from a different one) without +// storing any server-side state. The IP is coarsened to its /24 (IPv4) or /64 +// (IPv6) network prefix so that benign IP drift within the same network doesn't +// trip the check, and the User-Agent is folded in so a genuinely different +// client still trips it. Because the fingerprint is derived from the random +// token as well, an attacker cannot forge a valid fingerprint for a new +// network/UA without knowing the original token value. +func embedFingerprint(token, ip, userAgent string) string { if ip == "" { return token } h := sha256.New() h.Write([]byte(token)) - h.Write([]byte(ip)) + h.Write([]byte(normalizeIPForFingerprint(ip))) + h.Write([]byte(userAgent)) return token + csrfIPHashSep + hex.EncodeToString(h.Sum(nil)[:csrfIPHashLen]) } -// VerifyIPFromToken returns true when the IP fingerprint embedded in tokenValue matches ip. +// VerifyIPFromToken returns true when the client fingerprint embedded in tokenValue +// matches ip and userAgent (after coarsening ip to its network prefix). // // Returns true (no anomaly) when tokenValue has no embedded fingerprint — this handles // tokens that were issued before this feature was deployed (graceful degradation). -func VerifyIPFromToken(tokenValue, ip string) bool { +func VerifyIPFromToken(tokenValue, ip, userAgent string) bool { idx := strings.LastIndex(tokenValue, csrfIPHashSep) if idx < 0 { - return true // Old format — no IP fingerprint to check + return true // Old format — no fingerprint to check } base := tokenValue[:idx] if len(base) != csrfTokenLength*2 { // each byte → 2 hex chars return true // Unexpected format — skip check to avoid false positives } storedHash := tokenValue[idx+1:] - expected := embedIPInToken(base, ip) + expected := embedFingerprint(base, ip, userAgent) expectedHash := expected[idx+1:] - // Constant-time compare to prevent timing-based IP enumeration + // Constant-time compare to prevent timing-based fingerprint enumeration return subtle.ConstantTimeCompare([]byte(storedHash), []byte(expectedHash)) == 1 } @@ -155,13 +180,13 @@ func (c *CSRFManager) HandleCSRFToken(w http.ResponseWriter, r *http.Request) { return } - // Embed the issuing IP as a fingerprint so that HandleLogin can detect - // split-IP anomalies (auth page loaded from one IP, POST from another). - // The double-submit cookie pattern (cookie == header) is unchanged because - // both the cookie and the JavaScript-read header value carry the same full - // string (token + "." + ip-hash). + // Embed the issuing IP + User-Agent as a fingerprint so that HandleLogin can + // detect split-IP anomalies (auth page loaded from one client, POST from + // another). The double-submit cookie pattern (cookie == header) is unchanged + // because both the cookie and the JavaScript-read header value carry the + // same full string (token + "." + fingerprint-hash). ip := GetClientIPWithProxyCheck(r) - tokenWithIP := embedIPInToken(token, ip) + tokenWithIP := embedFingerprint(token, ip, r.Header.Get("User-Agent")) c.SetCSRFCookie(w, r, tokenWithIP) writeJSONOK(w, map[string]string{"token": tokenWithIP}) diff --git a/internal/web/middleware/csrf_test.go b/internal/web/middleware/csrf_test.go index b456a834..fbcbfdee 100644 --- a/internal/web/middleware/csrf_test.go +++ b/internal/web/middleware/csrf_test.go @@ -357,3 +357,111 @@ func TestCSRFManager_CloseIsNoOp(t *testing.T) { t.Error("GenerateToken after Close returned empty token") } } + +func TestNormalizeIPForFingerprint(t *testing.T) { + tests := []struct { + name string + ip string + want string + }{ + {"IPv4 masks to /24", "192.168.1.42", "192.168.1.0"}, + {"IPv4 masks to /24 (different host)", "192.168.1.200", "192.168.1.0"}, + {"IPv6 masks to /64", "2001:db8:1234:5678:aaaa:bbbb:cccc:dddd", "2001:db8:1234:5678::"}, + {"empty returns unchanged", "", ""}, + {"unparseable returns unchanged", "not-an-ip", "not-an-ip"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeIPForFingerprint(tt.ip) + if got != tt.want { + t.Errorf("normalizeIPForFingerprint(%q) = %q, want %q", tt.ip, got, tt.want) + } + }) + } +} + +func TestVerifyIPFromToken_SameNetworkSameUA(t *testing.T) { + const ua = "Mozilla/5.0 (iPhone)" + + // Use a base of csrfTokenLength*2 hex chars to exercise the real + // comparison path (shorter bases hit the graceful-degradation guard). + realBase := make([]byte, csrfTokenLength*2) + for i := range realBase { + realBase[i] = '0' + } + token := embedFingerprint(string(realBase), "192.168.1.10", ua) + + // Different IP in the same /24, same UA -> no anomaly. + if !VerifyIPFromToken(token, "192.168.1.250", ua) { + t.Error("expected true for different IP in same /24 with same UA") + } +} + +func TestVerifyIPFromToken_DifferentNetwork(t *testing.T) { + const ua = "Mozilla/5.0 (iPhone)" + + // Use a base of csrfTokenLength*2 hex chars to exercise the real + // comparison path (shorter bases hit the graceful-degradation guard). + realBase := make([]byte, csrfTokenLength*2) + for i := range realBase { + realBase[i] = '0' + } + token := embedFingerprint(string(realBase), "192.168.1.10", ua) + + // Different /24 network, same UA -> anomaly (fingerprint mismatch). + if VerifyIPFromToken(token, "192.168.2.10", ua) { + t.Error("expected false for different /24 network with same UA") + } +} + +func TestVerifyIPFromToken_DifferentUserAgent(t *testing.T) { + realBase := make([]byte, csrfTokenLength*2) + for i := range realBase { + realBase[i] = '0' + } + token := embedFingerprint(string(realBase), "192.168.1.10", "UA-A") + + if VerifyIPFromToken(token, "192.168.1.10", "UA-B") { + t.Error("expected false for same IP with different User-Agent") + } +} + +func TestVerifyIPFromToken_GracefulDegradation(t *testing.T) { + // Old-format token with no embedded fingerprint. + if !VerifyIPFromToken("plain-token-no-suffix", "1.2.3.4", "UA") { + t.Error("expected true (no anomaly) for token without embedded fingerprint") + } + + // Token with a suffix but an unexpected base length. + if !VerifyIPFromToken("shortbase.deadbeefdeadbeef", "1.2.3.4", "UA") { + t.Error("expected true (no anomaly) for token with unexpected base length") + } +} + +func TestVerifyIPFromToken_RoundTrip(t *testing.T) { + cm := NewCSRFManager() + defer cm.Close() + + token, err := cm.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken failed: %v", err) + } + + const ua = "curl/8.0" + tokenWithFingerprint := embedFingerprint(token, "10.0.0.5", ua) + + // Same network prefix (/24), same UA -> verified. + if !VerifyIPFromToken(tokenWithFingerprint, "10.0.0.99", ua) { + t.Error("expected true for round-trip within same /24 and same UA") + } + + // Different network prefix -> mismatch. + if VerifyIPFromToken(tokenWithFingerprint, "10.0.1.5", ua) { + t.Error("expected false for round-trip with different /24") + } + + // Different UA -> mismatch. + if VerifyIPFromToken(tokenWithFingerprint, "10.0.0.5", "different-ua") { + t.Error("expected false for round-trip with different User-Agent") + } +} diff --git a/internal/web/routes.go b/internal/web/routes.go index de906799..2da31d8e 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -57,8 +57,8 @@ func (s *Server) apiRoutes(authMgr *middleware.AuthManager, csrfMgr *middleware. apiRoute{pattern: "/api/sessions/{id}/queue", handler: http.HandlerFunc(s.handleSessionQueue)}, apiRoute{pattern: "/api/sessions/{id}/queue/{msgId}", handler: http.HandlerFunc(s.handleSessionQueue)}, apiRoute{pattern: "/api/sessions/{id}/queue/{msgId}/{subAction}", handler: http.HandlerFunc(s.handleSessionQueue)}, - apiRoute{pattern: "/api/sessions/{id}/periodic", handler: http.HandlerFunc(s.handleSessionPeriodic)}, - apiRoute{pattern: "/api/sessions/{id}/periodic/{subPath}", handler: http.HandlerFunc(s.handleSessionPeriodic)}, + apiRoute{pattern: "/api/sessions/{id}/loop", handler: http.HandlerFunc(s.handleSessionLoop)}, + apiRoute{pattern: "/api/sessions/{id}/loop/{subPath}", handler: http.HandlerFunc(s.handleSessionLoop)}, apiRoute{method: "GET", pattern: "/api/sessions/{id}/prompt-arg-cache", handler: http.HandlerFunc(s.handleSessionPromptArgCache)}, ) @@ -114,6 +114,8 @@ func (s *Server) apiRoutes(authMgr *middleware.AuthManager, csrfMgr *middleware. apiRoute{method: "DELETE", pattern: "/api/issues/{id}", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsDelete)}, apiRoute{method: "POST", pattern: "/api/issues/{id}/comments", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsComment)}, apiRoute{method: "POST", pattern: "/api/issues/{id}/dependencies", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsDep)}, + apiRoute{method: "GET", pattern: "/api/issues/labels", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsLabelsAll)}, + apiRoute{method: "POST", pattern: "/api/issues/{id}/labels", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsLabel)}, apiRoute{method: "GET", pattern: "/api/issues/config", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsConfig)}, apiRoute{method: "PUT", pattern: "/api/issues/config", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsConfig)}, apiRoute{method: "DELETE", pattern: "/api/issues/config", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsConfig)}, @@ -122,6 +124,9 @@ func (s *Server) apiRoutes(authMgr *middleware.AuthManager, csrfMgr *middleware. // Folder shortcut buttons (folder-native, stored in folders.json). apiRoute{method: "GET", pattern: "/api/folders/shortcuts", handler: http.HandlerFunc(s.apiHandlers.HandleFolderShortcuts)}, apiRoute{method: "PUT", pattern: "/api/folders/shortcuts", handler: http.HandlerFunc(s.apiHandlers.HandleFolderShortcuts)}, + // Global shortcut buttons (stored in settings.json, merged with folder shortcuts at render time). + apiRoute{method: "GET", pattern: "/api/global/shortcuts", handler: http.HandlerFunc(s.apiHandlers.HandleGlobalShortcuts)}, + apiRoute{method: "PUT", pattern: "/api/global/shortcuts", handler: http.HandlerFunc(s.apiHandlers.HandleGlobalShortcuts)}, apiRoute{method: "POST", pattern: "/api/issues/cleanup", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsCleanup)}, apiRoute{method: "POST", pattern: "/api/issues/sync", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsSync)}, apiRoute{method: "POST", pattern: "/api/issues/{id}/status", handler: http.HandlerFunc(s.apiHandlers.HandleBeadsStatus)}, diff --git a/internal/web/server.go b/internal/web/server.go index 0a62ce4f..3d53fdcb 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -16,6 +16,7 @@ import ( builtinConfig "github.com/inercia/mitto/config" "github.com/inercia/mitto/internal/acpproc" + "github.com/inercia/mitto/internal/agents" "github.com/inercia/mitto/internal/appdir" "github.com/inercia/mitto/internal/auxiliary" "github.com/inercia/mitto/internal/beads" @@ -24,6 +25,7 @@ import ( "github.com/inercia/mitto/internal/defense" "github.com/inercia/mitto/internal/hooks" "github.com/inercia/mitto/internal/logging" + "github.com/inercia/mitto/internal/mcpdiscovery" "github.com/inercia/mitto/internal/mcpserver" "github.com/inercia/mitto/internal/processors" "github.com/inercia/mitto/internal/session" @@ -170,8 +172,8 @@ type Server struct { // Queue title worker for generating titles for queued messages queueTitleWorker *conversation.QueueTitleWorker - // Periodic runner for scheduled prompt delivery - periodicRunner *PeriodicRunner + // Loop runner for scheduled prompt delivery + loopRunner *LoopRunner // Callback index for mapping callback tokens to session IDs callbackIndex *conversation.CallbackIndex @@ -348,21 +350,39 @@ func NewServer(config Config) (*Server, error) { acpProcessMgr.ModelProfileResolver = func(name string) *configPkg.ModelProfile { return config.MittoConfig.FindModelProfile(name) } + // Set Model profiles-by-tag resolver so process manager can resolve AuxiliaryModelTag (mitto-9vz). + acpProcessMgr.ModelProfilesByTagResolver = func(tag string) []configPkg.ModelProfile { + return config.MittoConfig.ModelProfilesByTag(tag) + } sessionMgr.SetACPProcessManager(acpProcessManagerAdapter{acpProcessMgr}) + // Apply the prompt inactivity watchdog cancellation timeout from settings. This + // breaks the GC deadlock where a wedged shared ACP process pins a session as + // stuck forever (mitto-54y): once idle exceeds the timeout, the watchdog cancels + // the in-flight prompt, clearing is_prompting so GC can recycle the process. + // Independent of the GC/auxiliary-prewarm gating below since it governs prompt + // handling, not process GC. + if config.MittoConfig != nil && config.MittoConfig.Session != nil { + if d, enabled := config.MittoConfig.Session.ParseAgentInactivityTimeout(); enabled { + conversation.SetPromptInactivityTimeout(d) + } else { + conversation.SetPromptInactivityTimeout(0) + } + } + // Start ACP process garbage collector to clean up idle sessions and processes. // The GC periodically checks for sessions with no observers, no active prompts, // and no pending work, and stops shared ACP processes that have no active sessions. if !config.DisableAuxiliaryPrewarm && os.Getenv("MITTO_TEST_MODE") == "" { gcConfig := acpproc.GCConfig{} - // Apply periodic suspend threshold from settings if configured. + // Apply loop suspend threshold from settings if configured. if config.MittoConfig != nil && config.MittoConfig.Session != nil { - if d, enabled := config.MittoConfig.Session.ParsePeriodicSuspendTimeout(); enabled { - gcConfig.PeriodicSuspendThreshold = d + if d, enabled := config.MittoConfig.Session.ParseLoopSuspendTimeout(); enabled { + gcConfig.LoopSuspendThreshold = d } else { // Explicitly disabled — set to 0 so StartGC doesn't apply default. // We need to bypass StartGC's "apply default for <= 0" logic. - gcConfig.PeriodicSuspendThreshold = -1 + gcConfig.LoopSuspendThreshold = -1 } } // Apply memory recycle threshold from settings if configured (opt-in). @@ -516,6 +536,72 @@ func NewServer(config Config) (*Server, error) { auxiliaryManager := auxiliary.NewWorkspaceAuxiliaryManager(acpProcessMgr, logger) sessionMgr.SetAuxiliaryManager(auxiliaryManager) + // Persist the real-MCP-derived tools snapshot per workspace (mitto-sys.8) + // so a restart reuses it within the TTL instead of re-probing every server. + // Only deterministic results are written; the LLM fallback stays in-memory. + if mcpCacheDir, cerr := appdir.MCPToolsCacheDir(); cerr == nil { + auxiliaryManager.MCPToolsPersistDir = mcpCacheDir + } else { + logger.Warn("mcp tools persistence disabled: cannot resolve cache dir", "error", cerr) + } + + // Wire deterministic MCP tool discovery (mitto-sys.2/mitto-sys.3/mitto-sys.6): + // resolves a workspace to its ACP agent and probes its configured stdio + + // http/sse MCP servers directly via tools/list, so FetchMCPTools can skip + // the LLM introspection fallback whenever every server is reachable. + if agentsDir, aerr := appdir.AgentsDir(); aerr == nil { + agentMgr := agents.NewManager(agentsDir, logger) + auxiliaryManager.StdioToolsDiscoverer = func(ctx context.Context, workspaceUUID string) ([]mcpdiscovery.ServerToolsResult, error) { + ws := sessionMgr.GetWorkspaceByUUID(workspaceUUID) + if ws == nil { + return nil, fmt.Errorf("workspace %q not found", workspaceUUID) + } + acpType := "" + if config.MittoConfig != nil { + acpType = config.MittoConfig.GetServerType(ws.ACPServer) + } + if acpType == "" { + acpType = ws.ACPServer + } + agent, gerr := agentMgr.GetAgentByACPId(acpType) + if gerr != nil { + return nil, gerr + } + return mcpdiscovery.DiscoverWorkspaceTools(ctx, agentMgr, agent.DirName, ws.WorkingDir, 8*time.Second, nil) + } + + // Wire event-driven MCP tool refresh (mitto-sys.4): enumerates a + // workspace's configured MCP servers so EnsureMCPWatchers can open a + // persistent notifications/tools/list_changed watcher per server. + auxiliaryManager.MCPServerLister = func(ctx context.Context, workspaceUUID string) ([]agents.MCPServer, error) { + ws := sessionMgr.GetWorkspaceByUUID(workspaceUUID) + if ws == nil { + return nil, fmt.Errorf("workspace %q not found", workspaceUUID) + } + acpType := "" + if config.MittoConfig != nil { + acpType = config.MittoConfig.GetServerType(ws.ACPServer) + } + if acpType == "" { + acpType = ws.ACPServer + } + agent, gerr := agentMgr.GetAgentByACPId(acpType) + if gerr != nil { + return nil, gerr + } + out, lerr := agentMgr.ListMCPServers(ctx, agent.DirName, &agents.MCPListInput{Path: ws.WorkingDir}) + if lerr != nil { + return nil, lerr + } + if out == nil { + return nil, nil + } + return out.Servers, nil + } + } else { + logger.Warn("stdio MCP discovery disabled: cannot resolve agents dir", "error", aerr) + } + // Initialize scanner defense // Enabled by default when external access is configured (ExternalPort >= 0) var scannerDefense *defense.ScannerDefense @@ -588,7 +674,7 @@ func NewServer(config Config) (*Server, error) { }) // The REST handlers sub-package facade is constructed later in NewServer, - // after callbackIndex, callbackRateLimiter and periodicRunner are + // after callbackIndex, callbackRateLimiter and loopRunner are // initialized — see "Construct the REST handlers sub-package facade" below. // Set events manager in session manager for broadcasting @@ -686,52 +772,52 @@ func NewServer(config Config) (*Server, error) { // Wired exactly once at startup. session.ConditionValidator = configPkg.ValidateCondition - // Initialize periodic runner for scheduled prompt delivery and session housekeeping - s.periodicRunner = NewPeriodicRunner(store, sessionMgr, logger) - // Share the self-suppressing beads client so the periodic runner's own + // Initialize loop runner for scheduled prompt delivery and session housekeeping + s.loopRunner = NewLoopRunner(store, sessionMgr, logger) + // Share the self-suppressing beads client so the loop runner's own // onTasks list reads do not bounce back through the watcher as external - // changes (which would spuriously re-fire onTasks periodic conversations). - s.periodicRunner.SetBeadsClient(s.beads) - s.periodicRunner.SetOnPeriodicStarted(s.BroadcastPeriodicStarted) - s.periodicRunner.SetOnAutoArchive(func(sessionID string) { + // changes (which would spuriously re-fire onTasks loop conversations). + s.loopRunner.SetBeadsClient(s.beads) + s.loopRunner.SetOnLoopStarted(s.BroadcastLoopStarted) + s.loopRunner.SetOnAutoArchive(func(sessionID string) { s.BroadcastACPStopped(sessionID, "auto_archived") s.BroadcastSessionArchived(sessionID, true) }) - s.periodicRunner.SetOnPeriodicAutoStopped(s.BroadcastPeriodicUpdated) - s.periodicRunner.SetOnPeriodicUpdated(s.BroadcastPeriodicUpdated) + s.loopRunner.SetOnLoopAutoStopped(s.BroadcastLoopUpdated) + s.loopRunner.SetOnLoopUpdated(s.BroadcastLoopUpdated) - // Configure the global periodic-iteration safeguard (user default, bounded by backstop). - maxPeriodicIter := configPkg.DefaultMaxPeriodicIterations + // Configure the global loop-iteration safeguard (user default, bounded by backstop). + maxLoopIter := configPkg.DefaultMaxLoopIterations if config.MittoConfig != nil { - maxPeriodicIter = config.MittoConfig.Conversations.GetMaxPeriodicIterations() + maxLoopIter = config.MittoConfig.Conversations.GetMaxLoopIterations() } - s.periodicRunner.SetMaxPeriodicIterations(maxPeriodicIter) + s.loopRunner.SetMaxLoopIterations(maxLoopIter) - // Configure the global floor for the on-completion periodic trigger's delay. - minCompletionDelay := configPkg.DefaultMinPeriodicCompletionDelaySeconds + // Configure the global floor for the on-completion loop trigger's delay. + minCompletionDelay := configPkg.DefaultMinLoopCompletionDelaySeconds if config.MittoConfig != nil { - minCompletionDelay = config.MittoConfig.Conversations.GetMinPeriodicCompletionDelaySeconds() + minCompletionDelay = config.MittoConfig.Conversations.GetMinLoopCompletionDelaySeconds() } - s.periodicRunner.SetMinPeriodicCompletionDelaySeconds(minCompletionDelay) + s.loopRunner.SetMinLoopCompletionDelaySeconds(minCompletionDelay) - // Configure startup delay for periodic runner to avoid thundering herd. - // Interactive sessions resume first via WebSocket; periodic sessions can afford to wait. - startupPeriodicDelay := configPkg.DefaultStartupPeriodicDelay + // Configure startup delay for loop runner to avoid thundering herd. + // Interactive sessions resume first via WebSocket; loop sessions can afford to wait. + startupLoopDelay := configPkg.DefaultStartupLoopDelay if config.MittoConfig != nil && config.MittoConfig.Session != nil { - startupPeriodicDelay = config.MittoConfig.Session.GetStartupPeriodicDelay() + startupLoopDelay = config.MittoConfig.Session.GetStartupLoopDelay() } - if startupPeriodicDelay > 0 { - s.periodicRunner.SetStartupDelay(startupPeriodicDelay) + if startupLoopDelay > 0 { + s.loopRunner.SetStartupDelay(startupLoopDelay) } - // Configure stagger delay between consecutive periodic session resumes. + // Configure stagger delay between consecutive loop session resumes. // Uses the same startup_stagger_ms config as the queue's ProcessPendingQueues. staggerMs := configPkg.DefaultStartupStaggerMs if config.MittoConfig != nil && config.MittoConfig.Session != nil { staggerMs = config.MittoConfig.Session.GetStartupStaggerMs() } if staggerMs > 0 { - s.periodicRunner.SetResumeStagger(time.Duration(staggerMs) * time.Millisecond) + s.loopRunner.SetResumeStagger(time.Duration(staggerMs) * time.Millisecond) } // Initialize callback index and rate limiter @@ -748,7 +834,7 @@ func NewServer(config Config) (*Server, error) { // Construct the REST handlers sub-package facade. Built here (not earlier) // so the late-initialized callbackIndex, callbackRateLimiter and - // periodicRunner are non-nil when wired into Deps. + // loopRunner are non-nil when wired into Deps. s.apiHandlers = handlers.New(handlers.Deps{ Logger: logger, ConfigReadOnly: config.ConfigReadOnly, @@ -780,16 +866,16 @@ func NewServer(config Config) (*Server, error) { CallbackRateLimiter: s.callbackRateLimiter, GetExternalPort: s.GetExternalPort, IsExternalListenerRunning: s.IsExternalListenerRunning, - TriggerPeriodicNow: s.periodicRunner.TriggerNow, - StopPeriodicForArchive: func(sessionID string) { - s.periodicRunner.StopPeriodicForArchive(sessionID, session.StoppedReasonArchived) + TriggerLoopNow: s.loopRunner.TriggerNow, + StopLoopForArchive: func(sessionID string) { + s.loopRunner.StopLoopForArchive(sessionID, session.StoppedReasonArchived) }, ErrSessionBusy: ErrSessionBusy, - ErrPeriodicNotEnabled: ErrPeriodicNotEnabled, - PeriodicDelayFloor: s.periodicDelayFloor, - BroadcastPeriodicUpdated: s.BroadcastPeriodicUpdated, + ErrLoopNotEnabled: ErrLoopNotEnabled, + LoopDelayFloor: s.loopDelayFloor, + BroadcastLoopUpdated: s.BroadcastLoopUpdated, BroadcastBeadsCleanupProgress: s.BroadcastBeadsCleanupProgress, - BootstrapOnCompletion: s.periodicRunner.BootstrapOnCompletion, + BootstrapOnCompletion: s.loopRunner.BootstrapOnCompletion, BroadcastSettingsUpdated: s.BroadcastSessionSettingsUpdated, BroadcastSessionDeleted: s.BroadcastSessionDeleted, BroadcastACPStartFailed: s.BroadcastACPStartFailed, @@ -829,9 +915,9 @@ func NewServer(config Config) (*Server, error) { if s.beadsWatcher != nil { s.beadsWatcher.Unsubscribe(s) s.beadsWatcher.Subscribe(s, s.getBeadsWatchDirs()) - if s.periodicRunner != nil { - s.beadsWatcher.Unsubscribe(s.periodicRunner) - s.beadsWatcher.Subscribe(s.periodicRunner, s.getBeadsWatchDirs()) + if s.loopRunner != nil { + s.beadsWatcher.Unsubscribe(s.loopRunner) + s.beadsWatcher.Subscribe(s.loopRunner, s.getBeadsWatchDirs()) } } }, @@ -868,20 +954,20 @@ func NewServer(config Config) (*Server, error) { if autoArchiveDuration, err := parseAutoArchivePeriod(autoArchivePeriod); err != nil { logger.Warn("Invalid auto-archive period, feature disabled", "period", autoArchivePeriod, "error", err) } else if autoArchiveDuration > 0 { - s.periodicRunner.SetAutoArchiveAfter(autoArchiveDuration) + s.loopRunner.SetAutoArchiveAfter(autoArchiveDuration) logger.Info("Auto-archive inactive sessions enabled", "period", autoArchivePeriod, "duration", autoArchiveDuration) } - // Configure periodic cleanup of archived sessions + // Configure loop cleanup of archived sessions retentionPeriod := config.MittoConfig.Session.GetArchiveRetentionPeriod() if retentionPeriod != "" { - s.periodicRunner.SetArchiveRetentionPeriod(retentionPeriod) - logger.Info("Periodic archive retention cleanup enabled", "retention_period", retentionPeriod) + s.loopRunner.SetArchiveRetentionPeriod(retentionPeriod) + logger.Info("Loop archive retention cleanup enabled", "retention_period", retentionPeriod) } } - // Set prompt resolver for periodic runner and session manager — resolves prompt names to text at execution time. - // Both use the same resolver: PeriodicRunner for scheduled prompts, conversation.SessionManager for interactive prompt-by-name. + // Set prompt resolver for loop runner and session manager — resolves prompt names to text at execution time. + // Both use the same resolver: LoopRunner for scheduled prompts, conversation.SessionManager for interactive prompt-by-name. promptResolverFunc := func(promptName string, workingDir string) (string, error) { return s.resolvePromptByName(promptName, workingDir) } @@ -891,22 +977,22 @@ func NewServer(config Config) (*Server, error) { promptParametersResolverFunc := func(promptName string, workingDir string) []configPkg.PromptParameter { return s.resolvePromptParametersByPromptName(promptName, workingDir) } - s.periodicRunner.SetPromptResolver(promptResolverFunc) + s.loopRunner.SetPromptResolver(promptResolverFunc) if s.sessionManager != nil { s.sessionManager.SetPromptResolver(promptResolverFunc) s.sessionManager.SetPreferredModelsResolver(preferredModelsResolverFunc) s.sessionManager.SetPromptParametersResolver(promptParametersResolverFunc) - // Wire event-driven on-completion periodic firing: sessions notify the runner + // Wire event-driven on-completion loop firing: sessions notify the runner // when they go idle so it can arm the next onCompletion run. - s.sessionManager.SetOnConversationIdle(s.periodicRunner.OnConversationIdle) + s.sessionManager.SetOnConversationIdle(s.loopRunner.OnConversationIdle) } - s.periodicRunner.Start() + s.loopRunner.Start() - // Wire up periodic runner to MCP server for the run-now tool. - // The periodic runner is created after the MCP server, so we use a setter. + // Wire up loop runner to MCP server for the run-now tool. + // The loop runner is created after the MCP server, so we use a setter. if s.mcpServer != nil { - s.mcpServer.SetPeriodicRunner(s.periodicRunner) + s.mcpServer.SetLoopRunner(s.loopRunner) } // Build callback index from existing sessions @@ -930,9 +1016,9 @@ func NewServer(config Config) (*Server, error) { } else { s.beadsWatcher = beadsWatcher s.beadsWatcher.Subscribe(s, s.getBeadsWatchDirs()) - // Also subscribe the periodic runner so onTasks periodic conversations + // Also subscribe the loop runner so onTasks loop conversations // can fire (or rebase their diff baseline) when beads change. - s.beadsWatcher.Subscribe(s.periodicRunner, s.getBeadsWatchDirs()) + s.beadsWatcher.Subscribe(s.loopRunner, s.getBeadsWatchDirs()) s.beadsWatcher.Start() logger.Info("Beads watcher started", "dirs", s.getBeadsWatchDirs()) } @@ -1090,6 +1176,11 @@ func (s *Server) Shutdown() error { s.sessionManager.CloseAll("server_shutdown") } + // Close all MCP event-driven tool watchers (mitto-sys.4) + if s.auxiliaryManager != nil { + s.auxiliaryManager.CloseAllMCPWatchers() + } + // Close rate limiter if s.rateLimiter != nil { s.rateLimiter.Close() @@ -1115,9 +1206,9 @@ func (s *Server) Shutdown() error { s.queueTitleWorker.Close() } - // Stop periodic runner - if s.periodicRunner != nil { - s.periodicRunner.Stop() + // Stop loop runner + if s.loopRunner != nil { + s.loopRunner.Stop() } // Close access logger @@ -1188,11 +1279,11 @@ func (s *Server) GetSessionManager() *conversation.SessionManager { return s.sessionManager } -// PeriodicRunner returns the server's periodic runner. +// LoopRunner returns the server's loop runner. // This is primarily used by integration tests to drive OnBeadsChanged directly // and to inject a fake beads.Client via SetBeadsClient. -func (s *Server) PeriodicRunner() *PeriodicRunner { - return s.periodicRunner +func (s *Server) LoopRunner() *LoopRunner { + return s.loopRunner } // handleRobotsTxt serves a robots.txt that disallows all crawling. @@ -1306,17 +1397,17 @@ func (s *Server) BroadcastSessionDeleted(sessionID string) { } } -// BroadcastPeriodicUpdated notifies all connected clients that a session's periodic state changed. -// This includes the full periodic config so clients can update their frequency panels. -func (s *Server) BroadcastPeriodicUpdated(sessionID string, periodic *session.PeriodicPrompt) { - data := conversation.BuildPeriodicUpdatedData(sessionID, periodic) - s.eventsManager.Broadcast(conversation.WSMsgTypePeriodicUpdated, data) +// BroadcastLoopUpdated notifies all connected clients that a session's loop state changed. +// This includes the full loop config so clients can update their frequency panels. +func (s *Server) BroadcastLoopUpdated(sessionID string, loop *session.LoopPrompt) { + data := conversation.BuildLoopUpdatedData(sessionID, loop) + s.eventsManager.Broadcast(conversation.WSMsgTypeLoopUpdated, data) if s.logger != nil { - configured := periodic != nil - enabled := periodic != nil && periodic.Enabled - s.logger.Debug("Broadcast periodic updated", "session_id", sessionID, - "periodic_configured", configured, "periodic_enabled", enabled, + configured := loop != nil + enabled := loop != nil && loop.Enabled + s.logger.Debug("Broadcast loop updated", "session_id", sessionID, + "loop_configured", configured, "loop_enabled", enabled, "clients", s.eventsManager.ClientCount()) } } @@ -1435,16 +1526,16 @@ func (s *Server) BroadcastACPStartFailed(sessionID, sessionName string, err erro } } -// BroadcastPeriodicStarted notifies all connected clients that a periodic prompt was delivered. +// BroadcastLoopStarted notifies all connected clients that a loop prompt was delivered. // This allows the frontend to show a toast notification and native OS notification. -func (s *Server) BroadcastPeriodicStarted(sessionID, sessionName string) { - s.eventsManager.Broadcast(WSMsgTypePeriodicStarted, map[string]string{ +func (s *Server) BroadcastLoopStarted(sessionID, sessionName string) { + s.eventsManager.Broadcast(WSMsgTypeLoopStarted, map[string]string{ "session_id": sessionID, "session_name": sessionName, }) if s.logger != nil { - s.logger.Info("Broadcast periodic started", "session_id", sessionID, "session_name", sessionName, + s.logger.Info("Broadcast loop started", "session_id", sessionID, "session_name", sessionName, "clients", s.eventsManager.ClientCount()) } } @@ -1666,9 +1757,9 @@ func (a *sessionManagerAdapter) BroadcastSessionRenamed(sessionID string, newNam a.sm.BroadcastSessionRenamed(sessionID, newName) } -// BroadcastPeriodicUpdated broadcasts a periodic_updated event to all connected clients. -func (a *sessionManagerAdapter) BroadcastPeriodicUpdated(sessionID string, periodic *session.PeriodicPrompt) { - a.sm.BroadcastPeriodicUpdated(sessionID, periodic) +// BroadcastLoopUpdated broadcasts a loop_updated event to all connected clients. +func (a *sessionManagerAdapter) BroadcastLoopUpdated(sessionID string, loop *session.LoopPrompt) { + a.sm.BroadcastLoopUpdated(sessionID, loop) } // GetUserDataSchema returns the user data schema for a workspace. diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 85c01560..496f2b0a 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -246,27 +246,27 @@ func TestServer_Logger_Nil(t *testing.T) { } // ============================================================================= -// conversation.BuildPeriodicUpdatedData tests +// conversation.BuildLoopUpdatedData tests // ============================================================================= -func TestBuildPeriodicUpdatedData_NilPeriodic(t *testing.T) { - data := conversation.BuildPeriodicUpdatedData("s1", nil) - if data["periodic_configured"] != false { - t.Errorf("periodic_configured = %v, want false", data["periodic_configured"]) +func TestBuildLoopUpdatedData_NilLoop(t *testing.T) { + data := conversation.BuildLoopUpdatedData("s1", nil) + if data["loop_configured"] != false { + t.Errorf("loop_configured = %v, want false", data["loop_configured"]) } - if data["periodic_enabled"] != false { - t.Errorf("periodic_enabled = %v, want false", data["periodic_enabled"]) + if data["loop_enabled"] != false { + t.Errorf("loop_enabled = %v, want false", data["loop_enabled"]) } // New keys must NOT be present when there's no config. for _, key := range []string{"trigger", "delay_seconds", "max_duration_seconds"} { if _, ok := data[key]; ok { - t.Errorf("key %q must be absent when periodic is nil", key) + t.Errorf("key %q must be absent when loop is nil", key) } } } -func TestBuildPeriodicUpdatedData_SchedulePeriodic(t *testing.T) { - p := &session.PeriodicPrompt{ +func TestBuildLoopUpdatedData_ScheduleLoop(t *testing.T) { + p := &session.LoopPrompt{ Prompt: "Test", Frequency: session.Frequency{Value: 30, Unit: session.FrequencyMinutes}, Enabled: true, @@ -276,10 +276,10 @@ func TestBuildPeriodicUpdatedData_SchedulePeriodic(t *testing.T) { DelaySeconds: 0, MaxDurationSeconds: 3600, } - data := conversation.BuildPeriodicUpdatedData("s1", p) + data := conversation.BuildLoopUpdatedData("s1", p) - if data["periodic_configured"] != true { - t.Errorf("periodic_configured = %v, want true", data["periodic_configured"]) + if data["loop_configured"] != true { + t.Errorf("loop_configured = %v, want true", data["loop_configured"]) } if data["trigger"] != "schedule" { t.Errorf("trigger = %v, want %q", data["trigger"], "schedule") @@ -298,29 +298,29 @@ func TestBuildPeriodicUpdatedData_SchedulePeriodic(t *testing.T) { } } -func TestBuildPeriodicUpdatedData_EmptyTriggerReportsSchedule(t *testing.T) { +func TestBuildLoopUpdatedData_EmptyTriggerReportsSchedule(t *testing.T) { // Trigger="" defaults to "schedule" via EffectiveTrigger(). - p := &session.PeriodicPrompt{ + p := &session.LoopPrompt{ Prompt: "Test", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, Trigger: "", // empty — must be resolved to "schedule" } - data := conversation.BuildPeriodicUpdatedData("s1", p) + data := conversation.BuildLoopUpdatedData("s1", p) if data["trigger"] != "schedule" { t.Errorf("trigger = %v, want %q (empty trigger must resolve to 'schedule')", data["trigger"], "schedule") } } -func TestBuildPeriodicUpdatedData_OnCompletionPeriodic(t *testing.T) { - p := &session.PeriodicPrompt{ +func TestBuildLoopUpdatedData_OnCompletionLoop(t *testing.T) { + p := &session.LoopPrompt{ Prompt: "Test", Enabled: true, Trigger: session.TriggerOnCompletion, DelaySeconds: 30, MaxDurationSeconds: 7200, } - data := conversation.BuildPeriodicUpdatedData("s1", p) + data := conversation.BuildLoopUpdatedData("s1", p) if data["trigger"] != "onCompletion" { t.Errorf("trigger = %v, want %q", data["trigger"], "onCompletion") @@ -333,16 +333,16 @@ func TestBuildPeriodicUpdatedData_OnCompletionPeriodic(t *testing.T) { } } -func TestBuildPeriodicUpdatedData_StoppedReasonPresent(t *testing.T) { - p := &session.PeriodicPrompt{ +func TestBuildLoopUpdatedData_StoppedReasonPresent(t *testing.T) { + p := &session.LoopPrompt{ Prompt: "Test", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: false, StoppedReason: session.StoppedReasonMaxDuration, } - data := conversation.BuildPeriodicUpdatedData("s1", p) - if data["periodic_stopped_reason"] != "maxDuration" { - t.Errorf("periodic_stopped_reason = %v, want %q", data["periodic_stopped_reason"], "maxDuration") + data := conversation.BuildLoopUpdatedData("s1", p) + if data["loop_stopped_reason"] != "maxDuration" { + t.Errorf("loop_stopped_reason = %v, want %q", data["loop_stopped_reason"], "maxDuration") } // trigger must still be present even when stopped. if data["trigger"] != "schedule" { diff --git a/internal/web/session_api.go b/internal/web/session_api.go index a089400f..04b8bdff 100644 --- a/internal/web/session_api.go +++ b/internal/web/session_api.go @@ -103,9 +103,9 @@ func (s *Server) handleSessionQueue(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleSessionPeriodic(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleSessionLoop(w http.ResponseWriter, r *http.Request) { if id, ok := s.sessionIDFromPath(w, r); ok { - s.apiHandlers.HandleSessionPeriodic(w, r, id, r.PathValue("subPath")) + s.apiHandlers.HandleSessionLoop(w, r, id, r.PathValue("subPath")) } } @@ -243,11 +243,11 @@ func (s *Server) buildPromptEnabledContext(sessionID string) *config.PromptEnabl // prompt. Gates "continue"-style prompts that are meaningless when empty. ctx.Session.HasMessages = !meta.LastUserMessageAt.IsZero() - // Periodic conversation type: true when a periodic configuration exists for this - // conversation (matches the PeriodicEnabled UI mode). Distinct from - // session.isPeriodic, which marks a scheduler-triggered run. - if periodic, err := store.Periodic(sessionID).Get(); err == nil && periodic != nil { - ctx.Session.IsPeriodicConversation = true + // Loop conversation type: true when a loop configuration exists for this + // conversation (matches the LoopEnabled UI mode). Distinct from + // session.isLoop, which marks a scheduler-triggered run. + if loop, err := store.Loop(sessionID).Get(); err == nil && loop != nil { + ctx.Session.IsLoopConversation = true } // Parent context (if this is a child) @@ -347,14 +347,20 @@ func (s *Server) buildPromptEnabledContext(sessionID string) *config.PromptEnabl ctx.UserData = udMap } - // Tools context - get from auxiliary manager if available - // (This may be empty if tools haven't been fetched yet) + // Tools context - get from auxiliary manager if available (This may be + // empty if tools haven't been fetched yet, leaving ctx.Tools zero-valued + // — a genuine cold start under the per-server model, see + // config.ToolsContext). Cached tool names are grouped by their inferred + // owning server (token before the first underscore) and marked + // Reachable; a server with no cached tools is simply absent, so it still + // fails open until it's actually discovered (mitto-sys.1). if s.auxiliaryManager != nil && ctx.Workspace.UUID != "" { if tools, ok := s.auxiliaryManager.GetCachedMCPTools(ctx.Workspace.UUID); ok { - ctx.Tools.Available = true + names := make([]string, 0, len(tools)) for _, tool := range tools { - ctx.Tools.Names = append(ctx.Tools.Names, tool.Name) + names = append(names, tool.Name) } + ctx.Tools = config.NewReachableToolsContext(names) } } @@ -488,15 +494,17 @@ func (s *Server) applyWorkspaceNamespace(ctx *config.PromptEnabledContext, worki ctx.ACP.Type = acpServerName } - // Tools context (workspace-level cache; may be empty if not yet fetched) - ctx.Tools.Available = false - ctx.Tools.Names = nil + // Tools context (workspace-level cache; may be empty if not yet fetched). + // Reset to a genuine cold start, then populate per-server state from the + // cache the same way buildSessionPromptEnabledContext does (mitto-sys.1). + ctx.Tools = config.ToolsContext{} if s.auxiliaryManager != nil && ctx.Workspace.UUID != "" { if tools, ok := s.auxiliaryManager.GetCachedMCPTools(ctx.Workspace.UUID); ok { - ctx.Tools.Available = true + names := make([]string, 0, len(tools)) for _, tool := range tools { - ctx.Tools.Names = append(ctx.Tools.Names, tool.Name) + names = append(names, tool.Name) } + ctx.Tools = config.NewReachableToolsContext(names) } } } diff --git a/internal/web/session_api_test.go b/internal/web/session_api_test.go index 9d4dd331..e722747a 100644 --- a/internal/web/session_api_test.go +++ b/internal/web/session_api_test.go @@ -1131,10 +1131,10 @@ func TestHandleListSessions_InvalidOffset(t *testing.T) { } // ============================================================================= -// Periodic glance-fields tests for handleListSessions / SessionListResponse +// Loop glance-fields tests for handleListSessions / SessionListResponse // ============================================================================= -func TestHandleListSessions_PeriodicGlanceFields_Schedule(t *testing.T) { +func TestHandleListSessions_LoopGlanceFields_Schedule(t *testing.T) { tmpDir := t.TempDir() store, err := session.NewStore(tmpDir) if err != nil { @@ -1146,8 +1146,8 @@ func TestHandleListSessions_PeriodicGlanceFields_Schedule(t *testing.T) { if err := store.Create(session.Metadata{SessionID: sid, ACPServer: "test", WorkingDir: "/tmp"}); err != nil { t.Fatalf("Create failed: %v", err) } - // Schedule periodic with explicit cap and duration. - if err := store.Periodic(sid).Set(&session.PeriodicPrompt{ + // Schedule loop with explicit cap and duration. + if err := store.Loop(sid).Set(&session.LoopPrompt{ Prompt: "hello", Frequency: session.Frequency{Value: 30, Unit: session.FrequencyMinutes}, Enabled: true, @@ -1177,25 +1177,25 @@ func TestHandleListSessions_PeriodicGlanceFields_Schedule(t *testing.T) { } s := sessions[0] - if s["periodic_trigger"] != "schedule" { - t.Errorf("periodic_trigger = %v, want %q", s["periodic_trigger"], "schedule") + if s["loop_trigger"] != "schedule" { + t.Errorf("loop_trigger = %v, want %q", s["loop_trigger"], "schedule") } - if s["periodic_iteration_count"] != float64(3) { - t.Errorf("periodic_iteration_count = %v, want 3", s["periodic_iteration_count"]) + if s["loop_iteration_count"] != float64(3) { + t.Errorf("loop_iteration_count = %v, want 3", s["loop_iteration_count"]) } - if s["periodic_max_iterations"] != float64(10) { - t.Errorf("periodic_max_iterations = %v, want 10", s["periodic_max_iterations"]) + if s["loop_max_iterations"] != float64(10) { + t.Errorf("loop_max_iterations = %v, want 10", s["loop_max_iterations"]) } - if s["periodic_max_duration_seconds"] != float64(3600) { - t.Errorf("periodic_max_duration_seconds = %v, want 3600", s["periodic_max_duration_seconds"]) + if s["loop_max_duration_seconds"] != float64(3600) { + t.Errorf("loop_max_duration_seconds = %v, want 3600", s["loop_max_duration_seconds"]) } // delay_seconds=0 is omitempty so it must be absent. - if _, ok := s["periodic_delay_seconds"]; ok { - t.Errorf("periodic_delay_seconds should be absent for schedule trigger with 0 delay") + if _, ok := s["loop_delay_seconds"]; ok { + t.Errorf("loop_delay_seconds should be absent for schedule trigger with 0 delay") } } -func TestHandleListSessions_PeriodicGlanceFields_OnCompletion(t *testing.T) { +func TestHandleListSessions_LoopGlanceFields_OnCompletion(t *testing.T) { tmpDir := t.TempDir() store, err := session.NewStore(tmpDir) if err != nil { @@ -1208,7 +1208,7 @@ func TestHandleListSessions_PeriodicGlanceFields_OnCompletion(t *testing.T) { t.Fatalf("Create failed: %v", err) } // onCompletion with delay and max duration. - if err := store.Periodic(sid).Set(&session.PeriodicPrompt{ + if err := store.Loop(sid).Set(&session.LoopPrompt{ Prompt: "run on idle", Enabled: true, Trigger: session.TriggerOnCompletion, @@ -1235,18 +1235,18 @@ func TestHandleListSessions_PeriodicGlanceFields_OnCompletion(t *testing.T) { } s := sessions[0] - if s["periodic_trigger"] != "onCompletion" { - t.Errorf("periodic_trigger = %v, want %q", s["periodic_trigger"], "onCompletion") + if s["loop_trigger"] != "onCompletion" { + t.Errorf("loop_trigger = %v, want %q", s["loop_trigger"], "onCompletion") } - if s["periodic_delay_seconds"] != float64(60) { - t.Errorf("periodic_delay_seconds = %v, want 60", s["periodic_delay_seconds"]) + if s["loop_delay_seconds"] != float64(60) { + t.Errorf("loop_delay_seconds = %v, want 60", s["loop_delay_seconds"]) } - if s["periodic_max_duration_seconds"] != float64(7200) { - t.Errorf("periodic_max_duration_seconds = %v, want 7200", s["periodic_max_duration_seconds"]) + if s["loop_max_duration_seconds"] != float64(7200) { + t.Errorf("loop_max_duration_seconds = %v, want 7200", s["loop_max_duration_seconds"]) } } -func TestHandleListSessions_PeriodicGlanceFields_EmptyTriggerReportsSchedule(t *testing.T) { +func TestHandleListSessions_LoopGlanceFields_EmptyTriggerReportsSchedule(t *testing.T) { tmpDir := t.TempDir() store, err := session.NewStore(tmpDir) if err != nil { @@ -1259,7 +1259,7 @@ func TestHandleListSessions_PeriodicGlanceFields_EmptyTriggerReportsSchedule(t * t.Fatalf("Create failed: %v", err) } // Trigger="" is the zero-value default; EffectiveTrigger() must resolve it to "schedule". - if err := store.Periodic(sid).Set(&session.PeriodicPrompt{ + if err := store.Loop(sid).Set(&session.LoopPrompt{ Prompt: "hello", Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours}, Enabled: true, @@ -1283,8 +1283,8 @@ func TestHandleListSessions_PeriodicGlanceFields_EmptyTriggerReportsSchedule(t * if len(sessions) == 0 { t.Fatal("expected at least one session") } - if sessions[0]["periodic_trigger"] != "schedule" { - t.Errorf("periodic_trigger = %v, want %q for empty Trigger field", sessions[0]["periodic_trigger"], "schedule") + if sessions[0]["loop_trigger"] != "schedule" { + t.Errorf("loop_trigger = %v, want %q for empty Trigger field", sessions[0]["loop_trigger"], "schedule") } } @@ -1690,16 +1690,18 @@ func TestFilterPromptsByEnabled(t *testing.T) { name: "tools_hasPattern satisfied", prompts: []config.WebPrompt{makePrompt("p", withEnabledWhen(`Tools.HasPattern("mitto_*")`))}, ctx: &config.PromptEnabledContext{ - Tools: config.ToolsContext{Available: true, Names: []string{"mitto_conversation_new", "other_tool"}}, + Tools: config.NewReachableToolsContext([]string{"mitto_conversation_new", "other_tool"}), }, wantNames: []string{"p"}, }, - // 9. Tools.HasPattern unsatisfied + // 9. Tools.HasPattern unsatisfied on a reachable server (mitto is known + // reachable via "mitto_other_thing", but the exact pattern doesn't + // match it — a genuine per-server negative, unlike an unknown server). { - name: "tools_hasPattern unsatisfied", - prompts: []config.WebPrompt{makePrompt("p", withEnabledWhen(`Tools.HasPattern("mitto_*")`))}, + name: "tools_hasPattern unsatisfied on reachable server", + prompts: []config.WebPrompt{makePrompt("p", withEnabledWhen(`Tools.HasPattern("mitto_specific_tool")`))}, ctx: &config.PromptEnabledContext{ - Tools: config.ToolsContext{Available: true, Names: []string{"other_tool"}}, + Tools: config.NewReachableToolsContext([]string{"mitto_other_thing"}), }, wantNames: nil, }, @@ -1708,34 +1710,40 @@ func TestFilterPromptsByEnabled(t *testing.T) { name: "tools_hasAllPatterns all satisfied", prompts: []config.WebPrompt{makePrompt("p", withEnabledWhen(`Tools.HasAllPatterns(["mitto_*", "jira_*"])`))}, ctx: &config.PromptEnabledContext{ - Tools: config.ToolsContext{Available: true, Names: []string{"mitto_foo", "jira_bar"}}, + Tools: config.NewReachableToolsContext([]string{"mitto_foo", "jira_bar"}), }, wantNames: []string{"p"}, }, - // 11. Tools.HasAllPatterns partially satisfied — excluded + // 11. Tools.HasAllPatterns partially satisfied on reachable servers — excluded. + // Both "mitto" and "jira" are known/reachable; "jira_bar" doesn't match + // jira's actual tool ("jira_other"), a genuine negative (an entirely + // unknown "jira" server would instead fail OPEN — see case 18/20). { - name: "tools_hasAllPatterns partially satisfied excluded", - prompts: []config.WebPrompt{makePrompt("p", withEnabledWhen(`Tools.HasAllPatterns(["mitto_*", "jira_*"])`))}, + name: "tools_hasAllPatterns partially satisfied on reachable servers excluded", + prompts: []config.WebPrompt{makePrompt("p", withEnabledWhen(`Tools.HasAllPatterns(["mitto_*", "jira_bar"])`))}, ctx: &config.PromptEnabledContext{ - Tools: config.ToolsContext{Available: true, Names: []string{"mitto_foo"}}, + Tools: config.NewReachableToolsContext([]string{"mitto_foo", "jira_other"}), }, wantNames: nil, }, - // 12. Tools.HasPattern fetched-empty tools — excluded (fail-closed) + // 12. Tools.HasPattern with a genuine cold start (no server ever + // probed at all) — included (fail-open, mitto-sys.1 edge case: an + // empty per-server map cannot be distinguished from "not yet + // fetched", so it preserves the legacy global fail-open behavior). { - name: "tools_hasPattern fetched-empty tools excluded", + name: "tools_hasPattern cold start (no servers known) fail-open included", prompts: []config.WebPrompt{makePrompt("p", withEnabledWhen(`Tools.HasPattern("mitto_*")`))}, ctx: &config.PromptEnabledContext{ - Tools: config.ToolsContext{Available: true, Names: nil}, + Tools: config.NewReachableToolsContext(nil), }, - wantNames: nil, + wantNames: []string{"p"}, }, // 12b. Tools.HasPattern unknown tools — included (fail-open during warm-up) { name: "tools_hasPattern unknown tools fail-open included", prompts: []config.WebPrompt{makePrompt("p", withEnabledWhen(`Tools.HasPattern("mitto_*")`))}, ctx: &config.PromptEnabledContext{ - Tools: config.ToolsContext{Available: false, Names: nil}, + Tools: config.ToolsContext{}, }, wantNames: []string{"p"}, }, @@ -1785,22 +1793,25 @@ func TestFilterPromptsByEnabled(t *testing.T) { }, ctx: &config.PromptEnabledContext{ ACP: config.ACPContext{Name: "Auggie (Opus 4.6)", Type: "augment"}, - Tools: config.ToolsContext{Available: true, Names: []string{"mitto_conversation_new"}}, + Tools: config.NewReachableToolsContext([]string{"mitto_conversation_new"}), Session: config.SessionContext{IsChild: false}, }, wantNames: []string{"p"}, }, - // 18. Combined: ACP.MatchesServerType passes, Tools.HasPattern fails + // 18. Combined: ACP.MatchesServerType passes, Tools.HasPattern fails on + // a reachable "jira" server (jira known via "jira_other_tool", but the + // exact pattern doesn't match it — an entirely unknown "jira" would + // instead fail OPEN, so this pins the genuine-negative case). { - name: "combined acp_matchesServerType passes tools_hasPattern fails excluded", + name: "combined acp_matchesServerType passes tools_hasPattern fails on reachable server excluded", prompts: []config.WebPrompt{ makePrompt("p", - withEnabledWhen(`ACP.MatchesServerType("augment") && Tools.HasPattern("jira_*")`), + withEnabledWhen(`ACP.MatchesServerType("augment") && Tools.HasPattern("jira_specific_tool")`), ), }, ctx: &config.PromptEnabledContext{ ACP: config.ACPContext{Name: "Auggie (Opus 4.6)", Type: "augment"}, - Tools: config.ToolsContext{Available: true, Names: []string{"mitto_foo"}}, + Tools: config.NewReachableToolsContext([]string{"mitto_foo", "jira_other_tool"}), }, wantNames: nil, }, @@ -1814,23 +1825,25 @@ func TestFilterPromptsByEnabled(t *testing.T) { }, ctx: &config.PromptEnabledContext{ ACP: config.ACPContext{Name: "Auggie (Opus 4.6)", Type: "augment"}, - Tools: config.ToolsContext{Available: true, Names: []string{"mitto_foo"}}, + Tools: config.NewReachableToolsContext([]string{"mitto_foo"}), }, wantNames: nil, }, - // 20. Mixed prompts — some pass, some fail, order preserved + // 20. Mixed prompts — some pass, some fail, order preserved. "jira" is + // known/reachable via "jira_other_tool" but the exact pattern doesn't + // match it (a genuine negative — an unknown server would fail open). { name: "mixed prompts correct order", prompts: []config.WebPrompt{ makePrompt("included-1"), makePrompt("excluded-acp", withEnabledWhen(`ACP.MatchesServerType("claude")`)), makePrompt("included-2", withEnabledWhen("!Session.IsChild")), - makePrompt("excluded-mcp", withEnabledWhen(`Tools.HasPattern("jira_*")`)), + makePrompt("excluded-mcp", withEnabledWhen(`Tools.HasPattern("jira_specific_tool")`)), makePrompt("included-3", withEnabledWhen(`ACP.MatchesServerType("augment")`)), }, ctx: &config.PromptEnabledContext{ ACP: config.ACPContext{Name: "Auggie (Opus 4.6)", Type: "augment"}, - Tools: config.ToolsContext{Available: true, Names: []string{"mitto_foo"}}, + Tools: config.NewReachableToolsContext([]string{"mitto_foo", "jira_other_tool"}), Session: config.SessionContext{IsChild: false}, }, wantNames: []string{"included-1", "included-2", "included-3"}, @@ -2292,11 +2305,11 @@ func TestSessionSubresourceRoutingPrecedence(t *testing.T) { mux.HandleFunc("/api/sessions/{id}/queue/{msgId}/{subAction}", func(w http.ResponseWriter, r *http.Request) { hit = "queue:" + r.PathValue("id") + ":" + r.PathValue("msgId") + ":" + r.PathValue("subAction") }) - mux.HandleFunc("/api/sessions/{id}/periodic", func(w http.ResponseWriter, r *http.Request) { - hit = "periodic:" + r.PathValue("id") + ":" + r.PathValue("subPath") + mux.HandleFunc("/api/sessions/{id}/loop", func(w http.ResponseWriter, r *http.Request) { + hit = "loop:" + r.PathValue("id") + ":" + r.PathValue("subPath") }) - mux.HandleFunc("/api/sessions/{id}/periodic/{subPath}", func(w http.ResponseWriter, r *http.Request) { - hit = "periodic:" + r.PathValue("id") + ":" + r.PathValue("subPath") + mux.HandleFunc("/api/sessions/{id}/loop/{subPath}", func(w http.ResponseWriter, r *http.Request) { + hit = "loop:" + r.PathValue("id") + ":" + r.PathValue("subPath") }) cases := map[string]string{ @@ -2307,15 +2320,15 @@ func TestSessionSubresourceRoutingPrecedence(t *testing.T) { "/api/sessions/abc123/user-data": "user-data:abc123", "/api/sessions/abc123/callback": "callback:abc123", // Sub-resources with optional trailing sub-ID (increment 4). - "/api/sessions/abc123/images": "images:abc123:", - "/api/sessions/abc123/images/img7": "images:abc123:img7", - "/api/sessions/abc123/files": "files:abc123:", - "/api/sessions/abc123/files/f9": "files:abc123:f9", - "/api/sessions/abc123/queue": "queue:abc123:", - "/api/sessions/abc123/queue/m42": "queue:abc123:m42", - "/api/sessions/abc123/queue/m42/move": "queue:abc123:m42:move", - "/api/sessions/abc123/periodic": "periodic:abc123:", - "/api/sessions/abc123/periodic/run-now": "periodic:abc123:run-now", + "/api/sessions/abc123/images": "images:abc123:", + "/api/sessions/abc123/images/img7": "images:abc123:img7", + "/api/sessions/abc123/files": "files:abc123:", + "/api/sessions/abc123/files/f9": "files:abc123:f9", + "/api/sessions/abc123/queue": "queue:abc123:", + "/api/sessions/abc123/queue/m42": "queue:abc123:m42", + "/api/sessions/abc123/queue/m42/move": "queue:abc123:m42:move", + "/api/sessions/abc123/loop": "loop:abc123:", + "/api/sessions/abc123/loop/run-now": "loop:abc123:run-now", // Base and events routes (increment 5 — explicit, no subtree). "/api/sessions/abc123": "base:abc123", "/api/sessions/abc123/events": "events:abc123", diff --git a/internal/web/session_loop_api.go b/internal/web/session_loop_api.go new file mode 100644 index 00000000..6f7bf3e2 --- /dev/null +++ b/internal/web/session_loop_api.go @@ -0,0 +1,18 @@ +package web + +import ( + configPkg "github.com/inercia/mitto/internal/config" +) + +// loopDelayFloor returns the configured global floor for the on-completion delay. +// Falls back to the package default when the loop runner is unavailable (e.g. tests). +// +// This server-internal lifecycle helper stays in the web package and is wired into the +// handlers sub-package via Deps.LoopDelayFloor; the HTTP handlers themselves live in +// internal/web/handlers/session_loop*.go. +func (s *Server) loopDelayFloor() int { + if s.loopRunner != nil { + return s.loopRunner.MinLoopCompletionDelaySeconds() + } + return configPkg.DefaultMinLoopCompletionDelaySeconds +} diff --git a/internal/web/session_periodic_api.go b/internal/web/session_periodic_api.go deleted file mode 100644 index 002680ec..00000000 --- a/internal/web/session_periodic_api.go +++ /dev/null @@ -1,18 +0,0 @@ -package web - -import ( - configPkg "github.com/inercia/mitto/internal/config" -) - -// periodicDelayFloor returns the configured global floor for the on-completion delay. -// Falls back to the package default when the periodic runner is unavailable (e.g. tests). -// -// This server-internal lifecycle helper stays in the web package and is wired into the -// handlers sub-package via Deps.PeriodicDelayFloor; the HTTP handlers themselves live in -// internal/web/handlers/session_periodic*.go. -func (s *Server) periodicDelayFloor() int { - if s.periodicRunner != nil { - return s.periodicRunner.MinPeriodicCompletionDelaySeconds() - } - return configPkg.DefaultMinPeriodicCompletionDelaySeconds -} diff --git a/internal/web/session_ws.go b/internal/web/session_ws.go index 08e6f8d4..c1730741 100644 --- a/internal/web/session_ws.go +++ b/internal/web/session_ws.go @@ -15,6 +15,7 @@ import ( acp "github.com/coder/acp-go-sdk" + "github.com/inercia/mitto/internal/auxiliary" "github.com/inercia/mitto/internal/config" "github.com/inercia/mitto/internal/conversation" "github.com/inercia/mitto/internal/logging" @@ -44,6 +45,95 @@ func buildContextUsageMap(size, used int) map[string]interface{} { } } +// eventStats holds counters derived from a single pass over a session's +// persisted events. These counts survive process restarts (unlike the +// in-memory counters on BackgroundSession such as child-wait stats and +// cumulative token usage). +type eventStats struct { + mcpCallsTotal int + mcpUICalls int + mcpChildrenWaitCalls int + turns int + acpToolCalls int + permissionsAllowed int + permissionsDenied int + errors int + imagesUploaded int +} + +// computeEventStats performs a single pass over a session's events, deriving +// MCP-usage, turn, tool-call, permission, error, and image-upload counts. +// tool_call events are deduplicated by ToolCallID so status re-emissions +// (recorded as separate events with the same ID) are only counted once. +func computeEventStats(events []session.Event) eventStats { + var stats eventStats + seenToolCalls := make(map[string]struct{}) + + for _, e := range events { + data, err := session.DecodeEventData(e) + if err != nil { + continue + } + + switch e.Type { + case session.EventTypeToolCall: + d, ok := data.(session.ToolCallData) + if !ok { + continue + } + if _, seen := seenToolCalls[d.ToolCallID]; seen { + continue + } + seenToolCalls[d.ToolCallID] = struct{}{} + + if strings.HasPrefix(d.Title, "mitto_") { + stats.mcpCallsTotal++ + if strings.HasPrefix(d.Title, "mitto_ui_") { + stats.mcpUICalls++ + } + if strings.HasPrefix(d.Title, "mitto_children_tasks_wait") { + stats.mcpChildrenWaitCalls++ + } + } else { + stats.acpToolCalls++ + } + + case session.EventTypeUserPrompt: + stats.turns++ + if d, ok := data.(session.UserPromptData); ok { + stats.imagesUploaded += len(d.Images) + } + + case session.EventTypePermission: + d, ok := data.(session.PermissionData) + if !ok { + continue + } + // The recorded Outcome values today are "auto_approved", "user_selected", + // and "timed_out" — none of which directly say "denied". Auto-approved + // permissions are always allowed. For user-made choices, classify by the + // selected option ID (agent-defined, but conventionally "allow*"/"deny*" + // or "reject*"). Timed-out or unrecognised choices count as neither. + option := strings.ToLower(d.SelectedOption) + switch { + case d.Outcome == "auto_approved": + stats.permissionsAllowed++ + case d.Outcome == "denied": + stats.permissionsDenied++ + case d.Outcome == "user_selected" && strings.Contains(option, "allow"): + stats.permissionsAllowed++ + case d.Outcome == "user_selected" && (strings.Contains(option, "deny") || strings.Contains(option, "reject")): + stats.permissionsDenied++ + } + + case session.EventTypeError: + stats.errors++ + } + } + + return stats +} + // buildUsageMap converts an acp.Usage value into a JSON-serialisable map // suitable for embedding in WebSocket messages. Returns nil when usage is nil. func buildUsageMap(usage *acp.Usage) map[string]interface{} { @@ -265,11 +355,11 @@ func (s *Server) handleSessionWS(w http.ResponseWriter, r *http.Request) { "session_id", sessionID) } } else if s.acpProcessManager != nil && s.acpProcessManager.IsGCSuspended(sessionID) { - // Session was intentionally suspended by the GC's periodic-suspend + // Session was intentionally suspended by the GC's loop-suspend // heuristic. Skip auto-resume to prevent the suspend/resume thrashing // loop (GC closes → frontend reconnects WS → auto-resume → GC closes). // The session will be resumed by ensure_resumed (user focus) or the - // PeriodicRunner (when the prompt is due). + // LoopRunner (when the prompt is due). if clientLogger != nil { clientLogger.Debug("Session is GC-suspended, not auto-resuming", "session_id", sessionID) @@ -443,16 +533,16 @@ func (c *SessionWSClient) sendSessionConnected(bs *conversation.BackgroundSessio c.logger.Warn("Failed to get metadata for connected message", "error", err) } - // Get periodic prompts state. - // periodic_configured = true means a periodic config exists (shows editor UI). - // periodic_enabled = true means runs are active (drives sidebar category + clock icon). - periodicStore := c.store.Periodic(c.sessionID) - if periodic, err := periodicStore.Get(); err == nil && periodic != nil { - data["periodic_configured"] = true - data["periodic_enabled"] = periodic.Enabled + // Get loop prompts state. + // loop_configured = true means a loop config exists (shows editor UI). + // loop_enabled = true means runs are active (drives sidebar category + clock icon). + loopStore := c.store.Loop(c.sessionID) + if loop, err := loopStore.Get(); err == nil && loop != nil { + data["loop_configured"] = true + data["loop_enabled"] = loop.Enabled } else { - data["periodic_configured"] = false - data["periodic_enabled"] = false + data["loop_configured"] = false + data["loop_enabled"] = false } // Get queue length for the session @@ -529,6 +619,59 @@ func (c *SessionWSClient) sendSessionConnected(bs *conversation.BackgroundSessio } } + // Include richer statistics (MCP usage, orchestration, activity). Event-derived + // counts survive restarts; child-wait and cumulative-usage counts are in-memory + // and reset on restart (see BackgroundSession.RecordChildWait / GetCumulativeUsage). + // Computed at connect only (not on every prompt_complete) to bound cost. + if c.store != nil { + if events, err := c.store.ReadEvents(c.sessionID); err == nil { + stats := computeEventStats(events) + if stats.mcpCallsTotal > 0 { + data["mcp_calls_total"] = stats.mcpCallsTotal + } + if stats.mcpUICalls > 0 { + data["mcp_ui_calls"] = stats.mcpUICalls + } + if stats.mcpChildrenWaitCalls > 0 { + data["mcp_children_wait_calls"] = stats.mcpChildrenWaitCalls + } + if stats.turns > 0 { + data["turns"] = stats.turns + } + if stats.acpToolCalls > 0 { + data["acp_tool_calls"] = stats.acpToolCalls + } + if stats.permissionsAllowed > 0 { + data["permissions_allowed"] = stats.permissionsAllowed + } + if stats.permissionsDenied > 0 { + data["permissions_denied"] = stats.permissionsDenied + } + if stats.errors > 0 { + data["errors"] = stats.errors + } + if stats.imagesUploaded > 0 { + data["images_uploaded"] = stats.imagesUploaded + } + } + if count, err := c.store.CountChildSessions(c.sessionID); err == nil && count > 0 { + data["children_spawned"] = count + } + } + if bs != nil { + if waitCount, waitTotal := bs.GetChildWaitStats(); waitCount > 0 { + data["child_wait_count"] = waitCount + data["child_wait_total_ms"] = waitTotal.Milliseconds() + } + if in, out, total := bs.GetCumulativeUsage(); total > 0 { + data["usage_cumulative"] = map[string]interface{}{ + "input_tokens": in, + "output_tokens": out, + "total_tokens": total, + } + } + } + // MCP bind status (global, server-level). Always included so the frontend can // show/clear a persistent badge across reconnects. data["mcp"] = map[string]interface{}{ @@ -1397,7 +1540,7 @@ func (c *SessionWSClient) handleKeepalive(clientTime int64, clientLastSeenSeq in "queue_length": queueLength, "status": status, } - // Include processor stats for periodic UI refresh + // Include processor stats for loop UI refresh if c.bgSession != nil { procCount, procActivations, procLastAt, procLastNames := c.bgSession.GetProcessorStats() keepaliveData["processor_count"] = procCount @@ -1651,16 +1794,59 @@ func (c *SessionWSClient) triggerMCPToolsFetch(workspaceUUID string) { }) } + // Late-starting servers (mitto-sys.5): keep re-probing configured-but- + // unreachable MCP servers with bounded backoff and broadcast tools as they + // come online. Use context.Background(), NOT the fetch's 30-min ctx (it is + // cancelled by defer cancel() when this function returns); the backoff's + // MaxAttempts terminates the loop. Deduped per workspace inside the manager. + if c.server != nil && c.server.auxiliaryManager != nil { + srv := c.server + wsUUID := workspaceUUID + srv.auxiliaryManager.EnsureMCPBackoffRetry(context.Background(), wsUUID, func(tools []auxiliary.MCPToolInfo) { + if srv.eventsManager != nil { + srv.eventsManager.Broadcast(conversation.WSMsgTypeMCPToolsAvailable, map[string]interface{}{ + "workspace_uuid": wsUUID, + "tools": tools, + }) + } + }) + + // Event-driven tool refresh (mitto-sys.4): keep a persistent + // notifications/tools/list_changed watcher per configured server and + // broadcast tools as they change, without waiting for the next + // polling round. Use context.Background() (NOT the fetch ctx, which + // is cancelled on return); watcher lifetime is bounded by teardown + // (Shutdown/RemoveWorkspace). Deduped per workspace inside the manager. + srv.auxiliaryManager.EnsureMCPWatchers(context.Background(), wsUUID, func(tools []auxiliary.MCPToolInfo) { + if srv.eventsManager != nil { + srv.eventsManager.Broadcast(conversation.WSMsgTypeMCPToolsAvailable, map[string]interface{}{ + "workspace_uuid": wsUUID, + "tools": tools, + }) + // Event-driven prompts_changed (no dependence on the 30/60/120s timer). + // Server-scoped: this callback outlives the per-client `c`, so we must + // NOT capture c here — mirror broadcastPromptsChanged inline instead. + srv.eventsManager.Broadcast(WSMsgTypePromptsChanged, map[string]interface{}{ + "changed_dirs": []string{}, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "reason": "mcp_tools_event", + }) + } + }) + } + // After broadcasting tools, check required tool patterns from prompts. go c.checkRequiredToolPatterns(workspaceUUID) } // checkRequiredToolPatterns checks if any prompts have tool pattern requirements in their -// enabledWhen CEL expressions and, if so, broadcasts prompts_changed immediately plus at -// delayed intervals so the frontend re-fetches and the backend re-evaluates with whatever -// MCP tools are cached at that point. -// MCP tools from external servers can take time to appear (e.g., external Python programs), -// so delayed re-broadcasts are used to catch late-appearing tools. +// enabledWhen CEL expressions and, if so, broadcasts prompts_changed immediately so the +// frontend re-fetches and the backend re-evaluates with whatever MCP tools are cached at +// that point. +// Late-appearing or changed MCP tools no longer need a blind timed re-broadcast here: they +// surface via the event-driven watcher (mitto-sys.4: EnsureMCPWatchers -> onUpdate -> +// broadcastPromptsChanged("mcp_tools_event")) and the bounded-backoff re-probe path +// (mitto-sys.5), both of which re-broadcast prompts_changed as soon as tools actually change. func (c *SessionWSClient) checkRequiredToolPatterns(workspaceUUID string) { patterns := c.collectRequiredToolPatterns() if len(patterns) == 0 { @@ -1672,32 +1858,15 @@ func (c *SessionWSClient) checkRequiredToolPatterns(workspaceUUID string) { } if c.logger != nil { - c.logger.Debug("required tools check: scheduling re-broadcasts for late-loading tools", + c.logger.Debug("required tools check: broadcasting initial filtered prompts list", "workspace_uuid", workspaceUUID, "pattern_count", len(patterns), "patterns", patterns) } - // Broadcast immediately so frontend gets initial filtered list. + // Broadcast immediately so frontend gets initial filtered list. Late/changed + // tools are handled by the event-driven watcher and backoff paths, not here. c.broadcastPromptsChanged("mcp_tools_initial") - - // Schedule delayed re-broadcasts to catch late-appearing MCP tools. - retryDelays := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second} - for _, delay := range retryDelays { - timer := time.NewTimer(delay) - select { - case <-timer.C: - c.broadcastPromptsChanged("mcp_tools_retry") - case <-c.ctx.Done(): - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - return - } - } } // collectRequiredToolPatterns collects all unique tool patterns from enabledWhen CEL expressions across all prompt sources. @@ -1811,7 +1980,7 @@ func (c *SessionWSClient) handleEnsureResumed() { } // Clear GC-suspended flag — the user explicitly focused this session, - // so it should resume regardless of the periodic suspend heuristic. + // so it should resume regardless of the loop suspend heuristic. if c.server.acpProcessManager != nil { c.server.acpProcessManager.ClearGCSuspended(c.sessionID) } @@ -2415,7 +2584,7 @@ func (c *SessionWSClient) OnUserPrompt(seq int64, senderID, promptID, message st // also delivered this event. Skipping here races with handleLoadEvents: // a concurrent load_events can update lastSentSeq to include this seq before // the observer notification runs, silently dropping the live notification. - // This caused periodic prompt pills to never appear in real-time. + // This caused loop prompt pills to never appear in real-time. c.seqMu.Lock() if seq > c.lastSentSeq { c.lastSentSeq = seq diff --git a/internal/web/session_ws_test.go b/internal/web/session_ws_test.go index 9e7ee67f..59fd489d 100644 --- a/internal/web/session_ws_test.go +++ b/internal/web/session_ws_test.go @@ -3,10 +3,13 @@ package web import ( "encoding/json" "os" + "path/filepath" "sync" "testing" "time" + "github.com/inercia/mitto/internal/appdir" + "github.com/inercia/mitto/internal/config" "github.com/inercia/mitto/internal/conversation" "github.com/inercia/mitto/internal/session" ) @@ -1228,3 +1231,182 @@ func TestSessionWSClient_OnEventMeta_AttachedToUserPrompt(t *testing.T) { } }) } + +// ============================================================================= +// checkRequiredToolPatterns: no blind timed re-broadcast (mitto-sys.12) +// ============================================================================= + +// TestCheckRequiredToolPatterns_NoTimedRetry proves that checkRequiredToolPatterns +// no longer schedules a 30/60/120s re-broadcast loop: it must return promptly +// (well under 1s) and must broadcast prompts_changed with reason +// "mcp_tools_initial" exactly once, never with reason "mcp_tools_retry". Late/ +// changed tools now surface via the event-driven watcher (mitto-sys.4) and the +// bounded-backoff path (mitto-sys.5) instead. +func TestCheckRequiredToolPatterns_NoTimedRetry(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(appdir.MittoDirEnv, tmpDir) + appdir.ResetCache() + t.Cleanup(appdir.ResetCache) + + promptsDir := filepath.Join(tmpDir, appdir.PromptsDirName) + if err := os.MkdirAll(promptsDir, 0755); err != nil { + t.Fatalf("failed to create prompts dir: %v", err) + } + // A prompt with an enabledWhen tool-pattern requirement so + // collectRequiredToolPatterns returns a non-empty list, which is the + // precondition for the (formerly timed) broadcast path to run at all. + promptContent := `name: "Slack Prompt" +enabledWhen: 'Tools.HasPattern("slack_*")' +prompt: | + Do something with Slack. +` + if err := os.WriteFile(filepath.Join(promptsDir, "slack.prompt.yaml"), []byte(promptContent), 0644); err != nil { + t.Fatalf("failed to write prompt file: %v", err) + } + + promptsCache := config.NewPromptsCache() + if _, err := promptsCache.Get(); err != nil { + t.Fatalf("PromptsCache.Get() failed: %v", err) + } + + eventsManager := NewGlobalEventsManager() + captureSend := make(chan []byte, 16) + eventsClient := &GlobalEventsClient{ + wsConn: &WSConn{send: captureSend}, + done: make(chan struct{}), + } + eventsManager.Register(eventsClient) + defer eventsManager.Unregister(eventsClient) + + server := &Server{ + config: Config{PromptsCache: promptsCache}, + eventsManager: eventsManager, + } + client := &SessionWSClient{ + sessionID: "test-session", + server: server, + } + + done := make(chan struct{}) + start := time.Now() + go func() { + client.checkRequiredToolPatterns("ws-uuid") + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("checkRequiredToolPatterns did not return within 1s — a blind timed re-broadcast loop appears to still be present") + } + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Errorf("checkRequiredToolPatterns took %v, want well under 1s (no timed retry loop)", elapsed) + } + + // Drain and inspect every broadcast message; give the (already-returned) + // call a brief moment in case of any async send. + var reasons []string + deadline := time.After(200 * time.Millisecond) +drain: + for { + select { + case raw := <-captureSend: + var msg struct { + Type string `json:"type"` + Data struct { + Reason string `json:"reason"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + t.Fatalf("failed to unmarshal broadcast message: %v", err) + } + if msg.Type != WSMsgTypePromptsChanged { + t.Errorf("broadcast type = %q, want %q", msg.Type, WSMsgTypePromptsChanged) + } + reasons = append(reasons, msg.Data.Reason) + case <-deadline: + break drain + } + } + + if len(reasons) != 1 { + t.Fatalf("got %d prompts_changed broadcasts %v, want exactly 1", len(reasons), reasons) + } + if reasons[0] != "mcp_tools_initial" { + t.Errorf("broadcast reason = %q, want %q", reasons[0], "mcp_tools_initial") + } + for _, r := range reasons { + if r == "mcp_tools_retry" { + t.Errorf("found a mcp_tools_retry broadcast — the timed re-broadcast loop must be fully removed") + } + } +} + +func TestComputeEventStats(t *testing.T) { + tests := []struct { + name string + events []session.Event + want eventStats + }{ + { + name: "empty", + events: nil, + want: eventStats{}, + }, + { + name: "mcp and acp tool calls counted separately", + events: []session.Event{ + {Type: session.EventTypeToolCall, Data: session.ToolCallData{ToolCallID: "1", Title: "mitto_conversation_get_current_mitto"}}, + {Type: session.EventTypeToolCall, Data: session.ToolCallData{ToolCallID: "2", Title: "mitto_ui_options_mitto"}}, + {Type: session.EventTypeToolCall, Data: session.ToolCallData{ToolCallID: "3", Title: "mitto_children_tasks_wait_mitto"}}, + {Type: session.EventTypeToolCall, Data: session.ToolCallData{ToolCallID: "4", Title: "str-replace-editor"}}, + }, + want: eventStats{mcpCallsTotal: 3, mcpUICalls: 1, mcpChildrenWaitCalls: 1, acpToolCalls: 1}, + }, + { + name: "duplicate tool_call_id counted once", + events: []session.Event{ + {Type: session.EventTypeToolCall, Data: session.ToolCallData{ToolCallID: "1", Title: "mitto_ui_notify_mitto", Status: "pending"}}, + {Type: session.EventTypeToolCall, Data: session.ToolCallData{ToolCallID: "1", Title: "mitto_ui_notify_mitto", Status: "completed"}}, + }, + want: eventStats{mcpCallsTotal: 1, mcpUICalls: 1}, + }, + { + name: "turns and images from user prompts", + events: []session.Event{ + {Type: session.EventTypeUserPrompt, Data: session.UserPromptData{Message: "hi"}}, + {Type: session.EventTypeUserPrompt, Data: session.UserPromptData{Message: "again", Images: []session.ImageRef{{ID: "a"}, {ID: "b"}}}}, + }, + want: eventStats{turns: 2, imagesUploaded: 2}, + }, + { + name: "errors counted", + events: []session.Event{ + {Type: session.EventTypeError, Data: session.ErrorData{Message: "boom"}}, + {Type: session.EventTypeError, Data: session.ErrorData{Message: "boom2"}}, + }, + want: eventStats{errors: 2}, + }, + { + name: "permissions bucketed by outcome and selected option", + events: []session.Event{ + {Type: session.EventTypePermission, Data: session.PermissionData{Outcome: "auto_approved", SelectedOption: "allow-once"}}, + {Type: session.EventTypePermission, Data: session.PermissionData{Outcome: "user_selected", SelectedOption: "allow"}}, + {Type: session.EventTypePermission, Data: session.PermissionData{Outcome: "user_selected", SelectedOption: "deny"}}, + {Type: session.EventTypePermission, Data: session.PermissionData{Outcome: "user_selected", SelectedOption: "reject-once"}}, + {Type: session.EventTypePermission, Data: session.PermissionData{Outcome: "timed_out", SelectedOption: ""}}, + {Type: session.EventTypePermission, Data: session.PermissionData{Outcome: "user_selected", SelectedOption: "unknown-option"}}, + }, + want: eventStats{permissionsAllowed: 2, permissionsDenied: 2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeEventStats(tt.events) + if got != tt.want { + t.Errorf("computeEventStats() = %+v, want %+v", got, tt.want) + } + }) + } +} diff --git a/internal/web/tasks_baseline.go b/internal/web/tasks_baseline.go index bb09b575..84585f47 100644 --- a/internal/web/tasks_baseline.go +++ b/internal/web/tasks_baseline.go @@ -11,7 +11,7 @@ import ( "github.com/inercia/mitto/internal/fileutil" ) -// tasksBaselineFileName is the per-session file (alongside periodic.json) that +// tasksBaselineFileName is the per-session file (alongside loop.json) that // persists the raw beads snapshot the onTasks trigger last considered "current" // for that conversation — i.e. its diff baseline. const tasksBaselineFileName = "tasks_baseline.json" @@ -38,7 +38,7 @@ type TasksBaseline struct { } // TasksBaselineStore manages the onTasks diff baseline file for a single -// session directory. Unlike PeriodicStore, it carries no in-memory mutex — +// session directory. Unlike LoopStore, it carries no in-memory mutex — // each instance is short-lived (created fresh per call) and writes go through // fileutil.WriteJSONAtomic, which is safe for concurrent writers at the // filesystem level (rename-based atomic replace). diff --git a/internal/web/ws_messages.go b/internal/web/ws_messages.go index 6e963744..edcda431 100644 --- a/internal/web/ws_messages.go +++ b/internal/web/ws_messages.go @@ -125,10 +125,10 @@ const ( // Data: { "session_id": string, "settings": { "flag_name": bool, ... } } WSMsgTypeSessionSettingsUpdated = "session_settings_updated" - // WSMsgTypePeriodicStarted notifies that a periodic prompt was delivered. - // Sent on /api/events to all connected clients when a scheduled periodic run starts. + // WSMsgTypeLoopStarted notifies that a loop prompt was delivered. + // Sent on /api/events to all connected clients when a scheduled loop run starts. // Data: { "session_id": string, "session_name": string } - WSMsgTypePeriodicStarted = "periodic_started" + WSMsgTypeLoopStarted = "loop_started" // WSMsgTypeAgentMessage contains HTML-rendered agent response content. // Sent incrementally as the agent generates output. diff --git a/tests/fixtures/workspaces/project-alpha/.mitto/prompts/periodic-param-prompt.prompt.yaml b/tests/fixtures/workspaces/project-alpha/.mitto/prompts/loop-param-prompt.prompt.yaml similarity index 55% rename from tests/fixtures/workspaces/project-alpha/.mitto/prompts/periodic-param-prompt.prompt.yaml rename to tests/fixtures/workspaces/project-alpha/.mitto/prompts/loop-param-prompt.prompt.yaml index fbfaf398..a71c473d 100644 --- a/tests/fixtures/workspaces/project-alpha/.mitto/prompts/periodic-param-prompt.prompt.yaml +++ b/tests/fixtures/workspaces/project-alpha/.mitto/prompts/loop-param-prompt.prompt.yaml @@ -1,7 +1,7 @@ -name: Periodic Param Test -description: A periodic-selector prompt with a required text param (E2E edit-args dialog testing) -menus: promptsPeriodic -group: Periodic +name: Loop Param Test +description: A loop-selector prompt with a required text param (E2E edit-args dialog testing) +menus: promptsLoop +group: Loop parameters: - name: TASK type: text diff --git a/tests/integration/inprocess/callback_test.go b/tests/integration/inprocess/callback_test.go index fc6897d7..165b44f2 100644 --- a/tests/integration/inprocess/callback_test.go +++ b/tests/integration/inprocess/callback_test.go @@ -88,7 +88,7 @@ func TestCallback_EnableAndGet(t *testing.T) { } } -// TestCallback_TriggerSuccess tests triggering a callback with periodic configured. +// TestCallback_TriggerSuccess tests triggering a callback with loop configured. func TestCallback_TriggerSuccess(t *testing.T) { ts := SetupTestServer(t) @@ -99,28 +99,28 @@ func TestCallback_TriggerSuccess(t *testing.T) { } defer ts.Client.DeleteSession(sess.SessionID) - // Configure periodic prompt - periodicBody := map[string]interface{}{ - "prompt": "test periodic prompt", + // Configure loop prompt + loopBody := map[string]interface{}{ + "prompt": "test loop prompt", "frequency": map[string]interface{}{ "value": 30, "unit": "minutes", }, "enabled": true, } - periodicJSON, _ := json.Marshal(periodicBody) - periodicURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/periodic" - periodicReq, _ := http.NewRequest(http.MethodPut, periodicURL, strings.NewReader(string(periodicJSON))) - periodicReq.Header.Set("Content-Type", "application/json") - periodicResp, err := ts.HTTPServer.Client().Do(periodicReq) + loopJSON, _ := json.Marshal(loopBody) + loopURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/loop" + loopReq, _ := http.NewRequest(http.MethodPut, loopURL, strings.NewReader(string(loopJSON))) + loopReq.Header.Set("Content-Type", "application/json") + loopResp, err := ts.HTTPServer.Client().Do(loopReq) if err != nil { - t.Fatalf("PUT periodic: %v", err) + t.Fatalf("PUT loop: %v", err) } - defer periodicResp.Body.Close() + defer loopResp.Body.Close() - if periodicResp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(periodicResp.Body) - t.Fatalf("expected 200 for periodic, got %d: %s", periodicResp.StatusCode, body) + if loopResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(loopResp.Body) + t.Fatalf("expected 200 for loop, got %d: %s", loopResp.StatusCode, body) } // Enable callback @@ -246,19 +246,19 @@ func TestCallback_MethodNotAllowed(t *testing.T) { } } -// TestCallback_PeriodicDisabled tests that callback fails when periodic is disabled. -func TestCallback_PeriodicDisabled(t *testing.T) { +// TestCallback_LoopDisabled tests that callback fails when loop is disabled. +func TestCallback_LoopDisabled(t *testing.T) { ts := SetupTestServer(t) // Create session - sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "periodic-disabled-test"}) + sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "loop-disabled-test"}) if err != nil { t.Fatalf("CreateSession: %v", err) } defer ts.Client.DeleteSession(sess.SessionID) - // Configure periodic (enabled) - periodicBody := map[string]interface{}{ + // Configure loop (enabled) + loopBody := map[string]interface{}{ "prompt": "test prompt", "frequency": map[string]interface{}{ "value": 30, @@ -266,15 +266,15 @@ func TestCallback_PeriodicDisabled(t *testing.T) { }, "enabled": true, } - periodicJSON, _ := json.Marshal(periodicBody) - periodicURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/periodic" - periodicReq, _ := http.NewRequest(http.MethodPut, periodicURL, strings.NewReader(string(periodicJSON))) - periodicReq.Header.Set("Content-Type", "application/json") - periodicResp, err := ts.HTTPServer.Client().Do(periodicReq) + loopJSON, _ := json.Marshal(loopBody) + loopURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/loop" + loopReq, _ := http.NewRequest(http.MethodPut, loopURL, strings.NewReader(string(loopJSON))) + loopReq.Header.Set("Content-Type", "application/json") + loopResp, err := ts.HTTPServer.Client().Do(loopReq) if err != nil { - t.Fatalf("PUT periodic: %v", err) + t.Fatalf("PUT loop: %v", err) } - periodicResp.Body.Close() + loopResp.Body.Close() // Enable callback enableURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/callback" @@ -290,16 +290,16 @@ func TestCallback_PeriodicDisabled(t *testing.T) { token := extractCallbackToken(callbackURL) testCallbackURL := buildTestCallbackURL(ts, token) - // Disable periodic - periodicBody["enabled"] = false - periodicJSON, _ = json.Marshal(periodicBody) - periodicReq, _ = http.NewRequest(http.MethodPut, periodicURL, strings.NewReader(string(periodicJSON))) - periodicReq.Header.Set("Content-Type", "application/json") - periodicResp, err = ts.HTTPServer.Client().Do(periodicReq) + // Disable loop + loopBody["enabled"] = false + loopJSON, _ = json.Marshal(loopBody) + loopReq, _ = http.NewRequest(http.MethodPut, loopURL, strings.NewReader(string(loopJSON))) + loopReq.Header.Set("Content-Type", "application/json") + loopResp, err = ts.HTTPServer.Client().Do(loopReq) if err != nil { - t.Fatalf("PUT periodic (disable): %v", err) + t.Fatalf("PUT loop (disable): %v", err) } - periodicResp.Body.Close() + loopResp.Body.Close() // Try to trigger callback triggerResp, err := ts.HTTPServer.Client().Post(testCallbackURL, "application/json", nil) @@ -308,7 +308,7 @@ func TestCallback_PeriodicDisabled(t *testing.T) { } defer triggerResp.Body.Close() - // Should get 410 Gone (periodic_disabled) + // Should get 410 Gone (loop_disabled) if triggerResp.StatusCode != http.StatusGone { body, _ := io.ReadAll(triggerResp.Body) t.Fatalf("expected 410, got %d: %s", triggerResp.StatusCode, body) @@ -319,25 +319,25 @@ func TestCallback_PeriodicDisabled(t *testing.T) { if err := json.NewDecoder(triggerResp.Body).Decode(&errorResp); err != nil { t.Logf("Note: couldn't decode error response (acceptable): %v", err) } else if code, ok := errorResp["code"].(string); ok { - if code != "periodic_disabled" { - t.Errorf("expected error code 'periodic_disabled', got %v", code) + if code != "loop_disabled" { + t.Errorf("expected error code 'loop_disabled', got %v", code) } } } -// TestCallback_PeriodicReEnabled_SameURL tests that re-enabling periodic works with same URL. -func TestCallback_PeriodicReEnabled_SameURL(t *testing.T) { +// TestCallback_LoopReEnabled_SameURL tests that re-enabling loop works with same URL. +func TestCallback_LoopReEnabled_SameURL(t *testing.T) { ts := SetupTestServer(t) // Create session - sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "periodic-reenabled-test"}) + sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "loop-reenabled-test"}) if err != nil { t.Fatalf("CreateSession: %v", err) } defer ts.Client.DeleteSession(sess.SessionID) - // Configure periodic (enabled) - periodicBody := map[string]interface{}{ + // Configure loop (enabled) + loopBody := map[string]interface{}{ "prompt": "test prompt", "frequency": map[string]interface{}{ "value": 30, @@ -345,15 +345,15 @@ func TestCallback_PeriodicReEnabled_SameURL(t *testing.T) { }, "enabled": true, } - periodicJSON, _ := json.Marshal(periodicBody) - periodicURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/periodic" - periodicReq, _ := http.NewRequest(http.MethodPut, periodicURL, strings.NewReader(string(periodicJSON))) - periodicReq.Header.Set("Content-Type", "application/json") - periodicResp, err := ts.HTTPServer.Client().Do(periodicReq) + loopJSON, _ := json.Marshal(loopBody) + loopURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/loop" + loopReq, _ := http.NewRequest(http.MethodPut, loopURL, strings.NewReader(string(loopJSON))) + loopReq.Header.Set("Content-Type", "application/json") + loopResp, err := ts.HTTPServer.Client().Do(loopReq) if err != nil { - t.Fatalf("PUT periodic: %v", err) + t.Fatalf("PUT loop: %v", err) } - periodicResp.Body.Close() + loopResp.Body.Close() // Enable callback enableURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/callback" @@ -369,27 +369,27 @@ func TestCallback_PeriodicReEnabled_SameURL(t *testing.T) { token := extractCallbackToken(callbackURL) testCallbackURL := buildTestCallbackURL(ts, token) - // Disable periodic - periodicBody["enabled"] = false - periodicJSON, _ = json.Marshal(periodicBody) - periodicReq, _ = http.NewRequest(http.MethodPut, periodicURL, strings.NewReader(string(periodicJSON))) - periodicReq.Header.Set("Content-Type", "application/json") - periodicResp, err = ts.HTTPServer.Client().Do(periodicReq) + // Disable loop + loopBody["enabled"] = false + loopJSON, _ = json.Marshal(loopBody) + loopReq, _ = http.NewRequest(http.MethodPut, loopURL, strings.NewReader(string(loopJSON))) + loopReq.Header.Set("Content-Type", "application/json") + loopResp, err = ts.HTTPServer.Client().Do(loopReq) if err != nil { - t.Fatalf("PUT periodic (disable): %v", err) + t.Fatalf("PUT loop (disable): %v", err) } - periodicResp.Body.Close() + loopResp.Body.Close() - // Re-enable periodic - periodicBody["enabled"] = true - periodicJSON, _ = json.Marshal(periodicBody) - periodicReq, _ = http.NewRequest(http.MethodPut, periodicURL, strings.NewReader(string(periodicJSON))) - periodicReq.Header.Set("Content-Type", "application/json") - periodicResp, err = ts.HTTPServer.Client().Do(periodicReq) + // Re-enable loop + loopBody["enabled"] = true + loopJSON, _ = json.Marshal(loopBody) + loopReq, _ = http.NewRequest(http.MethodPut, loopURL, strings.NewReader(string(loopJSON))) + loopReq.Header.Set("Content-Type", "application/json") + loopResp, err = ts.HTTPServer.Client().Do(loopReq) if err != nil { - t.Fatalf("PUT periodic (re-enable): %v", err) + t.Fatalf("PUT loop (re-enable): %v", err) } - periodicResp.Body.Close() + loopResp.Body.Close() // Try to trigger callback with same URL triggerResp, err := ts.HTTPServer.Client().Post(testCallbackURL, "application/json", nil) @@ -401,7 +401,7 @@ func TestCallback_PeriodicReEnabled_SameURL(t *testing.T) { // Should work (200, 409, or 500) - NOT 404 or 410 if triggerResp.StatusCode == http.StatusNotFound || triggerResp.StatusCode == http.StatusGone { body, _ := io.ReadAll(triggerResp.Body) - t.Fatalf("callback should work after re-enabling periodic, got %d: %s", triggerResp.StatusCode, body) + t.Fatalf("callback should work after re-enabling loop, got %d: %s", triggerResp.StatusCode, body) } t.Logf("Callback works after re-enabling: %d", triggerResp.StatusCode) @@ -581,8 +581,8 @@ func TestCallback_RateLimit(t *testing.T) { } defer ts.Client.DeleteSession(sess.SessionID) - // Configure periodic - periodicBody := map[string]interface{}{ + // Configure loop + loopBody := map[string]interface{}{ "prompt": "test prompt", "frequency": map[string]interface{}{ "value": 30, @@ -590,15 +590,15 @@ func TestCallback_RateLimit(t *testing.T) { }, "enabled": true, } - periodicJSON, _ := json.Marshal(periodicBody) - periodicURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/periodic" - periodicReq, _ := http.NewRequest(http.MethodPut, periodicURL, strings.NewReader(string(periodicJSON))) - periodicReq.Header.Set("Content-Type", "application/json") - periodicResp, err := ts.HTTPServer.Client().Do(periodicReq) + loopJSON, _ := json.Marshal(loopBody) + loopURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/loop" + loopReq, _ := http.NewRequest(http.MethodPut, loopURL, strings.NewReader(string(loopJSON))) + loopReq.Header.Set("Content-Type", "application/json") + loopResp, err := ts.HTTPServer.Client().Do(loopReq) if err != nil { - t.Fatalf("PUT periodic: %v", err) + t.Fatalf("PUT loop: %v", err) } - periodicResp.Body.Close() + loopResp.Body.Close() // Enable callback enableURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/callback" @@ -671,18 +671,18 @@ func TestCallback_GetNotFound(t *testing.T) { } } -// TestCallback_PeriodicNotConfigured tests that callback trigger fails when periodic is not configured. -func TestCallback_PeriodicNotConfigured(t *testing.T) { +// TestCallback_LoopNotConfigured tests that callback trigger fails when loop is not configured. +func TestCallback_LoopNotConfigured(t *testing.T) { ts := SetupTestServer(t) // Create session - sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "no-periodic-test"}) + sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "no-loop-test"}) if err != nil { t.Fatalf("CreateSession: %v", err) } defer ts.Client.DeleteSession(sess.SessionID) - // Enable callback WITHOUT configuring periodic + // Enable callback WITHOUT configuring loop enableURL := ts.HTTPServer.URL + "/mitto/api/sessions/" + sess.SessionID + "/callback" enableResp, err := ts.HTTPServer.Client().Post(enableURL, "application/json", nil) if err != nil { @@ -703,7 +703,7 @@ func TestCallback_PeriodicNotConfigured(t *testing.T) { } defer triggerResp.Body.Close() - // Should get 410 Gone (periodic not configured is same as disabled) + // Should get 410 Gone (loop not configured is same as disabled) if triggerResp.StatusCode != http.StatusGone { body, _ := io.ReadAll(triggerResp.Body) t.Fatalf("expected 410, got %d: %s", triggerResp.StatusCode, body) @@ -711,7 +711,7 @@ func TestCallback_PeriodicNotConfigured(t *testing.T) { var errorResp map[string]interface{} json.NewDecoder(triggerResp.Body).Decode(&errorResp) - if code, ok := errorResp["code"].(string); !ok || code != "periodic_not_configured" { - t.Logf("Note: error code is %v (may be 'periodic_not_configured' or 'periodic_disabled')", errorResp["code"]) + if code, ok := errorResp["code"].(string); !ok || code != "loop_disabled" { + t.Logf("Note: error code is %v (expected 'loop_disabled')", errorResp["code"]) } } diff --git a/tests/integration/inprocess/event_injector.go b/tests/integration/inprocess/event_injector.go index d9fb9dfb..854219d1 100644 --- a/tests/integration/inprocess/event_injector.go +++ b/tests/integration/inprocess/event_injector.go @@ -130,7 +130,7 @@ func (inj *TestEventInjector) InjectMixed(rounds int) (firstSeq, lastSeq int64) } // InjectUserPromptWithName injects a single user_prompt event with the given -// message text and prompt name. This simulates what the periodic runner records +// message text and prompt name. This simulates what the loop runner records // when delivering a workspace prompt. Returns the seq of the injected event. func (inj *TestEventInjector) InjectUserPromptWithName(message, promptName string) int64 { inj.t.Helper() diff --git a/tests/integration/inprocess/periodic_context_test.go b/tests/integration/inprocess/loop_context_test.go similarity index 52% rename from tests/integration/inprocess/periodic_context_test.go rename to tests/integration/inprocess/loop_context_test.go index a8df4313..37d9cbc1 100644 --- a/tests/integration/inprocess/periodic_context_test.go +++ b/tests/integration/inprocess/loop_context_test.go @@ -9,35 +9,35 @@ import ( "github.com/inercia/mitto/internal/client" ) -// TestPeriodicContextSemantics verifies the three context-aware periodic send cases: +// TestLoopContextSemantics verifies the three context-aware loop send cases: // -// (a) NEW: PUT periodic with prompt_name + frequency + max_iterations → GET shows enabled config. -// (b) REGULAR→periodic: PUT periodic (enabled), then run-now → periodic configured. -// (c) PERIODIC one-shot: with a config already set, POST /queue with a DIFFERENT prompt_name → +// (a) NEW: PUT loop with prompt_name + frequency + max_iterations → GET shows enabled config. +// (b) REGULAR→loop: PUT loop (enabled), then run-now → loop configured. +// (c) LOOP one-shot: with a config already set, POST /queue with a DIFFERENT prompt_name → // -// periodic config is UNCHANGED (same prompt_name, frequency, max_iterations, enabled). -func TestPeriodicContextSemantics(t *testing.T) { +// loop config is UNCHANGED (same prompt_name, frequency, max_iterations, enabled). +func TestLoopContextSemantics(t *testing.T) { ts := SetupTestServer(t) // ------------------------------------------------------------------------- - // Case (a): Configure periodic on a fresh session — verify GET reflects it. + // Case (a): Configure loop on a fresh session — verify GET reflects it. // ------------------------------------------------------------------------- - t.Run("new_periodic_config", func(t *testing.T) { - sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "periodic-new"}) + t.Run("new_loop_config", func(t *testing.T) { + sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "loop-new"}) if err != nil { t.Fatalf("CreateSession failed: %v", err) } defer ts.Client.DeleteSession(sess.SessionID) - req := client.SetPeriodicRequest{ + req := client.SetLoopRequest{ PromptName: "daily-standup", - Frequency: client.PeriodicFrequency{Value: 2, Unit: "hours"}, + Frequency: client.LoopFrequency{Value: 2, Unit: "hours"}, Enabled: true, MaxIterations: 5, } - cfg, err := ts.Client.SetPeriodic(sess.SessionID, req) + cfg, err := ts.Client.SetLoop(sess.SessionID, req) if err != nil { - t.Fatalf("SetPeriodic failed: %v", err) + t.Fatalf("SetLoop failed: %v", err) } if !cfg.Enabled { t.Errorf("expected enabled=true, got false") @@ -53,9 +53,9 @@ func TestPeriodicContextSemantics(t *testing.T) { } // Verify GET returns the same config. - got, err := ts.Client.GetPeriodic(sess.SessionID) + got, err := ts.Client.GetLoop(sess.SessionID) if err != nil { - t.Fatalf("GetPeriodic failed: %v", err) + t.Fatalf("GetLoop failed: %v", err) } if got.PromptName != "daily-standup" { t.Errorf("GET: expected prompt_name=%q, got %q", "daily-standup", got.PromptName) @@ -67,31 +67,31 @@ func TestPeriodicContextSemantics(t *testing.T) { t.Errorf("GET: expected enabled=true") } - t.Logf("Case (a): periodic config confirmed via GET ✓") + t.Logf("Case (a): loop config confirmed via GET ✓") }) // ------------------------------------------------------------------------- - // Case (b): Regular → periodic: PUT periodic then run-now succeeds. + // Case (b): Regular → loop: PUT loop then run-now succeeds. // Use a raw prompt text (not prompt_name) so run-now doesn't fail trying // to resolve a named prompt that doesn't exist in the test workspace. // ------------------------------------------------------------------------- - t.Run("regular_to_periodic_run_now", func(t *testing.T) { - sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "regular-to-periodic"}) + t.Run("regular_to_loop_run_now", func(t *testing.T) { + sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "regular-to-loop"}) if err != nil { t.Fatalf("CreateSession failed: %v", err) } defer ts.Client.DeleteSession(sess.SessionID) - // PUT periodic config with raw prompt text (simulates makePeriodicNow step 1). - req := client.SetPeriodicRequest{ + // PUT loop config with raw prompt text (simulates makeLoopNow step 1). + req := client.SetLoopRequest{ Prompt: "Perform the weekly review tasks.", - Frequency: client.PeriodicFrequency{Value: 1, Unit: "hours"}, + Frequency: client.LoopFrequency{Value: 1, Unit: "hours"}, Enabled: true, MaxIterations: 3, } - cfg, err := ts.Client.SetPeriodic(sess.SessionID, req) + cfg, err := ts.Client.SetLoop(sess.SessionID, req) if err != nil { - t.Fatalf("SetPeriodic failed: %v", err) + t.Fatalf("SetLoop failed: %v", err) } if !cfg.Enabled { t.Errorf("expected enabled=true after PUT") @@ -100,16 +100,16 @@ func TestPeriodicContextSemantics(t *testing.T) { t.Errorf("expected max_iterations=3, got %d", cfg.MaxIterations) } - // POST run-now (simulates makePeriodicNow step 2). + // POST run-now (simulates makeLoopNow step 2). // The mock ACP server can receive and respond to a raw prompt. - if err := ts.Client.RunPeriodicNow(sess.SessionID, true); err != nil { - t.Fatalf("RunPeriodicNow failed: %v", err) + if err := ts.Client.RunLoopNow(sess.SessionID, true); err != nil { + t.Fatalf("RunLoopNow failed: %v", err) } - // Verify the periodic config is still set after run-now. - got, err := ts.Client.GetPeriodic(sess.SessionID) + // Verify the loop config is still set after run-now. + got, err := ts.Client.GetLoop(sess.SessionID) if err != nil { - t.Fatalf("GetPeriodic after run-now failed: %v", err) + t.Fatalf("GetLoop after run-now failed: %v", err) } if !got.Enabled { t.Errorf("expected enabled=true after run-now") @@ -118,29 +118,29 @@ func TestPeriodicContextSemantics(t *testing.T) { t.Errorf("expected max_iterations=3 after run-now, got %d", got.MaxIterations) } - t.Logf("Case (b): regular→periodic configured (max_iterations=%d) and run-now accepted ✓", got.MaxIterations) + t.Logf("Case (b): regular→loop configured (max_iterations=%d) and run-now accepted ✓", got.MaxIterations) }) // ------------------------------------------------------------------------- - // Case (c): Periodic one-shot — POST /queue with different prompt_name; - // periodic config must be UNCHANGED. + // Case (c): Loop one-shot — POST /queue with different prompt_name; + // loop config must be UNCHANGED. // ------------------------------------------------------------------------- - t.Run("periodic_one_shot_leaves_config_unchanged", func(t *testing.T) { - sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "periodic-oneshot"}) + t.Run("loop_one_shot_leaves_config_unchanged", func(t *testing.T) { + sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "loop-oneshot"}) if err != nil { t.Fatalf("CreateSession failed: %v", err) } defer ts.Client.DeleteSession(sess.SessionID) - // Set up the existing periodic config. - original := client.SetPeriodicRequest{ + // Set up the existing loop config. + original := client.SetLoopRequest{ PromptName: "nightly-build", - Frequency: client.PeriodicFrequency{Value: 24, Unit: "hours"}, + Frequency: client.LoopFrequency{Value: 24, Unit: "hours"}, Enabled: true, MaxIterations: 10, } - if _, err := ts.Client.SetPeriodic(sess.SessionID, original); err != nil { - t.Fatalf("SetPeriodic (setup) failed: %v", err) + if _, err := ts.Client.SetLoop(sess.SessionID, original); err != nil { + t.Fatalf("SetLoop (setup) failed: %v", err) } // POST /queue with a DIFFERENT prompt_name (one-shot send). @@ -148,24 +148,24 @@ func TestPeriodicContextSemantics(t *testing.T) { t.Fatalf("AddToQueueNamed failed: %v", err) } - // GET periodic config — must be unchanged. - got, err := ts.Client.GetPeriodic(sess.SessionID) + // GET loop config — must be unchanged. + got, err := ts.Client.GetLoop(sess.SessionID) if err != nil { - t.Fatalf("GetPeriodic after one-shot failed: %v", err) + t.Fatalf("GetLoop after one-shot failed: %v", err) } if got.PromptName != "nightly-build" { - t.Errorf("periodic config mutated: expected prompt_name=%q, got %q", "nightly-build", got.PromptName) + t.Errorf("loop config mutated: expected prompt_name=%q, got %q", "nightly-build", got.PromptName) } if got.Frequency.Value != 24 || got.Frequency.Unit != "hours" { - t.Errorf("periodic config mutated: frequency=%+v", got.Frequency) + t.Errorf("loop config mutated: frequency=%+v", got.Frequency) } if got.MaxIterations != 10 { - t.Errorf("periodic config mutated: expected max_iterations=10, got %d", got.MaxIterations) + t.Errorf("loop config mutated: expected max_iterations=10, got %d", got.MaxIterations) } if !got.Enabled { - t.Errorf("periodic config mutated: expected enabled=true, got false") + t.Errorf("loop config mutated: expected enabled=true, got false") } - t.Logf("Case (c): periodic config unchanged after one-shot queue POST ✓") + t.Logf("Case (c): loop config unchanged after one-shot queue POST ✓") }) } diff --git a/tests/integration/inprocess/periodic_oncompletion_e2e_test.go b/tests/integration/inprocess/loop_oncompletion_e2e_test.go similarity index 72% rename from tests/integration/inprocess/periodic_oncompletion_e2e_test.go rename to tests/integration/inprocess/loop_oncompletion_e2e_test.go index bab3c4f3..4a17dc10 100644 --- a/tests/integration/inprocess/periodic_oncompletion_e2e_test.go +++ b/tests/integration/inprocess/loop_oncompletion_e2e_test.go @@ -10,15 +10,15 @@ import ( "github.com/inercia/mitto/internal/client" ) -// TestPeriodicOnCompletionE2E verifies the on-completion periodic trigger and +// TestLoopOnCompletionE2E verifies the on-completion loop trigger and // maxDuration auto-stop end-to-end against the mock ACP server. // // Trigger flow recap: // -// - SetPeriodic (with Enabled=true, Trigger=onCompletion) automatically boots +// - SetLoop (with Enabled=true, Trigger=onCompletion) automatically boots // a fresh conversation's loop via BootstrapOnCompletion: it delivers run 1 // and, via OnConversationIdle, arms the ~5 s timer for the next auto-fire. -// An explicit RunPeriodicNow call is neither needed nor safe here — it would +// An explicit RunLoopNow call is neither needed nor safe here — it would // race the auto-bootstrapped run 1 and get a 409 "session busy" conflict. // - After each turn completes, OnConversationIdle fires the next run after // DelaySeconds (clamped to the global floor, default 5 s). @@ -26,17 +26,17 @@ import ( // - max_duration: at the next firing, if now-FirstRunAt >= MaxDurationSeconds, // the runner sets enabled=false WITHOUT delivering (so iteration_count stays at 1). // -// Note: GetPeriodic polling is the auto-stop assertion; the disable and the +// Note: GetLoop polling is the auto-stop assertion; the disable and the // WebSocket broadcast are the same server action so no WS observer is needed. -func TestPeriodicOnCompletionE2E(t *testing.T) { +func TestLoopOnCompletionE2E(t *testing.T) { ts := SetupTestServer(t) // ------------------------------------------------------------------------- // Subtest 1: max_iterations auto-stop // - // Configure MaxIterations=2. RunPeriodicNow delivers run 1 (count→1) and + // Configure MaxIterations=2. RunLoopNow delivers run 1 (count→1) and // arms a 5 s timer. After ~5 s the timer fires, delivers run 2 (count→2), - // and the runner disables the periodic (count >= cap). + // and the runner disables the loop (count >= cap). // ------------------------------------------------------------------------- t.Run("max_iterations_auto_stop", func(t *testing.T) { sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "oncomplete-maxiter"}) @@ -47,7 +47,7 @@ func TestPeriodicOnCompletionE2E(t *testing.T) { // Zero Frequency is intentional: onCompletion skips frequency validation. // DelaySeconds=0 is clamped to the server floor (5 s). - cfg, err := ts.Client.SetPeriodic(sess.SessionID, client.SetPeriodicRequest{ + cfg, err := ts.Client.SetLoop(sess.SessionID, client.SetLoopRequest{ Prompt: "ping", Trigger: "onCompletion", DelaySeconds: 0, @@ -55,29 +55,29 @@ func TestPeriodicOnCompletionE2E(t *testing.T) { Enabled: true, }) if err != nil { - t.Fatalf("SetPeriodic failed: %v", err) + t.Fatalf("SetLoop failed: %v", err) } if cfg.Trigger != "onCompletion" { t.Fatalf("expected trigger=onCompletion, got %q", cfg.Trigger) } if !cfg.Enabled { - t.Fatalf("expected enabled=true after SetPeriodic, got false") + t.Fatalf("expected enabled=true after SetLoop, got false") } - // SetPeriodic above already booted the loop (BootstrapOnCompletion): it + // SetLoop above already booted the loop (BootstrapOnCompletion): it // delivered run 1, set FirstRunAt, incremented iteration_count to 1, and // armed the on-completion timer (~5 s) via OnConversationIdle. Calling - // RunPeriodicNow here would race that auto-delivered run 1 and fail with + // RunLoopNow here would race that auto-delivered run 1 and fail with // a 409 "session busy" conflict. - // Poll until the runner disables the periodic after reaching MaxIterations=2. + // Poll until the runner disables the loop after reaching MaxIterations=2. deadline := time.Now().Add(30 * time.Second) - var last *client.PeriodicConfig + var last *client.LoopConfig for time.Now().Before(deadline) { time.Sleep(250 * time.Millisecond) - got, err := ts.Client.GetPeriodic(sess.SessionID) + got, err := ts.Client.GetLoop(sess.SessionID) if err != nil { - t.Logf("GetPeriodic transient error: %v", err) + t.Logf("GetLoop transient error: %v", err) continue } last = got @@ -92,7 +92,7 @@ func TestPeriodicOnCompletionE2E(t *testing.T) { if last != nil { enabled, count = last.Enabled, last.IterationCount } - t.Fatalf("periodic not auto-stopped within 30 s: enabled=%v iteration_count=%d", enabled, count) + t.Fatalf("loop not auto-stopped within 30 s: enabled=%v iteration_count=%d", enabled, count) } if last.IterationCount != 2 { t.Errorf("expected iteration_count=2 at auto-stop, got %d", last.IterationCount) @@ -103,7 +103,7 @@ func TestPeriodicOnCompletionE2E(t *testing.T) { // ------------------------------------------------------------------------- // Subtest 2: max_duration auto-stop // - // Configure MaxDurationSeconds=4. RunPeriodicNow delivers run 1 (count→1, + // Configure MaxDurationSeconds=4. RunLoopNow delivers run 1 (count→1, // FirstRunAt=T0) and arms a ~5 s timer. At T0+5 s the timer fires; elapsed // (≈5 s) >= MaxDurationSeconds (4 s) so the runner disables WITHOUT // delivering run 2 — iteration_count remains 1. @@ -115,7 +115,7 @@ func TestPeriodicOnCompletionE2E(t *testing.T) { } defer ts.Client.DeleteSession(sess.SessionID) - cfg, err := ts.Client.SetPeriodic(sess.SessionID, client.SetPeriodicRequest{ + cfg, err := ts.Client.SetLoop(sess.SessionID, client.SetLoopRequest{ Prompt: "ping", Trigger: "onCompletion", DelaySeconds: 0, // clamped to 5 s floor @@ -124,28 +124,28 @@ func TestPeriodicOnCompletionE2E(t *testing.T) { Enabled: true, }) if err != nil { - t.Fatalf("SetPeriodic failed: %v", err) + t.Fatalf("SetLoop failed: %v", err) } if cfg.Trigger != "onCompletion" { t.Fatalf("expected trigger=onCompletion, got %q", cfg.Trigger) } if !cfg.Enabled { - t.Fatalf("expected enabled=true after SetPeriodic, got false") + t.Fatalf("expected enabled=true after SetLoop, got false") } - // SetPeriodic above already booted the loop (BootstrapOnCompletion): run 1 + // SetLoop above already booted the loop (BootstrapOnCompletion): run 1 // delivered, FirstRunAt=now, count→1, timer armed (~5 s). An explicit - // RunPeriodicNow call here would race that auto-delivered run and fail + // RunLoopNow call here would race that auto-delivered run and fail // with a 409 "session busy" conflict. // Poll until the runner disables (max duration reached at the next firing). deadline := time.Now().Add(30 * time.Second) - var last *client.PeriodicConfig + var last *client.LoopConfig for time.Now().Before(deadline) { time.Sleep(250 * time.Millisecond) - got, err := ts.Client.GetPeriodic(sess.SessionID) + got, err := ts.Client.GetLoop(sess.SessionID) if err != nil { - t.Logf("GetPeriodic transient error: %v", err) + t.Logf("GetLoop transient error: %v", err) continue } last = got @@ -160,7 +160,7 @@ func TestPeriodicOnCompletionE2E(t *testing.T) { if last != nil { enabled, count = last.Enabled, last.IterationCount } - t.Fatalf("periodic not auto-stopped within 30 s (max_duration): enabled=%v iteration_count=%d", enabled, count) + t.Fatalf("loop not auto-stopped within 30 s (max_duration): enabled=%v iteration_count=%d", enabled, count) } // The second run must NOT have been delivered; the stop preceded delivery. if last.IterationCount != 1 { diff --git a/tests/integration/inprocess/periodic_ontasks_e2e_test.go b/tests/integration/inprocess/loop_ontasks_e2e_test.go similarity index 90% rename from tests/integration/inprocess/periodic_ontasks_e2e_test.go rename to tests/integration/inprocess/loop_ontasks_e2e_test.go index 5cec70b5..7f93445a 100644 --- a/tests/integration/inprocess/periodic_ontasks_e2e_test.go +++ b/tests/integration/inprocess/loop_ontasks_e2e_test.go @@ -68,6 +68,12 @@ func (c *fakeOnTasksBeadsClient) Update(context.Context, string, beads.UpdatePar } func (c *fakeOnTasksBeadsClient) Comment(context.Context, string, string, string) error { return nil } func (c *fakeOnTasksBeadsClient) Dep(context.Context, string, beads.DepParams) error { return nil } +func (c *fakeOnTasksBeadsClient) Label(context.Context, string, beads.LabelParams) error { + return nil +} +func (c *fakeOnTasksBeadsClient) ListAllLabels(context.Context, string) ([]byte, error) { + return []byte(`[]`), nil +} func (c *fakeOnTasksBeadsClient) ConfigShow(context.Context, string) (map[string]string, error) { return nil, nil } @@ -121,10 +127,10 @@ func onTasksIssuesJSONEqual(t *testing.T, a, b []byte) bool { } // createOnTasksSession creates a session rooted at workingDir (created if -// missing) with an enabled onTasks periodic prompt gated by condition. -// Additional SetPeriodicRequest fields (MaxIterations, CooldownSeconds, ...) +// missing) with an enabled onTasks loop prompt gated by condition. +// Additional SetLoopRequest fields (MaxIterations, CooldownSeconds, ...) // can be set via opts. -func createOnTasksSession(t *testing.T, ts *TestServer, workingDir, name, condition string, opts ...func(*client.SetPeriodicRequest)) *client.SessionInfo { +func createOnTasksSession(t *testing.T, ts *TestServer, workingDir, name, condition string, opts ...func(*client.SetLoopRequest)) *client.SessionInfo { t.Helper() if err := os.MkdirAll(workingDir, 0755); err != nil { t.Fatalf("MkdirAll(%s) error = %v", workingDir, err) @@ -133,35 +139,35 @@ func createOnTasksSession(t *testing.T, ts *TestServer, workingDir, name, condit if err != nil { t.Fatalf("CreateSession failed: %v", err) } - req := client.SetPeriodicRequest{Prompt: "iterate", Trigger: "onTasks", Condition: condition, Enabled: true} + req := client.SetLoopRequest{Prompt: "iterate", Trigger: "onTasks", Condition: condition, Enabled: true} for _, opt := range opts { opt(&req) } - cfg, err := ts.Client.SetPeriodic(sess.SessionID, req) + cfg, err := ts.Client.SetLoop(sess.SessionID, req) if err != nil { - t.Fatalf("SetPeriodic failed: %v", err) + t.Fatalf("SetLoop failed: %v", err) } if cfg.Trigger != "onTasks" { t.Fatalf("expected trigger=onTasks, got %q", cfg.Trigger) } if !cfg.Enabled { - t.Fatalf("expected enabled=true after SetPeriodic, got false") + t.Fatalf("expected enabled=true after SetLoop, got false") } return sess } -func getOnTasksPeriodic(t *testing.T, ts *TestServer, sessionID string) *client.PeriodicConfig { +func getOnTasksLoop(t *testing.T, ts *TestServer, sessionID string) *client.LoopConfig { t.Helper() - got, err := ts.Client.GetPeriodic(sessionID) + got, err := ts.Client.GetLoop(sessionID) if err != nil { - t.Fatalf("GetPeriodic(%s) error = %v", sessionID, err) + t.Fatalf("GetLoop(%s) error = %v", sessionID, err) } return got } func assertOnTasksIterationCount(t *testing.T, ts *TestServer, sessionID string, want int) { t.Helper() - if got := getOnTasksPeriodic(t, ts, sessionID).IterationCount; got != want { + if got := getOnTasksLoop(t, ts, sessionID).IterationCount; got != want { t.Fatalf("iteration_count = %d, want %d", got, want) } } @@ -169,7 +175,7 @@ func assertOnTasksIterationCount(t *testing.T, ts *TestServer, sessionID string, func waitOnTasksIterationCount(t *testing.T, ts *TestServer, sessionID string, want int) { t.Helper() waitFor(t, 10*time.Second, func() bool { - got, err := ts.Client.GetPeriodic(sessionID) + got, err := ts.Client.GetLoop(sessionID) return err == nil && got.IterationCount == want }, fmt.Sprintf("iteration_count to reach %d for session %s", want, sessionID)) } @@ -182,25 +188,25 @@ func waitOnTasksSessionIdle(t *testing.T, ts *TestServer, sessionID string) { }, "session "+sessionID+" to go idle") } -// TestPeriodicOnTasksE2E verifies the onTasks periodic trigger end-to-end +// TestLoopOnTasksE2E verifies the onTasks loop trigger end-to-end // against the mock ACP server: CEL-gated firing, the 4-layer loop-prevention // system (busy guard, quiescence rebase, cooldown floor, no-progress circuit // breaker), and MaxIterations/MaxDuration auto-stop. // // The `.beads/` filesystem watcher itself is out of scope here (unit-tested // separately in internal/config); this test drives the same entry point the -// watcher uses — PeriodicRunner.OnBeadsChanged — directly, with a fake +// watcher uses — LoopRunner.OnBeadsChanged — directly, with a fake // beads.Client standing in for `bd list`. -func TestPeriodicOnTasksE2E(t *testing.T) { +func TestLoopOnTasksE2E(t *testing.T) { ts := SetupTestServer(t) - runner := ts.Server.PeriodicRunner() + runner := ts.Server.LoopRunner() fake := newFakeOnTasksBeadsClient() runner.SetBeadsClient(fake) // Keep the global cooldown floor at 0 so per-session CooldownSeconds (or its // absence) fully controls timing in each subtest; use a short quiescence // window so the busy-guard/rebase subtest doesn't need to wait 30s. - runner.SetMinPeriodicTasksCooldownSeconds(0) + runner.SetMinLoopTasksCooldownSeconds(0) runner.SetTasksQuiescenceWindow(400 * time.Millisecond) // ------------------------------------------------------------------------- @@ -354,7 +360,7 @@ func TestPeriodicOnTasksE2E(t *testing.T) { t.Run("cooldown_floor_blocks_rapid_refire", func(t *testing.T) { dir := filepath.Join(ts.TempDir, "workspace", "ontasks-cooldown") sess := createOnTasksSession(t, ts, dir, "ontasks-cooldown", "", - func(r *client.SetPeriodicRequest) { r.CooldownSeconds = 2 }) + func(r *client.SetLoopRequest) { r.CooldownSeconds = 2 }) defer ts.Client.DeleteSession(sess.SessionID) fake.setRaw(dir, marshalOnTasksIssues(t)) @@ -405,7 +411,7 @@ func TestPeriodicOnTasksE2E(t *testing.T) { // Fires 2 and 3: the SAME issue touched again (only updated_at changes) — // no genuine new progress. tasksNoProgressLimit (see - // internal/web/periodic_runner_tasks.go) is 3, so these bring the + // internal/web/loop_runner_tasks.go) is 3, so these bring the // consecutive no-progress count to 1 and 2; the breaker must not trip yet. for i, at := range []string{"2026-07-01T00:01:00Z", "2026-07-01T00:02:00Z"} { fake.setRaw(dir, marshalOnTasksIssues(t, @@ -414,8 +420,8 @@ func TestPeriodicOnTasksE2E(t *testing.T) { waitOnTasksIterationCount(t, ts, sess.SessionID, 2+i) waitOnTasksSessionIdle(t, ts, sess.SessionID) - if !getOnTasksPeriodic(t, ts, sess.SessionID).Enabled { - t.Fatalf("periodic should still be enabled before the no-progress limit is reached (fire %d)", i+2) + if !getOnTasksLoop(t, ts, sess.SessionID).Enabled { + t.Fatalf("loop should still be enabled before the no-progress limit is reached (fire %d)", i+2) } } @@ -425,10 +431,10 @@ func TestPeriodicOnTasksE2E(t *testing.T) { runner.OnBeadsChanged(onTasksChangeEvent(dir)) waitFor(t, 10*time.Second, func() bool { - return !getOnTasksPeriodic(t, ts, sess.SessionID).Enabled + return !getOnTasksLoop(t, ts, sess.SessionID).Enabled }, "onTasks circuit breaker to auto-pause after repeated no-progress fires") - if got := getOnTasksPeriodic(t, ts, sess.SessionID).StoppedReason; got != "noProgress" { + if got := getOnTasksLoop(t, ts, sess.SessionID).StoppedReason; got != "noProgress" { t.Errorf("StoppedReason = %q, want %q", got, "noProgress") } }) @@ -440,7 +446,7 @@ func TestPeriodicOnTasksE2E(t *testing.T) { t.Run("max_iterations_auto_stop", func(t *testing.T) { dir := filepath.Join(ts.TempDir, "workspace", "ontasks-maxiter") sess := createOnTasksSession(t, ts, dir, "ontasks-maxiter", "", - func(r *client.SetPeriodicRequest) { r.MaxIterations = 1 }) + func(r *client.SetLoopRequest) { r.MaxIterations = 1 }) defer ts.Client.DeleteSession(sess.SessionID) fake.setRaw(dir, marshalOnTasksIssues(t)) @@ -452,10 +458,10 @@ func TestPeriodicOnTasksE2E(t *testing.T) { runner.OnBeadsChanged(onTasksChangeEvent(dir)) waitFor(t, 10*time.Second, func() bool { - return !getOnTasksPeriodic(t, ts, sess.SessionID).Enabled - }, "onTasks periodic to auto-stop after max_iterations") + return !getOnTasksLoop(t, ts, sess.SessionID).Enabled + }, "onTasks loop to auto-stop after max_iterations") - got := getOnTasksPeriodic(t, ts, sess.SessionID) + got := getOnTasksLoop(t, ts, sess.SessionID) if got.IterationCount != 1 { t.Errorf("iteration_count = %d, want 1", got.IterationCount) } @@ -471,7 +477,7 @@ func TestPeriodicOnTasksE2E(t *testing.T) { t.Run("max_duration_auto_stop", func(t *testing.T) { dir := filepath.Join(ts.TempDir, "workspace", "ontasks-maxdur") sess := createOnTasksSession(t, ts, dir, "ontasks-maxdur", "", - func(r *client.SetPeriodicRequest) { r.MaxDurationSeconds = 1 }) + func(r *client.SetLoopRequest) { r.MaxDurationSeconds = 1 }) defer ts.Client.DeleteSession(sess.SessionID) fake.setRaw(dir, marshalOnTasksIssues(t)) @@ -493,10 +499,10 @@ func TestPeriodicOnTasksE2E(t *testing.T) { runner.OnBeadsChanged(onTasksChangeEvent(dir)) waitFor(t, 10*time.Second, func() bool { - return !getOnTasksPeriodic(t, ts, sess.SessionID).Enabled - }, "onTasks periodic to auto-stop after max_duration") + return !getOnTasksLoop(t, ts, sess.SessionID).Enabled + }, "onTasks loop to auto-stop after max_duration") - got := getOnTasksPeriodic(t, ts, sess.SessionID) + got := getOnTasksLoop(t, ts, sess.SessionID) if got.IterationCount != 1 { t.Errorf("iteration_count = %d, want 1 (no second delivery)", got.IterationCount) } diff --git a/tests/integration/inprocess/prompt_name_sync_test.go b/tests/integration/inprocess/prompt_name_sync_test.go index af7baa3c..9dd9d628 100644 --- a/tests/integration/inprocess/prompt_name_sync_test.go +++ b/tests/integration/inprocess/prompt_name_sync_test.go @@ -24,7 +24,7 @@ func extractPromptName(e client.SyncEvent) string { } // TestGapFill_UserPromptWithPromptName verifies that when a user_prompt event -// with prompt_name (simulating a periodic prompt) is missed and later recovered +// with prompt_name (simulating a loop prompt) is missed and later recovered // via load_events, the prompt_name field survives the store round-trip. func TestGapFill_UserPromptWithPromptName(t *testing.T) { ts := SetupTestServer(t) @@ -91,7 +91,7 @@ func TestGapFill_UserPromptWithPromptName(t *testing.T) { clientMaxSeq := initialLastSeq - // Phase 2: Inject missed events — a periodic prompt + agent response. + // Phase 2: Inject missed events — a loop prompt + agent response. promptSeq := inj.InjectUserPromptWithName("Run daily health check", "daily-check") inj.InjectAgentMessages(1) // agent response @@ -142,11 +142,11 @@ func TestGapFill_UserPromptWithPromptName(t *testing.T) { gotName, recovered[0].Seq) } -// TestGapFill_MultiplePeriodicPromptsWithSameText verifies that multiple periodic +// TestGapFill_MultipleLoopPromptsWithSameText verifies that multiple loop // prompts with identical message text but different seqs are all delivered by the // server (no server-side content dedup). This is the backend counterpart of the // mergeMessagesWithSync fix in lib.js. -func TestGapFill_MultiplePeriodicPromptsWithSameText(t *testing.T) { +func TestGapFill_MultipleLoopPromptsWithSameText(t *testing.T) { ts := SetupTestServer(t) sess, err := ts.Client.CreateSession(client.CreateSessionRequest{}) @@ -182,7 +182,7 @@ func TestGapFill_MultiplePeriodicPromptsWithSameText(t *testing.T) { } defer ws.Close() - // Inject 3 periodic prompts with identical text (simulates 3 scheduled runs). + // Inject 3 loop prompts with identical text (simulates 3 scheduled runs). seq1 := inj.InjectUserPromptWithName("Run scheduled task", "cron-job") seq2 := inj.InjectUserPromptWithName("Run scheduled task", "cron-job") seq3 := inj.InjectUserPromptWithName("Run scheduled task", "cron-job") @@ -210,7 +210,7 @@ func TestGapFill_MultiplePeriodicPromptsWithSameText(t *testing.T) { } } - // ASSERT 1: All 3 periodic prompts are returned (no server-side content dedup). + // ASSERT 1: All 3 loop prompts are returned (no server-side content dedup). if len(prompts) != 3 { t.Fatalf("expected 3 user_prompt events, got %d (total events: %d)", len(prompts), len(events)) } @@ -235,7 +235,7 @@ func TestGapFill_MultiplePeriodicPromptsWithSameText(t *testing.T) { } } - t.Logf("All 3 periodic prompts with identical text delivered with distinct seqs ✓") + t.Logf("All 3 loop prompts with identical text delivered with distinct seqs ✓") } // TestGapFill_PromptNameSurvivesFullReload verifies the simplest path: @@ -321,7 +321,7 @@ func TestGapFill_PromptNameSurvivesFullReload(t *testing.T) { } // TestGapFill_PromptNameEmptyForAdHocPrompts verifies that ad-hoc prompts -// (without prompt_name) are distinguishable from periodic prompts in the +// (without prompt_name) are distinguishable from loop prompts in the // same event stream. func TestGapFill_PromptNameEmptyForAdHocPrompts(t *testing.T) { ts := SetupTestServer(t) @@ -334,9 +334,9 @@ func TestGapFill_PromptNameEmptyForAdHocPrompts(t *testing.T) { inj := NewEventInjector(t, ts, sess.SessionID) - // Inject an ad-hoc prompt (no prompt_name) and a periodic prompt. + // Inject an ad-hoc prompt (no prompt_name) and a loop prompt. inj.InjectUserPrompts(1) // seq 1: ad-hoc - inj.InjectUserPromptWithName("Weekly status report", "weekly-report") // seq 2: periodic + inj.InjectUserPromptWithName("Weekly status report", "weekly-report") // seq 2: loop ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -395,12 +395,12 @@ func TestGapFill_PromptNameEmptyForAdHocPrompts(t *testing.T) { t.Errorf("ad-hoc prompt[0]: expected empty prompt_name, got %q", adHocName) } - // ASSERT: Second prompt (periodic) has prompt_name. - periodicName := extractPromptName(prompts[1]) - if periodicName != "weekly-report" { - t.Errorf("periodic prompt[1]: expected prompt_name=%q, got %q", "weekly-report", periodicName) + // ASSERT: Second prompt (loop) has prompt_name. + loopName := extractPromptName(prompts[1]) + if loopName != "weekly-report" { + t.Errorf("loop prompt[1]: expected prompt_name=%q, got %q", "weekly-report", loopName) } - t.Logf("Ad-hoc prompt_name=%q, periodic prompt_name=%q — correctly distinguished ✓", - adHocName, periodicName) + t.Logf("Ad-hoc prompt_name=%q, loop prompt_name=%q — correctly distinguished ✓", + adHocName, loopName) } diff --git a/tests/integration/inprocess/prompt_test.go b/tests/integration/inprocess/prompt_test.go index 29277b22..25f806d4 100644 --- a/tests/integration/inprocess/prompt_test.go +++ b/tests/integration/inprocess/prompt_test.go @@ -384,7 +384,7 @@ func TestTemplateRender_CoexistWithMitto(t *testing.T) { // instead of aborting the send. This is intentional (see the isAutomatedDispatch // comment in prompt_dispatcher.go): pasted text containing "{{" must still be // delivered literally for direct human input. Named prompts and automated -// dispatches (queue, periodic-runner) still fail closed. +// dispatches (queue, loop-runner) still fail closed. func TestTemplateRender_FailOpen_RawMessage(t *testing.T) { ts, orderFile := setupDeferredConfigServer(t) @@ -456,18 +456,18 @@ func truncate(s string, maxLen int) string { return s[:maxLen] } -// TestTemplateRender_PeriodicRun verifies that Go template rendering works correctly -// on the periodic-run dispatch path: .Session.IsPeriodic == true and -// .Session.IsPeriodicForced == true when triggered via RunPeriodicNow (manual "run now"). -func TestTemplateRender_PeriodicRun(t *testing.T) { +// TestTemplateRender_LoopRun verifies that Go template rendering works correctly +// on the loop-run dispatch path: .Session.IsLoop == true and +// .Session.IsLoopForced == true when triggered via RunLoopNow (manual "run now"). +func TestTemplateRender_LoopRun(t *testing.T) { ts, orderFile := setupDeferredConfigServer(t) - // Write a named prompt whose body uses the periodic context fields. - writeTemplatePrompt(t, ts, "tmpl-periodic", "tmpl-periodic", - `PeriodicMarker: {{ if .Session.IsPeriodic }}PERIODIC{{ else }}ONESHOT{{ end }}{{ if .Session.IsPeriodicForced }}-FORCED{{ end }}`) + // Write a named prompt whose body uses the loop context fields. + writeTemplatePrompt(t, ts, "tmpl-loop", "tmpl-loop", + `LoopMarker: {{ if .Session.IsLoop }}LOOP{{ else }}ONESHOT{{ end }}{{ if .Session.IsLoopForced }}-FORCED{{ end }}`) - // Create session without an initial prompt (avoids a concurrent-prompt 409 during SetPeriodic). - sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "periodic-template-test"}) + // Create session without an initial prompt (avoids a concurrent-prompt 409 during SetLoop). + sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "loop-template-test"}) if err != nil { t.Fatalf("CreateSession: %v", err) } @@ -491,50 +491,50 @@ func TestTemplateRender_PeriodicRun(t *testing.T) { t.Fatalf("LoadEvents: %v", err) } - // Configure periodic with the named template prompt. - cfg, err := ts.Client.SetPeriodic(sess.SessionID, client.SetPeriodicRequest{ - PromptName: "tmpl-periodic", - Frequency: client.PeriodicFrequency{Value: 1, Unit: "hours"}, + // Configure loop with the named template prompt. + cfg, err := ts.Client.SetLoop(sess.SessionID, client.SetLoopRequest{ + PromptName: "tmpl-loop", + Frequency: client.LoopFrequency{Value: 1, Unit: "hours"}, Enabled: true, }) if err != nil { - t.Fatalf("SetPeriodic: %v", err) + t.Fatalf("SetLoop: %v", err) } if !cfg.Enabled { - t.Fatalf("expected enabled=true after SetPeriodic, got false") + t.Fatalf("expected enabled=true after SetLoop, got false") } - // Trigger run 1 via RunPeriodicNow (the manual "run now" path; forced=true → IsPeriodicForced=true). - if err := ts.Client.RunPeriodicNow(sess.SessionID, true); err != nil { - t.Fatalf("RunPeriodicNow: %v", err) + // Trigger run 1 via RunLoopNow (the manual "run now" path; forced=true → IsLoopForced=true). + if err := ts.Client.RunLoopNow(sess.SessionID, true); err != nil { + t.Fatalf("RunLoopNow: %v", err) } - // Wait for the periodic prompt to complete. + // Wait for the loop prompt to complete. waitFor(t, 25*time.Second, func() bool { mu.Lock() defer mu.Unlock() return completes >= 1 - }, "periodic prompt complete") + }, "loop prompt complete") // Inspect the rendered text the mock agent received. lines := readRPCOrder(t, orderFile) - got := promptLineFor(lines, "PeriodicMarker:") + got := promptLineFor(lines, "LoopMarker:") if got == "" { - t.Fatalf("PeriodicMarker: line not found in RPC order; all lines: %v", lines) + t.Fatalf("LoopMarker: line not found in RPC order; all lines: %v", lines) } t.Logf("captured line: %q", got) - // Core acceptance: template must see IsPeriodic == true. - if !strings.Contains(got, "PERIODIC") { - t.Errorf("expected PERIODIC in rendered line, got %q", got) + // Core acceptance: template must see IsLoop == true. + if !strings.Contains(got, "LOOP") { + t.Errorf("expected LOOP in rendered line, got %q", got) } if strings.Contains(got, "ONESHOT") { - t.Errorf("ONESHOT rendered — IsPeriodic was false; got %q", got) + t.Errorf("ONESHOT rendered — IsLoop was false; got %q", got) } - // RunPeriodicNow sets IsPeriodicForced=true (periodic_runner.go:TriggerNow forced=true). + // RunLoopNow sets IsLoopForced=true (loop_runner.go:TriggerNow forced=true). if !strings.Contains(got, "-FORCED") { - t.Errorf("expected -FORCED in rendered line (RunPeriodicNow sets IsPeriodicForced=true); got %q", got) + t.Errorf("expected -FORCED in rendered line (RunLoopNow sets IsLoopForced=true); got %q", got) } } diff --git a/tests/ui/specs/beads.spec.ts b/tests/ui/specs/beads.spec.ts index d9c52d05..3ba96d99 100644 --- a/tests/ui/specs/beads.spec.ts +++ b/tests/ui/specs/beads.spec.ts @@ -315,8 +315,10 @@ testWithCleanup.describe("Beads view - detail panel", () => { .click(); await expect(panel).toBeVisible({ timeout: timeouts.shortAction }); - // Click the title heading to enter inline edit mode. - await panel.locator('h2:has-text("Short issue")').click(); + // Click the (editable) body title heading to enter inline edit mode. A + // second, read-only truncated title now lives in the header, so target the + // editable one via its cursor-text class. + await panel.locator('h2.cursor-text:has-text("Short issue")').click(); const titleInput = panel.locator('input.font-semibold'); await expect(titleInput).toBeVisible({ timeout: timeouts.shortAction }); await expect(titleInput).toHaveValue("Short issue"); @@ -358,9 +360,8 @@ testWithCleanup.describe("Beads view - detail panel", () => { .click(); await expect(panel).toBeVisible({ timeout: timeouts.shortAction }); - // Delete moved to the kebab menu (now a ContextMenu portaled to body). - await panel.locator('button[data-tip="More actions"]').click(); - await page.locator("ul.menu.fixed").getByRole("button", { name: "Delete", exact: true }).click(); + // Delete is now a direct button in the header Toolbar. + await panel.locator('[data-testid="beads-panel-delete"]').click(); const dialog = page.locator('[data-testid="confirm-dialog"]'); await expect(dialog).toBeVisible({ timeout: timeouts.shortAction }); @@ -422,7 +423,7 @@ testWithCleanup.describe("Beads view - detail panel", () => { .click(); await expect(panel).toBeVisible({ timeout: timeouts.shortAction }); - await panel.locator('h2:has-text("Short issue")').click(); + await panel.locator('h2.cursor-text:has-text("Short issue")').click(); const titleInput = panel.locator('input.font-semibold'); await expect(titleInput).toBeVisible({ timeout: timeouts.shortAction }); @@ -431,7 +432,7 @@ testWithCleanup.describe("Beads view - detail panel", () => { await titleInput.press("Escape"); await expect( - panel.locator('h2:has-text("Short issue")'), + panel.locator('h2.cursor-text:has-text("Short issue")'), ).toBeVisible({ timeout: timeouts.shortAction }); expect(updateCalled).toBe(false); }, @@ -657,9 +658,8 @@ testWithCleanup.describe("Beads view - epic deletion", () => { await expect(panel).toBeVisible({ timeout: timeouts.shortAction }); await expect(panel.getByText("mitto-epic")).toBeVisible(); - // Delete moved to the kebab menu (now a ContextMenu portaled to body). - await panel.locator('button[data-tip="More actions"]').click(); - await page.locator("ul.menu.fixed").getByRole("button", { name: "Delete", exact: true }).click(); + // Delete is now a direct button in the header Toolbar. + await panel.locator('[data-testid="beads-panel-delete"]').click(); const dialog = page.locator('[data-testid="confirm-dialog"]'); await expect(dialog).toBeVisible({ timeout: timeouts.shortAction }); return dialog; diff --git a/tests/ui/specs/chat.spec.ts b/tests/ui/specs/chat.spec.ts index f4574e1d..493eaeb4 100644 --- a/tests/ui/specs/chat.spec.ts +++ b/tests/ui/specs/chat.spec.ts @@ -462,7 +462,7 @@ test.describe("Message List Rendering", () => { await page.addInitScript(() => { localStorage.removeItem("mitto_last_session_id"); localStorage.removeItem("mitto_last_session_id_conversations"); - localStorage.removeItem("mitto_last_session_id_periodic"); + localStorage.removeItem("mitto_last_session_id_loop"); localStorage.removeItem("mitto_last_session_id_archived"); localStorage.removeItem("mitto_conversation_filter_tab"); }); diff --git a/tests/ui/specs/copy-markdown.spec.ts b/tests/ui/specs/copy-markdown.spec.ts index 523844b4..67b72d21 100644 --- a/tests/ui/specs/copy-markdown.spec.ts +++ b/tests/ui/specs/copy-markdown.spec.ts @@ -137,17 +137,13 @@ test.describe("Copy as Markdown", () => { const msg = helpers.uniqueMessage("Convo copy"); await helpers.sendMessageAndWait(page, msg); - await page.locator(selectors.headerConversationMenu).click(); - const menu = page.locator(selectors.contextMenu).first(); - await expect(menu).toBeVisible({ timeout: timeouts.shortAction }); - - const copyItem = page - .locator(`${selectors.contextMenu} button`) - .filter({ hasText: "Copy as Markdown" }); + // "Copy as Markdown" is now a dedicated button in the conversation toolbar + // pill (previously it lived inside the "…" conversation-actions menu). + const copyItem = page.locator(selectors.headerCopyMarkdown); await expect(copyItem).toBeVisible({ timeout: timeouts.shortAction }); await copyItem.click(); - // Success toast and the menu closes. + // Success toast appears and no context menu is left open. await expect(page.getByText("Conversation copied as Markdown")).toBeVisible({ timeout: timeouts.appReady, }); diff --git a/tests/ui/specs/hierarchical-sessions.spec.ts b/tests/ui/specs/hierarchical-sessions.spec.ts index a9431679..95c8f8cb 100644 --- a/tests/ui/specs/hierarchical-sessions.spec.ts +++ b/tests/ui/specs/hierarchical-sessions.spec.ts @@ -472,7 +472,7 @@ test.describe("Hierarchical Session Grouping", () => { await filterBtn.click(); // All four category checkboxes must be visible and checked by default - for (const key of ["regular", "periodic", "archived", "tasks"]) { + for (const key of ["regular", "loop", "archived", "tasks"]) { const cb = page.locator(`[data-testid="category-filter-${key}"]`); await expect(cb).toBeVisible({ timeout: 3000 }); await expect(cb).toBeChecked(); diff --git a/tests/ui/specs/periodic-edit-args.spec.ts b/tests/ui/specs/loop-edit-args.spec.ts similarity index 76% rename from tests/ui/specs/periodic-edit-args.spec.ts rename to tests/ui/specs/loop-edit-args.spec.ts index c88bb299..d421e6dd 100644 --- a/tests/ui/specs/periodic-edit-args.spec.ts +++ b/tests/ui/specs/loop-edit-args.spec.ts @@ -2,28 +2,28 @@ import { testWithCleanup as test, expect } from "../fixtures/test-fixtures"; import { timeouts, apiUrl, selectors } from "../utils/selectors"; /** - * Periodic "edit arguments" button tests (mitto-2eu). + * Loop "edit arguments" button tests (mitto-2eu). * - * Covers the SlidersIcon button rendered next to the PeriodicPromptSelector: - * - Enabled when the selected periodic prompt declares parameters; clicking + * Covers the SlidersIcon button rendered next to the LoopPromptSelector: + * - Enabled when the selected loop prompt declares parameters; clicking * opens the shared PromptParameterDialog. - * - Submitting the dialog PATCHes /api/sessions/:id/periodic with { arguments }. + * - Submitting the dialog PATCHes /api/sessions/:id/loop with { arguments }. * - Reopening the dialog pre-seeds the previously-saved value (initialValues). * - Disabled when the selected prompt declares no parameters. * * Fixtures (project-alpha workspace): - * periodic-param-prompt.prompt.yaml ("Periodic Param Test", menus: promptsPeriodic, TASK: text optional) + * loop-param-prompt.prompt.yaml ("Loop Param Test", menus: promptsLoop, TASK: text optional) * greeting.prompt.yaml ("Hello Greeting", no parameters) * - * Note: the periodic selector only lists a prompt when menuSatisfies() holds for - * the promptsPeriodic menu, which auto-supplies no parameter types. A prompt with + * Note: the loop selector only lists a prompt when menuSatisfies() holds for + * the promptsLoop menu, which auto-supplies no parameter types. A prompt with * a REQUIRED text param would therefore be hidden from the selector entirely, so * the editable-args case necessarily uses an optional (or boolean) parameter. */ -const PARAM_PROMPT = "Periodic Param Test"; +const PARAM_PROMPT = "Loop Param Test"; const NO_PARAM_PROMPT = "Hello Greeting"; -const EDIT_ARGS_BTN = '[data-testid="periodic-edit-args-button"]'; +const EDIT_ARGS_BTN = '[data-testid="loop-edit-args-button"]'; const DIALOG = '[data-testid="prompt-param-dialog"]'; async function apiCreateSession( @@ -46,18 +46,18 @@ async function apiCreateSession( return id; } -async function enablePeriodic( +async function enableLoop( request: import("@playwright/test").APIRequestContext, sessionId: string, promptName: string, ): Promise<void> { - const resp = await request.put(apiUrl(`/api/sessions/${sessionId}/periodic`), { + const resp = await request.put(apiUrl(`/api/sessions/${sessionId}/loop`), { data: { prompt_name: promptName, frequency: { value: 1, unit: "hours" }, enabled: true }, }); - expect(resp.ok(), `PUT periodic failed: ${resp.status()} ${await resp.text()}`).toBe(true); + expect(resp.ok(), `PUT loop failed: ${resp.status()} ${await resp.text()}`).toBe(true); } -test.describe("Periodic edit-arguments button", () => { +test.describe("Loop edit-arguments button", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); await page.waitForLoadState("networkidle"); @@ -69,9 +69,9 @@ test.describe("Periodic edit-arguments button", () => { timeouts: t, }) => { const sessionId = await apiCreateSession(page, request); - await enablePeriodic(request, sessionId, PARAM_PROMPT); + await enableLoop(request, sessionId, PARAM_PROMPT); - await expect(page.locator('[data-testid="periodic-frequency-panel"]')).toBeVisible({ + await expect(page.locator('[data-testid="loop-frequency-panel"]')).toBeVisible({ timeout: t.agentResponse, }); @@ -91,11 +91,11 @@ test.describe("Periodic edit-arguments button", () => { await expect(taskField).toHaveValue(""); await taskField.fill("nightly cleanup"); - // Submitting PATCHes the periodic config with the arguments map + // Submitting PATCHes the loop config with the arguments map const [patchReq] = await Promise.all([ page.waitForRequest( (req) => - req.url().includes(`/api/sessions/${sessionId}/periodic`) && + req.url().includes(`/api/sessions/${sessionId}/loop`) && req.method() === "PATCH", { timeout: t.appReady }, ), @@ -119,9 +119,9 @@ test.describe("Periodic edit-arguments button", () => { timeouts: t, }) => { const sessionId = await apiCreateSession(page, request); - await enablePeriodic(request, sessionId, NO_PARAM_PROMPT); + await enableLoop(request, sessionId, NO_PARAM_PROMPT); - await expect(page.locator('[data-testid="periodic-frequency-panel"]')).toBeVisible({ + await expect(page.locator('[data-testid="loop-frequency-panel"]')).toBeVisible({ timeout: t.agentResponse, }); diff --git a/tests/ui/specs/periodic-oncompletion.spec.ts b/tests/ui/specs/loop-oncompletion.spec.ts similarity index 71% rename from tests/ui/specs/periodic-oncompletion.spec.ts rename to tests/ui/specs/loop-oncompletion.spec.ts index 3b8ebe0d..ce5a7ec2 100644 --- a/tests/ui/specs/periodic-oncompletion.spec.ts +++ b/tests/ui/specs/loop-oncompletion.spec.ts @@ -2,20 +2,20 @@ import { testWithCleanup as test, expect } from "../fixtures/test-fixtures"; import { apiUrl } from "../utils/selectors"; /** - * On-completion periodic trigger UI tests. + * On-completion loop trigger UI tests. * - * Verifies that the PeriodicFrequencyPanel correctly handles the + * Verifies that the LoopFrequencyPanel correctly handles the * "On completion" trigger tab: tab switching, delay input visibility, * delay clamping (>= minDelaySeconds), max time inputs, and that the * correct PATCH bodies are sent. * - * Setup: creates a session and configures it as periodic via the REST API + * Setup: creates a session and configures it as loop via the REST API * (more reliable than context-menu UI flows in beforeEach). The backend - * sends a periodic_updated WebSocket event that flips periodicEnabled=true - * in the frontend, causing the PeriodicFrequencyPanel to appear. + * sends a loop_updated WebSocket event that flips loopEnabled=true + * in the frontend, causing the LoopFrequencyPanel to appear. */ -test.describe("Periodic on-completion trigger", () => { +test.describe("Loop on-completion trigger", () => { let sessionId: string; test.beforeEach(async ({ page, request, helpers, timeouts }) => { @@ -34,15 +34,15 @@ test.describe("Periodic on-completion trigger", () => { await helpers.navigateAndWait(page); await helpers.navigateToSession(page, sessionId); - // Configure the session as periodic directly via REST API. + // Configure the session as loop directly via REST API. // This is more reliable in beforeEach than UI-driven context menus because - // it avoids click-timing races; the backend still broadcasts periodic_updated + // it avoids click-timing races; the backend still broadcasts loop_updated // over WebSocket so the frontend panel appears as expected. const putResp = await request.put( - apiUrl(`/api/sessions/${sessionId}/periodic`), + apiUrl(`/api/sessions/${sessionId}/loop`), { data: { - prompt: "Test periodic", + prompt: "Test loop", frequency: { value: 1, unit: "hours" }, enabled: true, max_iterations: 0, @@ -51,24 +51,24 @@ test.describe("Periodic on-completion trigger", () => { ); expect( putResp.ok(), - `PUT periodic failed: ${putResp.status()}`, + `PUT loop failed: ${putResp.status()}`, ).toBeTruthy(); - // The periodic_updated WS event flips periodicEnabled=true in ChatInput, - // which makes the PeriodicFrequencyPanel visible. + // The loop_updated WS event flips loopEnabled=true in ChatInput, + // which makes the LoopFrequencyPanel visible. await expect( - page.locator('[data-testid="periodic-frequency-panel"]'), + page.locator('[data-testid="loop-frequency-panel"]'), ).toBeVisible({ timeout: timeouts.appReady }); // Expand the settings body to show the trigger tabs and limit rows. - await page.locator('[data-testid="periodic-expand-toggle"]').click(); + await page.locator('[data-testid="loop-expand-toggle"]').click(); // Both trigger tabs should now be visible. await expect( - page.locator('[data-testid="periodic-trigger-tab-schedule"]'), + page.locator('[data-testid="loop-trigger-tab-schedule"]'), ).toBeVisible({ timeout: timeouts.shortAction }); await expect( - page.locator('[data-testid="periodic-trigger-tab-oncompletion"]'), + page.locator('[data-testid="loop-trigger-tab-oncompletion"]'), ).toBeVisible({ timeout: timeouts.shortAction }); }); @@ -78,10 +78,10 @@ test.describe("Periodic on-completion trigger", () => { }) => { // Tabs were asserted in beforeEach — confirm both are present await expect( - page.locator('[data-testid="periodic-trigger-tab-schedule"]'), + page.locator('[data-testid="loop-trigger-tab-schedule"]'), ).toBeVisible(); await expect( - page.locator('[data-testid="periodic-trigger-tab-oncompletion"]'), + page.locator('[data-testid="loop-trigger-tab-oncompletion"]'), ).toBeVisible(); }); @@ -90,10 +90,10 @@ test.describe("Periodic on-completion trigger", () => { timeouts, }) => { await expect( - page.locator('[data-testid="periodic-max-duration-value"]'), + page.locator('[data-testid="loop-max-duration-value"]'), ).toBeVisible(); await expect( - page.locator('[data-testid="periodic-max-duration-unit"]'), + page.locator('[data-testid="loop-max-duration-unit"]'), ).toBeVisible(); }); @@ -103,7 +103,7 @@ test.describe("Periodic on-completion trigger", () => { }) => { const patchBodies: any[] = []; await page.route( - `**${apiUrl(`/api/sessions/${sessionId}/periodic`)}`, + `**${apiUrl(`/api/sessions/${sessionId}/loop`)}`, async (route) => { if (route.request().method() === "PATCH") { patchBodies.push(route.request().postDataJSON()); @@ -113,11 +113,11 @@ test.describe("Periodic on-completion trigger", () => { ); await page - .locator('[data-testid="periodic-trigger-tab-oncompletion"]') + .locator('[data-testid="loop-trigger-tab-oncompletion"]') .click(); // Staged edits: changes are only persisted when the Save button is pressed. - await page.locator('[data-testid="periodic-save-button"]').click(); + await page.locator('[data-testid="loop-save-button"]').click(); await expect .poll(() => patchBodies.length, { timeout: timeouts.shortAction }) @@ -131,17 +131,17 @@ test.describe("Periodic on-completion trigger", () => { }) => { // Initially in schedule mode — delay input should not be visible await expect( - page.locator('[data-testid="periodic-delay-input"]'), + page.locator('[data-testid="loop-delay-input"]'), ).not.toBeVisible(); // Switch to onCompletion await page - .locator('[data-testid="periodic-trigger-tab-oncompletion"]') + .locator('[data-testid="loop-trigger-tab-oncompletion"]') .click(); // Delay input should now appear await expect( - page.locator('[data-testid="periodic-delay-input"]'), + page.locator('[data-testid="loop-delay-input"]'), ).toBeVisible({ timeout: timeouts.shortAction }); }); @@ -151,14 +151,14 @@ test.describe("Periodic on-completion trigger", () => { }) => { // Switch to onCompletion await page - .locator('[data-testid="periodic-trigger-tab-oncompletion"]') + .locator('[data-testid="loop-trigger-tab-oncompletion"]') .click(); await expect( - page.locator('[data-testid="periodic-delay-input"]'), + page.locator('[data-testid="loop-delay-input"]'), ).toBeVisible({ timeout: timeouts.shortAction }); // Enter a value below the 5s floor - const delayInput = page.locator('[data-testid="periodic-delay-input"]'); + const delayInput = page.locator('[data-testid="loop-delay-input"]'); await delayInput.fill("2"); await delayInput.blur(); @@ -176,7 +176,7 @@ test.describe("Periodic on-completion trigger", () => { }) => { const patchBodies: any[] = []; await page.route( - `**${apiUrl(`/api/sessions/${sessionId}/periodic`)}`, + `**${apiUrl(`/api/sessions/${sessionId}/loop`)}`, async (route) => { if (route.request().method() === "PATCH") { patchBodies.push(route.request().postDataJSON()); @@ -187,13 +187,13 @@ test.describe("Periodic on-completion trigger", () => { // Set max time to 2 hours const maxDurInput = page.locator( - '[data-testid="periodic-max-duration-value"]', + '[data-testid="loop-max-duration-value"]', ); await maxDurInput.fill("2"); await maxDurInput.blur(); // Staged edits: changes are only persisted when the Save button is pressed. - await page.locator('[data-testid="periodic-save-button"]').click(); + await page.locator('[data-testid="loop-save-button"]').click(); await expect .poll( @@ -209,13 +209,13 @@ test.describe("Periodic on-completion trigger", () => { expect(maxDurPatch.max_duration_seconds).toBeGreaterThan(0); }); - test("saving a new unbounded on-completion periodic warns, then saves on confirm", async ({ + test("saving a new unbounded on-completion loop warns, then saves on confirm", async ({ page, timeouts, }) => { const patchBodies: any[] = []; await page.route( - `**${apiUrl(`/api/sessions/${sessionId}/periodic`)}`, + `**${apiUrl(`/api/sessions/${sessionId}/loop`)}`, async (route) => { if (route.request().method() === "PATCH") { patchBodies.push(route.request().postDataJSON()); @@ -226,17 +226,17 @@ test.describe("Periodic on-completion trigger", () => { // Switch to onCompletion (pre-fills safety limits for this new conversation). await page - .locator('[data-testid="periodic-trigger-tab-oncompletion"]') + .locator('[data-testid="loop-trigger-tab-oncompletion"]') .click(); - // Clear both limits → unbounded config (dangerous for a brand-new periodic). + // Clear both limits → unbounded config (dangerous for a brand-new loop). await page - .locator('[data-testid="periodic-panel-max-iterations"]') + .locator('[data-testid="loop-panel-max-iterations"]') .fill("0"); - await page.locator('[data-testid="periodic-max-duration-value"]').fill("0"); + await page.locator('[data-testid="loop-max-duration-value"]').fill("0"); - // Saving an unbounded, dangerous, brand-new periodic must prompt first. - await page.locator('[data-testid="periodic-save-button"]').click(); + // Saving an unbounded, dangerous, brand-new loop must prompt first. + await page.locator('[data-testid="loop-save-button"]').click(); const dialog = page.locator('[data-testid="confirm-dialog"]'); await expect(dialog).toBeVisible({ timeout: timeouts.shortAction }); await expect(dialog).toContainText("could keep running indefinitely"); @@ -260,7 +260,7 @@ test.describe("Periodic on-completion trigger", () => { }) => { const patchBodies: any[] = []; await page.route( - `**${apiUrl(`/api/sessions/${sessionId}/periodic`)}`, + `**${apiUrl(`/api/sessions/${sessionId}/loop`)}`, async (route) => { if (route.request().method() === "PATCH") { patchBodies.push(route.request().postDataJSON()); @@ -270,14 +270,14 @@ test.describe("Periodic on-completion trigger", () => { ); await page - .locator('[data-testid="periodic-trigger-tab-oncompletion"]') + .locator('[data-testid="loop-trigger-tab-oncompletion"]') .click(); await page - .locator('[data-testid="periodic-panel-max-iterations"]') + .locator('[data-testid="loop-panel-max-iterations"]') .fill("0"); - await page.locator('[data-testid="periodic-max-duration-value"]').fill("0"); + await page.locator('[data-testid="loop-max-duration-value"]').fill("0"); - await page.locator('[data-testid="periodic-save-button"]').click(); + await page.locator('[data-testid="loop-save-button"]').click(); const dialog = page.locator('[data-testid="confirm-dialog"]'); await expect(dialog).toBeVisible({ timeout: timeouts.shortAction }); @@ -295,7 +295,7 @@ test.describe("Periodic on-completion trigger", () => { page, }) => { await expect( - page.locator('[data-testid="periodic-trigger-tab-ontasks"]'), + page.locator('[data-testid="loop-trigger-tab-ontasks"]'), ).toHaveCount(0); }); }); diff --git a/tests/ui/specs/periodic-prompt-pill.spec.ts b/tests/ui/specs/loop-prompt-pill.spec.ts similarity index 76% rename from tests/ui/specs/periodic-prompt-pill.spec.ts rename to tests/ui/specs/loop-prompt-pill.spec.ts index 5d5ccab0..b421230e 100644 --- a/tests/ui/specs/periodic-prompt-pill.spec.ts +++ b/tests/ui/specs/loop-prompt-pill.spec.ts @@ -2,14 +2,14 @@ import { testWithCleanup as test, expect } from "../fixtures/test-fixtures"; import { selectors } from "../utils/selectors"; /** - * Periodic Prompt Pill tests. + * Loop Prompt Pill tests. * - * These tests verify the end-to-end flow of periodic prompt triggering and + * These tests verify the end-to-end flow of loop prompt triggering and * the NamedPromptPill appearing in the UI. * * Bug context: * Before the fix, `checkAndFillGap` was called AFTER `updateLastKnownSeq` - * in the WebSocket message handlers. This meant that when a periodic prompt + * in the WebSocket message handlers. This meant that when a loop prompt * was triggered and the `user_prompt` event was missed by the WebSocket * observer, gap detection in subsequent agent messages would fail because * `updateLastKnownSeq` had already advanced the watermark past the missing @@ -18,7 +18,7 @@ import { selectors } from "../utils/selectors"; * The fix swaps the order so `checkAndFillGap` runs BEFORE `updateLastKnownSeq`. * * Regression test: - * The pill should appear QUICKLY (within ~10s) after the periodic prompt is + * The pill should appear QUICKLY (within ~10s) after the loop prompt is * triggered via run-now. If the old bug were present the pill would either * never appear (gap never filled) or only appear after a reconnect cycle * (much longer than 10s). @@ -30,18 +30,18 @@ import { selectors } from "../utils/selectors"; * → trigger pattern: (?i)(^|\n)(hello|hi|hey)([!., \n]|$) ← matches the prompt text */ -test.describe("Periodic Prompt Pill", () => { +test.describe("Loop Prompt Pill", () => { test.beforeEach(async ({ page }) => { // Navigate to the app. We do NOT use navigateAndEnsureSession here because - // after test 1 creates a periodic session the app may auto-select it, and - // periodic sessions hide the textarea that waitForAppReady expects. + // after test 1 creates a loop session the app may auto-select it, and + // loop sessions hide the textarea that waitForAppReady expects. // Each test creates its own fresh session via createFreshSession() instead. await page.goto("/"); // Wait for the page to be minimally loaded (session list visible). await page.waitForLoadState("networkidle"); }); - test("should show NamedPromptPill when periodic prompt is triggered via run-now", async ({ + test("should show NamedPromptPill when loop prompt is triggered via run-now", async ({ page, request, helpers, @@ -52,12 +52,12 @@ test.describe("Periodic Prompt Pill", () => { const sessionId = await helpers.createFreshSession(page); expect(sessionId).toBeTruthy(); - // 2. Configure periodic prompt using the "Hello Greeting" prompt that exists in + // 2. Configure loop prompt using the "Hello Greeting" prompt that exists in // the project-alpha workspace fixture. The mock ACP matches the resolved // prompt text ("Hello! How are you doing today?") via the simple-greeting // fixture (pattern: (?i)(hello|hi|hey)…). - const periodicResponse = await request.put( - apiUrl(`/api/sessions/${sessionId}/periodic`), + const loopResponse = await request.put( + apiUrl(`/api/sessions/${sessionId}/loop`), { data: { prompt_name: "Hello Greeting", @@ -67,25 +67,25 @@ test.describe("Periodic Prompt Pill", () => { }, ); expect( - periodicResponse.ok(), - `PUT periodic failed: ${periodicResponse.status()} ${await periodicResponse.text()}`, + loopResponse.ok(), + `PUT loop failed: ${loopResponse.status()} ${await loopResponse.text()}`, ).toBe(true); // 3. Record T0 so we can assert the pill appears promptly (not after a long delay). const t0 = Date.now(); - // 4. Trigger the periodic prompt immediately via run-now (bypass the schedule). + // 4. Trigger the loop prompt immediately via run-now (bypass the schedule). // The server may need a moment to un-archive / resume the session before it // can accept a new prompt, so allow a brief retry on 409/503. let runResponse = await request.post( - apiUrl(`/api/sessions/${sessionId}/periodic/run-now`), + apiUrl(`/api/sessions/${sessionId}/loop/run-now`), { data: { reset_timer: false } }, ); // If the session is briefly busy (409), wait a moment and retry once. if (runResponse.status() === 409) { await page.waitForTimeout(2000); runResponse = await request.post( - apiUrl(`/api/sessions/${sessionId}/periodic/run-now`), + apiUrl(`/api/sessions/${sessionId}/loop/run-now`), { data: { reset_timer: false } }, ); } @@ -106,7 +106,7 @@ test.describe("Periodic Prompt Pill", () => { const elapsed = Date.now() - t0; console.log( - `[periodic-prompt-pill] NamedPromptPill appeared after ${elapsed}ms ` + + `[loop-prompt-pill] NamedPromptPill appeared after ${elapsed}ms ` + `(should be well under 10s if gap detection is working)`, ); diff --git a/tests/ui/specs/periodic-prompt-selector.spec.ts b/tests/ui/specs/loop-prompt-selector.spec.ts similarity index 70% rename from tests/ui/specs/periodic-prompt-selector.spec.ts rename to tests/ui/specs/loop-prompt-selector.spec.ts index 9c05c82e..4a9d3e2d 100644 --- a/tests/ui/specs/periodic-prompt-selector.spec.ts +++ b/tests/ui/specs/loop-prompt-selector.spec.ts @@ -2,7 +2,7 @@ import { testWithCleanup as test, expect } from "../fixtures/test-fixtures"; import { timeouts, apiUrl, selectors } from "../utils/selectors"; /** - * PeriodicPromptSelector internals tests. + * LoopPromptSelector internals tests. * * Regression net: catches a daisyUI restyle breaking: * - The selector dropdown opening/closing @@ -10,15 +10,15 @@ import { timeouts, apiUrl, selectors } from "../utils/selectors"; * - Search filter narrowing the list * - Prompt selection updating the displayed name * - * The existing periodic-prompt-pill.spec.ts only tests the pill trigger - * (NamedPromptPill) after a periodic run. This spec goes deeper, opening - * the PeriodicPromptSelector *picker* dropdown itself. + * The existing loop-prompt-pill.spec.ts only tests the pill trigger + * (NamedPromptPill) after a loop run. This spec goes deeper, opening + * the LoopPromptSelector *picker* dropdown itself. * * Setup: * - Uses the project-alpha workspace fixture which has a "Hello Greeting" * prompt in .mitto/prompts/greeting.md - * - Configures periodic via PUT /api/sessions/:id/periodic so the UI - * renders PeriodicPromptSelector with isOpen=true. + * - Configures loop via PUT /api/sessions/:id/loop so the UI + * renders LoopPromptSelector with isOpen=true. */ /** @@ -47,30 +47,30 @@ async function apiCreateSession( } /** - * Configure periodic on a session via REST API. + * Configure loop on a session via REST API. * - * The `periodic_updated` broadcast arrives on the global events WebSocket + * The `loop_updated` broadcast arrives on the global events WebSocket * (/api/events) which may connect slightly after the session WS. We retry * the PUT a few times with a brief delay to ensure the message is received. - * Callers should then wait for a UI signal (e.g. periodic-frequency-panel + * Callers should then wait for a UI signal (e.g. loop-frequency-panel * visible) with a generous timeout. */ -async function enablePeriodic( +async function enableLoop( request: import("@playwright/test").APIRequestContext, sessionId: string, promptName: string, ): Promise<void> { - const resp = await request.put(apiUrl(`/api/sessions/${sessionId}/periodic`), { + const resp = await request.put(apiUrl(`/api/sessions/${sessionId}/loop`), { data: { prompt_name: promptName, frequency: { value: 1, unit: "hours" }, enabled: true, }, }); - expect(resp.ok(), `PUT periodic failed: ${resp.status()} ${await resp.text()}`).toBe(true); + expect(resp.ok(), `PUT loop failed: ${resp.status()} ${await resp.text()}`).toBe(true); } -test.describe("PeriodicPromptSelector internals", () => { +test.describe("LoopPromptSelector internals", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); await page.waitForLoadState("networkidle"); @@ -81,20 +81,20 @@ test.describe("PeriodicPromptSelector internals", () => { request, timeouts: t, }) => { - // Create a fresh session and enable periodic + // Create a fresh session and enable loop const sessionId = await apiCreateSession(page, request); expect(sessionId).toBeTruthy(); - await enablePeriodic(request, sessionId, "Hello Greeting"); + await enableLoop(request, sessionId, "Hello Greeting"); - // PeriodicFrequencyPanel becomes visible when periodic_enabled=true. + // LoopFrequencyPanel becomes visible when loop_enabled=true. // Use agentResponse timeout to handle the global events WS race: the - // periodic_updated broadcast arrives on /api/events which may connect + // loop_updated broadcast arrives on /api/events which may connect // slightly after the session WS, so the state update can take a few seconds. - const frequencyPanel = page.locator('[data-testid="periodic-frequency-panel"]'); + const frequencyPanel = page.locator('[data-testid="loop-frequency-panel"]'); await expect(frequencyPanel).toBeVisible({ timeout: t.agentResponse }); // The trigger button should show the currently selected prompt name - const triggerBtn = page.locator('[data-testid="periodic-prompt-selector-button"]'); + const triggerBtn = page.locator('[data-testid="loop-prompt-selector-button"]'); await expect(triggerBtn).toBeAttached(); await expect(triggerBtn).toContainText("Hello Greeting"); @@ -102,17 +102,17 @@ test.describe("PeriodicPromptSelector internals", () => { await triggerBtn.click(); // Dropdown panel should now appear - const dropdown = page.locator('[data-testid="periodic-prompt-selector-dropdown"]'); + const dropdown = page.locator('[data-testid="loop-prompt-selector-dropdown"]'); await expect(dropdown).toBeVisible({ timeout: t.shortAction }); // Search filter input should be focused - const searchInput = page.locator('[data-testid="periodic-prompt-selector-search"]'); + const searchInput = page.locator('[data-testid="loop-prompt-selector-search"]'); await expect(searchInput).toBeVisible(); await expect(searchInput).toBeFocused(); // At least the "Hello Greeting" prompt from the project-alpha workspace should appear const promptList = page.locator( - '[data-testid="periodic-prompt-selector-list"]', + '[data-testid="loop-prompt-selector-list"]', ); await expect(promptList).toBeVisible(); @@ -133,24 +133,24 @@ test.describe("PeriodicPromptSelector internals", () => { }) => { const sessionId = await apiCreateSession(page, request); expect(sessionId).toBeTruthy(); - await enablePeriodic(request, sessionId, "Hello Greeting"); + await enableLoop(request, sessionId, "Hello Greeting"); - await expect(page.locator('[data-testid="periodic-frequency-panel"]')).toBeVisible({ + await expect(page.locator('[data-testid="loop-frequency-panel"]')).toBeVisible({ timeout: t.agentResponse, }); - const triggerBtn = page.locator('[data-testid="periodic-prompt-selector-button"]'); + const triggerBtn = page.locator('[data-testid="loop-prompt-selector-button"]'); await triggerBtn.click(); - const dropdown = page.locator('[data-testid="periodic-prompt-selector-dropdown"]'); + const dropdown = page.locator('[data-testid="loop-prompt-selector-dropdown"]'); await expect(dropdown).toBeVisible({ timeout: t.shortAction }); - const searchInput = page.locator('[data-testid="periodic-prompt-selector-search"]'); + const searchInput = page.locator('[data-testid="loop-prompt-selector-search"]'); // Type a filter that matches "Hello Greeting" await searchInput.fill("hello"); const promptList = page.locator( - '[data-testid="periodic-prompt-selector-list"]', + '[data-testid="loop-prompt-selector-list"]', ); const greetingItem = promptList.locator("button.prompt-item", { hasText: "Hello Greeting", @@ -181,14 +181,14 @@ test.describe("PeriodicPromptSelector internals", () => { timeouts: t, }) => { const sessionId = await apiCreateSession(page, request); - await enablePeriodic(request, sessionId, "Hello Greeting"); + await enableLoop(request, sessionId, "Hello Greeting"); - await expect(page.locator('[data-testid="periodic-frequency-panel"]')).toBeVisible({ + await expect(page.locator('[data-testid="loop-frequency-panel"]')).toBeVisible({ timeout: t.agentResponse, }); - await page.locator('[data-testid="periodic-prompt-selector-button"]').click(); - const dropdown = page.locator('[data-testid="periodic-prompt-selector-dropdown"]'); + await page.locator('[data-testid="loop-prompt-selector-button"]').click(); + const dropdown = page.locator('[data-testid="loop-prompt-selector-dropdown"]'); await expect(dropdown).toBeVisible({ timeout: t.shortAction }); // Click somewhere outside the dropdown diff --git a/tests/ui/specs/make-loop.spec.ts b/tests/ui/specs/make-loop.spec.ts new file mode 100644 index 00000000..32781eb5 --- /dev/null +++ b/tests/ui/specs/make-loop.spec.ts @@ -0,0 +1,123 @@ +import { testWithCleanup as test, expect } from "../fixtures/test-fixtures"; +import { apiUrl } from "../utils/selectors"; + +/** + * "Make loop" context menu action tests. + * + * Verifies that right-clicking a regular (non-loop, non-child) conversation + * and selecting "Make loop" from the context menu: + * 1. Sends PUT /api/sessions/{id}/loop with the draft body. + * 2. The loop_updated broadcast triggers the frontend to flip + * session.loop_enabled=true. + * 3. The LoopFrequencyPanel (data-testid="loop-frequency-panel") becomes + * visible in the ChatInput, confirming the inline editor opened. + * + * Setup mirrors conversation-context-menu.spec.ts (right-click the session item). + */ + +// daisyUI context menus render as fixed-position <ul class="menu fixed z-50 …"> +const MENU = ".menu.fixed.z-50.shadow-xl"; + +test.describe("Make loop — context menu action", () => { + let sessionId: string; + + test.beforeEach(async ({ page, request, helpers }) => { + // Create a fresh regular (non-loop) top-level session. + const createResp = await request.post(apiUrl("/api/sessions"), { + data: { name: `Make Loop Test ${Date.now()}` }, + }); + expect(createResp.ok(), `POST /api/sessions failed: ${createResp.status()}`).toBeTruthy(); + const created = await createResp.json(); + sessionId = created.session_id || created.id; + expect(sessionId).toBeTruthy(); + + await helpers.navigateAndWait(page); + await helpers.navigateToSession(page, sessionId); + }); + + test("shows 'Make loop' in the context menu for a regular session", async ({ + page, + timeouts, + }) => { + // Open context menu via right-click on the session item. + const sessionItem = page.locator(`[data-session-id="${sessionId}"]`).first(); + await expect(sessionItem).toBeVisible({ timeout: timeouts.appReady }); + await sessionItem.click({ button: "right" }); + + const menu = page.locator(MENU).first(); + await expect(menu).toBeVisible({ timeout: timeouts.shortAction }); + + // "Make loop" must be present. + const makeLoopBtn = menu.locator("button").filter({ hasText: "Make loop" }); + await expect(makeLoopBtn).toBeVisible({ timeout: timeouts.shortAction }); + }); + + test("clicking 'Make loop' converts the conversation and opens the loop editor", async ({ + page, + timeouts, + }) => { + // Open context menu and click "Make loop". + const sessionItem = page.locator(`[data-session-id="${sessionId}"]`).first(); + await expect(sessionItem).toBeVisible({ timeout: timeouts.appReady }); + await sessionItem.click({ button: "right" }); + + const menu = page.locator(MENU).first(); + await expect(menu).toBeVisible({ timeout: timeouts.shortAction }); + + const makeLoopBtn = menu.locator("button").filter({ hasText: "Make loop" }); + await expect(makeLoopBtn).toBeVisible({ timeout: timeouts.shortAction }); + await makeLoopBtn.click(); + + // The menu should close after selection. + await expect(page.locator(MENU)).toHaveCount(0, { timeout: timeouts.shortAction }); + + // The loop_updated WebSocket event flips loop_enabled=true. + // LoopFrequencyPanel renders when loopEnabled=true in ChatInput. + const loopPanel = page.locator('[data-testid="loop-frequency-panel"]'); + await expect(loopPanel).toBeVisible({ timeout: timeouts.appReady }); + }); + + test("clicking 'Make non-loop' reverts the conversation and hides the loop editor", async ({ + page, + timeouts, + }) => { + // Step 1: Convert to loop via "Make loop" (reuse existing flow). + const sessionItem = page.locator(`[data-session-id="${sessionId}"]`).first(); + await expect(sessionItem).toBeVisible({ timeout: timeouts.appReady }); + await sessionItem.click({ button: "right" }); + + let menu = page.locator(MENU).first(); + await expect(menu).toBeVisible({ timeout: timeouts.shortAction }); + + const makeLoopBtn = menu.locator("button").filter({ hasText: "Make loop" }); + await expect(makeLoopBtn).toBeVisible({ timeout: timeouts.shortAction }); + await makeLoopBtn.click(); + + await expect(page.locator(MENU)).toHaveCount(0, { timeout: timeouts.shortAction }); + + // Wait for the loop editor to appear (confirms conversion succeeded). + const loopPanel = page.locator('[data-testid="loop-frequency-panel"]'); + await expect(loopPanel).toBeVisible({ timeout: timeouts.appReady }); + + // Step 2: Right-click again — now "Make non-loop" should be visible + // and "Make loop" should be gone (they are mutually exclusive). + await sessionItem.click({ button: "right" }); + menu = page.locator(MENU).first(); + await expect(menu).toBeVisible({ timeout: timeouts.shortAction }); + + const makeNonLoopBtn = menu.locator("button").filter({ hasText: "Make non-loop" }); + await expect(makeNonLoopBtn).toBeVisible({ timeout: timeouts.shortAction }); + + // "Make loop" must NOT appear for an already-loop session. + await expect(menu.locator("button").filter({ hasText: "Make loop" })).toHaveCount(0); + + // Step 3: Click "Make non-loop" and confirm the editor disappears. + await makeNonLoopBtn.click(); + await expect(page.locator(MENU)).toHaveCount(0, { timeout: timeouts.shortAction }); + + // The loop_updated broadcast (nil) flips loop_enabled=false. + // LoopFrequencyPanel stays in the DOM but collapses to h-0/opacity-0 + // (CSS transition), so Playwright sees it as not visible. + await expect(loopPanel).not.toBeVisible({ timeout: timeouts.appReady }); + }); +}); diff --git a/tests/ui/specs/make-periodic.spec.ts b/tests/ui/specs/make-periodic.spec.ts deleted file mode 100644 index 4c8e6adb..00000000 --- a/tests/ui/specs/make-periodic.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { testWithCleanup as test, expect } from "../fixtures/test-fixtures"; -import { apiUrl } from "../utils/selectors"; - -/** - * "Make periodic" context menu action tests. - * - * Verifies that right-clicking a regular (non-periodic, non-child) conversation - * and selecting "Make periodic" from the context menu: - * 1. Sends PUT /api/sessions/{id}/periodic with the draft body. - * 2. The periodic_updated broadcast triggers the frontend to flip - * session.periodic_enabled=true. - * 3. The PeriodicFrequencyPanel (data-testid="periodic-frequency-panel") becomes - * visible in the ChatInput, confirming the inline editor opened. - * - * Setup mirrors conversation-context-menu.spec.ts (right-click the session item). - */ - -// daisyUI context menus render as fixed-position <ul class="menu fixed z-50 …"> -const MENU = ".menu.fixed.z-50.shadow-xl"; - -test.describe("Make periodic — context menu action", () => { - let sessionId: string; - - test.beforeEach(async ({ page, request, helpers }) => { - // Create a fresh regular (non-periodic) top-level session. - const createResp = await request.post(apiUrl("/api/sessions"), { - data: { name: `Make Periodic Test ${Date.now()}` }, - }); - expect(createResp.ok(), `POST /api/sessions failed: ${createResp.status()}`).toBeTruthy(); - const created = await createResp.json(); - sessionId = created.session_id || created.id; - expect(sessionId).toBeTruthy(); - - await helpers.navigateAndWait(page); - await helpers.navigateToSession(page, sessionId); - }); - - test("shows 'Make periodic' in the context menu for a regular session", async ({ - page, - timeouts, - }) => { - // Open context menu via right-click on the session item. - const sessionItem = page.locator(`[data-session-id="${sessionId}"]`).first(); - await expect(sessionItem).toBeVisible({ timeout: timeouts.appReady }); - await sessionItem.click({ button: "right" }); - - const menu = page.locator(MENU).first(); - await expect(menu).toBeVisible({ timeout: timeouts.shortAction }); - - // "Make periodic" must be present. - const makePeriodicBtn = menu.locator("button").filter({ hasText: "Make periodic" }); - await expect(makePeriodicBtn).toBeVisible({ timeout: timeouts.shortAction }); - }); - - test("clicking 'Make periodic' converts the conversation and opens the periodic editor", async ({ - page, - timeouts, - }) => { - // Open context menu and click "Make periodic". - const sessionItem = page.locator(`[data-session-id="${sessionId}"]`).first(); - await expect(sessionItem).toBeVisible({ timeout: timeouts.appReady }); - await sessionItem.click({ button: "right" }); - - const menu = page.locator(MENU).first(); - await expect(menu).toBeVisible({ timeout: timeouts.shortAction }); - - const makePeriodicBtn = menu.locator("button").filter({ hasText: "Make periodic" }); - await expect(makePeriodicBtn).toBeVisible({ timeout: timeouts.shortAction }); - await makePeriodicBtn.click(); - - // The menu should close after selection. - await expect(page.locator(MENU)).toHaveCount(0, { timeout: timeouts.shortAction }); - - // The periodic_updated WebSocket event flips periodic_enabled=true. - // PeriodicFrequencyPanel renders when periodicEnabled=true in ChatInput. - const periodicPanel = page.locator('[data-testid="periodic-frequency-panel"]'); - await expect(periodicPanel).toBeVisible({ timeout: timeouts.appReady }); - }); - - test("clicking 'Make non-periodic' reverts the conversation and hides the periodic editor", async ({ - page, - timeouts, - }) => { - // Step 1: Convert to periodic via "Make periodic" (reuse existing flow). - const sessionItem = page.locator(`[data-session-id="${sessionId}"]`).first(); - await expect(sessionItem).toBeVisible({ timeout: timeouts.appReady }); - await sessionItem.click({ button: "right" }); - - let menu = page.locator(MENU).first(); - await expect(menu).toBeVisible({ timeout: timeouts.shortAction }); - - const makePeriodicBtn = menu.locator("button").filter({ hasText: "Make periodic" }); - await expect(makePeriodicBtn).toBeVisible({ timeout: timeouts.shortAction }); - await makePeriodicBtn.click(); - - await expect(page.locator(MENU)).toHaveCount(0, { timeout: timeouts.shortAction }); - - // Wait for the periodic editor to appear (confirms conversion succeeded). - const periodicPanel = page.locator('[data-testid="periodic-frequency-panel"]'); - await expect(periodicPanel).toBeVisible({ timeout: timeouts.appReady }); - - // Step 2: Right-click again — now "Make non-periodic" should be visible - // and "Make periodic" should be gone (they are mutually exclusive). - await sessionItem.click({ button: "right" }); - menu = page.locator(MENU).first(); - await expect(menu).toBeVisible({ timeout: timeouts.shortAction }); - - const makeNonPeriodicBtn = menu.locator("button").filter({ hasText: "Make non-periodic" }); - await expect(makeNonPeriodicBtn).toBeVisible({ timeout: timeouts.shortAction }); - - // "Make periodic" must NOT appear for an already-periodic session. - await expect(menu.locator("button").filter({ hasText: "Make periodic" })).toHaveCount(0); - - // Step 3: Click "Make non-periodic" and confirm the editor disappears. - await makeNonPeriodicBtn.click(); - await expect(page.locator(MENU)).toHaveCount(0, { timeout: timeouts.shortAction }); - - // The periodic_updated broadcast (nil) flips periodic_enabled=false. - // PeriodicFrequencyPanel stays in the DOM but collapses to h-0/opacity-0 - // (CSS transition), so Playwright sees it as not visible. - await expect(periodicPanel).not.toBeVisible({ timeout: timeouts.appReady }); - }); -}); diff --git a/tests/ui/specs/session-panel-tabs.spec.ts b/tests/ui/specs/session-panel-tabs.spec.ts new file mode 100644 index 00000000..70b0439a --- /dev/null +++ b/tests/ui/specs/session-panel-tabs.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "../fixtures/test-fixtures"; +import type { Page, Locator } from "@playwright/test"; + +/** + * Regression guard for the Conversation properties panel tab glitch. + * + * Root cause (fixed): the three tab <label>s in SessionPanel combined daisyUI's + * `.tab` (from `tabs-lift`) AND `.tooltip` on the SAME element. Both utilities + * render into the element's ::before/::after pseudo-elements, so they collided. + * When a tab's (visually hidden) radio gained focus after selection, + * `.tooltip:has(:focus-visible)` made the tooltip ::before visible; merged with + * `tabs-lift`'s active-tab corner pseudo-element (and shifted left by the + * tooltip's own translateX(-50%)), it painted a stray grey rounded bar under the + * tabs. The fix drops the daisyUI `tooltip`/`data-tip` in favour of a native + * `title` attribute (matching WorkspacesDialog's tabs). + * + * This spec locks that in: with the panel open, no tab may carry the tooltip + * utility, and neither the ::before nor the ::after of any tab may render as a + * visible filled bar — checked in the exact condition that triggered the bug + * (the tab's radio focused/active). + */ + +const openSessionPanel = async (page: Page): Promise<Locator> => { + await page.locator('[data-testid="header-session-details"]').click(); + const panel = page.locator('[data-testid="session-panel"]'); + await expect(panel).toBeVisible({ timeout: 5000 }); + return panel; +}; + +// Read the computed ::before / ::after of a tab label, plus the structural +// markers that identify the (removed) daisyUI tooltip. Runs while the tab's +// hidden radio is focused, reproducing the state that surfaced the glitch. +async function inspectTab(tab: Locator) { + return tab.evaluate((el) => { + const radio = el.querySelector("input"); + if (radio) (radio as HTMLElement).focus(); + const before = getComputedStyle(el, "::before"); + const after = getComputedStyle(el, "::after"); + return { + hasTooltipClass: el.classList.contains("tooltip"), + hasDataTip: el.hasAttribute("data-tip"), + beforeBg: before.backgroundColor, + beforeContent: before.content, + afterBg: after.backgroundColor, + afterContent: after.content, + }; + }); +} + +const TRANSPARENT = "rgba(0, 0, 0, 0)"; + +test.describe("SessionPanel tabs (no stray pseudo-element on focus)", () => { + test.beforeEach(async ({ page, helpers }) => { + await helpers.navigateAndEnsureSession(page); + }); + + test("renders exactly three tabs without the daisyUI tooltip utility", async ({ + page, + }) => { + const panel = await openSessionPanel(page); + const tabs = panel.locator('[role="tablist"] label.tab'); + await expect(tabs).toHaveCount(3); + + // Each tab uses a native `title` (accessible hover hint) and NOT the + // daisyUI `tooltip` class — the class was the root cause of the collision. + for (let i = 0; i < 3; i++) { + const tab = tabs.nth(i); + await expect(tab).not.toHaveClass(/\btooltip\b/); + await expect(tab).toHaveAttribute("title", /.+/); + await expect(tab).not.toHaveAttribute("data-tip", /.+/); + } + }); + + test("no tab paints a stray filled pseudo-element bar when focused", async ({ + page, + }) => { + const panel = await openSessionPanel(page); + const tabs = panel.locator('[role="tablist"] label.tab'); + await expect(tabs).toHaveCount(3); + + for (let i = 0; i < 3; i++) { + const tab = tabs.nth(i); + // Activate the tab (this also focuses its hidden radio — the exact + // condition under which the old tooltip ::before became visible). + await tab.click(); + const res = await inspectTab(tab); + + // Structural: the tooltip utility must be gone (it owned ::before/::after). + expect(res.hasTooltipClass, `tab ${i} must not have .tooltip`).toBe(false); + expect(res.hasDataTip, `tab ${i} must not have data-tip`).toBe(false); + + // The daisyUI tooltip arrow lived on ::after with content:"" (never + // 'none'). After the fix `tabs-lift` uses no ::after, so it must not exist. + expect(res.afterContent, `tab ${i} ::after must not render`).toBe("none"); + expect(res.afterBg, `tab ${i} ::after must be transparent`).toBe( + TRANSPARENT, + ); + + // The stray bar was the tooltip bubble body: a solid neutral-coloured, + // rounded ::before. `tabs-lift`'s legit active-tab corner uses only a + // background-IMAGE (transparent background-color), so a non-transparent + // ::before background here means the bubble regressed. + expect(res.beforeBg, `tab ${i} ::before must not be a filled bar`).toBe( + TRANSPARENT, + ); + } + }); +}); diff --git a/tests/ui/specs/sessions.spec.ts b/tests/ui/specs/sessions.spec.ts index c721b90c..660420e6 100644 --- a/tests/ui/specs/sessions.spec.ts +++ b/tests/ui/specs/sessions.spec.ts @@ -15,9 +15,9 @@ test.describe("Session Management", () => { test.beforeEach(async ({ page, helpers }) => { // navigateAndWait resets the conversation filter tab in localStorage via // addInitScript before the page loads. However the app's React side also - // has an auto-tab-switch effect that switches to the Periodic tab whenever - // the active session has periodic_enabled=true (e.g. after the - // periodic-prompt-pill.spec.ts suite). We therefore explicitly click the + // has an auto-tab-switch effect that switches to the Loop tab whenever + // the active session has loop_enabled=true (e.g. after the + // loop-prompt-pill.spec.ts suite). We therefore explicitly click the // Conversations tab after the app is ready so all session-management tests // always start on the correct tab. await helpers.navigateAndWait(page); diff --git a/tests/ui/specs/sidebar-toolbar-structure.spec.ts b/tests/ui/specs/sidebar-toolbar-structure.spec.ts index ec3ea887..fe9868a5 100644 --- a/tests/ui/specs/sidebar-toolbar-structure.spec.ts +++ b/tests/ui/specs/sidebar-toolbar-structure.spec.ts @@ -2,22 +2,23 @@ import { test, expect } from "../fixtures/test-fixtures"; import type { Page } from "@playwright/test"; /** - * Structural regression net for the sidebar toolbar button row (the join group - * holding New Conversation / Workspaces / Filter / Density / Search / Settings). + * Structural regression net for the sidebar toolbar button row (New + * Conversation / Workspaces / Filter / Density / Search / Settings). * - * Locks down two bugs that were fixed separately: - * 1. SIZING: the Filter/Density triggers are <summary> elements inside a - * <details>, and a <details> silently ignores flex-grow when flex-basis is - * 0 (the `flex-1` utility). That left those two items at content width - * (~44px) while the plain <button> items grew to ~70px. The fix switches - * all children to `flex-auto` (flex-basis auto) so every item gets an - * equal share. This spec asserts all items share the same width/height. - * 2. BORDER: the global per-<button> border rule never reached the <summary> - * triggers, leaving Filter/Density borderless. A scoped styles-v2.css rule - * mirrors the border onto the toolbar's summaries. This spec asserts all - * items share the same border-top color. - * - * Selectors are anchored on stable data-testids so they survive restyles. + * The toolbar is now rendered by the portable Toolbar component + * (web/static/components/Toolbar.js) as a segmented "pill": a single bordered, + * rounded container (`.mitto-toolbar`) holding borderless ghost icon buttons. + * This spec locks the invariants of that shape: + * 1. PRESENCE: all six items are present and visible (anchored on stable + * data-testids so selectors survive restyles). + * 2. HEIGHT: every item shares the same height (all btn-sm) so the row reads + * as one continuous surface. Widths intentionally differ now — the + * Filter/Density dropdowns carry a caret and are wider than plain icon + * buttons — so width equality is NOT asserted. + * 3. BORDERS: the single pill container carries the visible border; the + * individual items are borderless at rest (the global per-<button> resting + * border is suppressed for `.mitto-toolbar > button`). This documents the + * restyle and guards against a regression back to per-item boxes. */ const ITEM_IDS = [ @@ -32,26 +33,39 @@ const ITEM_IDS = [ const toolbar = (page: Page) => page.locator('[data-testid="sidebar-toolbar"]').first(); -// Collect per-item box metrics + resting border-top color. The mouse defaults -// to (0,0) so no item is hovered; ghost buttons only show their border at rest -// via the app's global/scoped border rules, which is exactly what we assert. +const pill = (page: Page) => + page.locator('[data-testid="sidebar-toolbar"] .mitto-toolbar').first(); + +// Collect per-item box metrics + resting border-top width/style. The mouse +// defaults to (0,0) so no item is hovered; we assert the resting appearance. async function collectItemMetrics(page: Page) { const tb = toolbar(page); - const metrics: { id: string; w: number; h: number; border: string }[] = []; + const metrics: { + id: string; + w: number; + h: number; + borderWidth: number; + borderStyle: string; + }[] = []; for (const id of ITEM_IDS) { const el = tb.locator(`[data-testid="${id}"]`).first(); await expect(el).toBeVisible(); const m = await el.evaluate((node) => { const r = node.getBoundingClientRect(); const cs = getComputedStyle(node); - return { w: r.width, h: r.height, border: cs.borderTopColor }; + return { + w: r.width, + h: r.height, + borderWidth: parseFloat(cs.borderTopWidth) || 0, + borderStyle: cs.borderTopStyle, + }; }); metrics.push({ id, ...m }); } return metrics; } -test.describe("Sidebar toolbar structure (sizing + border regression)", () => { +test.describe("Sidebar toolbar structure (segmented pill)", () => { test.beforeEach(async ({ page, helpers }) => { await helpers.navigateAndWait(page); await expect(toolbar(page)).toBeVisible({ timeout: 5000 }); @@ -64,26 +78,10 @@ test.describe("Sidebar toolbar structure (sizing + border regression)", () => { } }); - test("all toolbar items share identical width and height", async ({ - page, - }) => { + test("all toolbar items share the same height", async ({ page }) => { const metrics = await collectItemMetrics(page); - - const widths = metrics.map((m) => m.w); const heights = metrics.map((m) => m.h); - - // Sub-pixel flex rounding can differ by a fraction of a pixel between - // items; allow a 1.5px spread but nothing close to the old 70 vs 44 gap. - const widthSpread = Math.max(...widths) - Math.min(...widths); const heightSpread = Math.max(...heights) - Math.min(...heights); - - expect( - widthSpread, - `toolbar item widths must match (got ${JSON.stringify( - metrics.map((m) => [m.id, Math.round(m.w)]), - )})`, - ).toBeLessThanOrEqual(1.5); - expect( heightSpread, `toolbar item heights must match (got ${JSON.stringify( @@ -92,16 +90,30 @@ test.describe("Sidebar toolbar structure (sizing + border regression)", () => { ).toBeLessThanOrEqual(1.5); }); - test("all toolbar items share the same border-top color", async ({ - page, - }) => { + test("pill container is bordered and rounded", async ({ page }) => { + const el = pill(page); + await expect(el).toBeVisible(); + const box = await el.evaluate((node) => { + const cs = getComputedStyle(node); + return { + borderWidth: parseFloat(cs.borderTopWidth) || 0, + borderStyle: cs.borderTopStyle, + radius: parseFloat(cs.borderTopLeftRadius) || 0, + }; + }); + expect(box.borderStyle).not.toBe("none"); + expect(box.borderWidth).toBeGreaterThan(0); + expect(box.radius).toBeGreaterThan(0); + }); + + test("toolbar items are borderless at rest", async ({ page }) => { const metrics = await collectItemMetrics(page); - const colors = new Set(metrics.map((m) => m.border)); - expect( - colors.size, - `toolbar item borders must match (got ${JSON.stringify( - metrics.map((m) => [m.id, m.border]), - )})`, - ).toBe(1); + for (const m of metrics) { + const borderless = m.borderStyle === "none" || m.borderWidth === 0; + expect( + borderless, + `${m.id} must be borderless at rest (style=${m.borderStyle}, width=${m.borderWidth})`, + ).toBe(true); + } }); }); diff --git a/tests/ui/specs/sync.spec.ts b/tests/ui/specs/sync.spec.ts index 163c45f8..bcec4740 100644 --- a/tests/ui/specs/sync.spec.ts +++ b/tests/ui/specs/sync.spec.ts @@ -313,7 +313,7 @@ test.describe("Message Ordering After Sync", () => { }); test.describe("Keepalive", () => { - test("should send keepalive messages periodically", async ({ + test("should send keepalive messages at regular intervals", async ({ page, helpers, }) => { diff --git a/tests/ui/specs/ui-prompt-toggle.spec.ts b/tests/ui/specs/ui-prompt-toggle.spec.ts index 14f5e0b7..5f234034 100644 --- a/tests/ui/specs/ui-prompt-toggle.spec.ts +++ b/tests/ui/specs/ui-prompt-toggle.spec.ts @@ -110,22 +110,22 @@ test.describe("MCP UI options panel — stop button", () => { await expect(page.locator(".chat-input-container")).toBeVisible(); }); - test("periodic frequency panel hides during a UI prompt and reappears after Stop", async ({ + test("loop frequency panel hides during a UI prompt and reappears after Stop", async ({ page, request, apiUrl, helpers, timeouts, }) => { - // Create a fresh, isolated session and convert it to periodic via the API. - // (The right-click "Make periodic" context menu flow is covered elsewhere; + // Create a fresh, isolated session and convert it to loop via the API. + // (The right-click "Make loop" context menu flow is covered elsewhere; // here we go straight through the REST endpoint so this test does not depend // on the session-list context menu.) const sessionId = await helpers.createFreshSession(page); expect(sessionId).toBeTruthy(); - const periodicResponse = await request.put( - apiUrl(`/api/sessions/${sessionId}/periodic`), + const loopResponse = await request.put( + apiUrl(`/api/sessions/${sessionId}/loop`), { data: { prompt_name: "Hello Greeting", @@ -135,16 +135,16 @@ test.describe("MCP UI options panel — stop button", () => { }, ); expect( - periodicResponse.ok(), - `PUT periodic failed: ${periodicResponse.status()} ${await periodicResponse.text()}`, + loopResponse.ok(), + `PUT loop failed: ${loopResponse.status()} ${await loopResponse.text()}`, ).toBe(true); - // The periodic_updated broadcast flips periodicConfigured=true, so the - // PeriodicFrequencyPanel opens (isOpen → opacity-100; collapsed → h-0). - const periodicPanel = page.locator( - '[data-testid="periodic-frequency-panel"]', + // The loop_updated broadcast flips loopConfigured=true, so the + // LoopFrequencyPanel opens (isOpen → opacity-100; collapsed → h-0). + const loopPanel = page.locator( + '[data-testid="loop-frequency-panel"]', ); - await expect(periodicPanel).toBeVisible({ timeout: timeouts.appReady }); + await expect(loopPanel).toBeVisible({ timeout: timeouts.appReady }); // Inject a synthetic ui_prompt (options) into the active session WebSocket. const dispatched = await page.evaluate((sid) => { @@ -153,9 +153,9 @@ test.describe("MCP UI options panel — stop button", () => { type: "ui_prompt", data: { session_id: sid, - request_id: "test-ui-periodic-1", + request_id: "test-ui-loop-1", prompt_type: "options_buttons", - question: "Periodic hide test question?", + question: "Loop hide test question?", options: [ { id: "a", label: "Option A", description: "First option" }, { id: "b", label: "Option B", description: "Second option" }, @@ -185,11 +185,11 @@ test.describe("MCP UI options panel — stop button", () => { const panel = page.locator(".ui-prompt-panel"); await expect(panel).toBeVisible({ timeout: 5000 }); - // ...and the periodic frequency panel collapses (hidden) while the UI prompt + // ...and the loop frequency panel collapses (hidden) while the UI prompt // is active — the two are mutually exclusive. - await expect(periodicPanel).toBeHidden({ timeout: 5000 }); + await expect(loopPanel).toBeHidden({ timeout: 5000 }); - // Stopping the prompt aborts it; the periodic frequency panel reappears. + // Stopping the prompt aborts it; the loop frequency panel reappears. const stopBtn = page.locator( '.ui-prompt-panel button[data-tip="Stop the agent"]', ); @@ -197,6 +197,6 @@ test.describe("MCP UI options panel — stop button", () => { await stopBtn.click(); await expect(panel).toBeHidden(); - await expect(periodicPanel).toBeVisible({ timeout: timeouts.appReady }); + await expect(loopPanel).toBeVisible({ timeout: timeouts.appReady }); }); }); diff --git a/tests/ui/specs/workspace-shortcuts-add.spec.ts b/tests/ui/specs/workspace-shortcuts-add.spec.ts new file mode 100644 index 00000000..ea6bfff0 --- /dev/null +++ b/tests/ui/specs/workspace-shortcuts-add.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from "../fixtures/test-fixtures"; +import type { Page } from "@playwright/test"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Reproduction + regression lock for the "+ Add shortcut" seeding across all + * three shortcut sections of the WorkspacesDialog "Cuts" (shortcuts) tab. + * + * The reported bug: adding a shortcut under "Conversation" / "Tasks list" + * produced an unusable row (empty prompt selector, icon grid dominating the + * view), while "Beads issue" worked. addShortcutRow() must be symmetric — a + * freshly-added row in EVERY section must be seeded with the first available + * prompt for that section (so the <select> has a non-empty value), which + * requires sectionPrompts[section] to be populated for all three. + * + * The project-alpha fixture ships prompts tagged for each menu: + * - menus: beadsList → tasksList section + * - menus: conversation → conversations section + * - menus: beadsIssues → beadsIssue section + */ + +const projectRoot = path.resolve(__dirname, "../../.."); +const WORKSPACE_ALPHA = path.join( + projectRoot, + "tests/fixtures/workspaces/project-alpha", +); +const FOLDER_NAME = "project-alpha"; + +const SECTIONS = ["tasksList", "conversations", "beadsIssue"] as const; + +const dialog = (page: Page) => + page.locator('[data-testid="workspaces-dialog"]'); + +async function openDialog(page: Page) { + await page.locator('button[data-testid="workspaces-btn"]').first().click(); + await expect(dialog(page)).toBeVisible({ timeout: 5000 }); +} + +async function selectFolderHeader(page: Page) { + await page.locator(`[data-folder-name="${FOLDER_NAME}"]`).click(); +} + +async function openShortcutsTab(page: Page) { + await page.locator('[data-testid="ws-tab-shortcuts"]').click(); + // Wait for the shortcuts panel to finish loading its prompt lists: the + // first section's "+ Add shortcut" button becomes enabled. + await expect( + page.locator('[data-testid="shortcut-add-tasksList"]'), + ).toBeVisible({ timeout: 5000 }); +} + +test.describe("WorkspacesDialog shortcuts — Add shortcut seeding", () => { + // Skip in Docker — requires a host-local workspace path. + test.beforeEach(() => { + test.skip( + !!process.env.MITTO_EXTERNAL_SERVER, + "Requires host-local workspace path unavailable in Docker", + ); + }); + + test.beforeAll(async ({ request, apiUrl }) => { + await request.post(apiUrl("/api/workspaces"), { + data: { acp_server: "mock-acp", working_dir: WORKSPACE_ALPHA }, + }); + }); + + test.beforeEach(async ({ page, helpers }) => { + await helpers.navigateAndWait(page); + }); + + test("adds a seeded row in every section (not just Beads issue)", async ({ + page, + }) => { + await openDialog(page); + await selectFolderHeader(page); + await openShortcutsTab(page); + + for (const section of SECTIONS) { + const addBtn = page.locator(`[data-testid="shortcut-add-${section}"]`); + await expect(addBtn).toBeEnabled(); + await addBtn.click(); + + const row = page.locator(`[data-testid="shortcut-row-${section}-0"]`); + await expect(row).toBeVisible({ timeout: 5000 }); + + // The freshly-added row must be seeded with a real prompt: the <select> + // value is non-empty (the reported bug left this empty for two sections). + const select = row.locator("select"); + const value = await select.inputValue(); + expect( + value, + `section "${section}" should seed the first available prompt`, + ).not.toBe(""); + + // The icon-picker grid must collapse when the dropdown is not focused. + // daisyUI collapses it via display:none; a `display` utility (flex) placed + // directly on .dropdown-content would land in the utilities layer and + // override that rule, leaving the grid permanently laid out (invisible but + // click-intercepting — it would swallow clicks on the controls below it, + // e.g. the next section's "+ Add shortcut"). Assert it is truly hidden. + const grid = row.locator(".dropdown-content"); + await expect(grid).toBeHidden(); + } + }); +}); diff --git a/tests/ui/utils/helpers.ts b/tests/ui/utils/helpers.ts index c160d7f3..dedd0e31 100644 --- a/tests/ui/utils/helpers.ts +++ b/tests/ui/utils/helpers.ts @@ -216,10 +216,10 @@ export async function navigateToSession( * Wait for the Mitto app to be fully loaded and ready. * Accepts two ready states: * 1. The chat textarea is visible (regular active session). - * 2. The "Conversations" sidebar heading is visible (periodic session selected, + * 2. The "Conversations" sidebar heading is visible (loop session selected, * or no session — both hide the textarea but still render the sidebar). * Using an OR condition prevents waitForAppReady from timing out when a previous - * test has left a periodic session as the active session. + * test has left a loop session as the active session. * `.first()` avoids a Playwright strict-mode violation when both elements exist. */ export async function waitForAppReady(page: Page): Promise<void> { @@ -245,7 +245,7 @@ export async function waitForActiveSession(page: Page): Promise<void> { * Returns the session ID. * * This also handles the case where the currently active session is a - * PERIODIC session: periodic sessions hide the textarea entirely (they show a + * LOOP session: loop sessions hide the textarea entirely (they show a * different prompt-configuration UI). `locator.isDisabled()` returns false for * a non-existent element, so we must also check the element count to detect * the absent-textarea case. @@ -254,15 +254,15 @@ export async function ensureActiveSession(page: Page): Promise<string> { const textarea = page.locator(selectors.chatInput); // Determine whether we need a new session: - // • textarea absent → periodic session is active (no regular input area) + // • textarea absent → loop session is active (no regular input area) // • textarea disabled → session exists but ACP is not ready yet const textareaCount = await textarea.count(); const needsNewSession = textareaCount === 0 || (await textarea.isDisabled()); if (needsNewSession) { // Ensure we are on the Conversations tab before creating a new session. - // If the Periodic tab is active, clicking "New Conversation" would create - // a periodic session (which also hides the textarea), perpetuating the + // If the Loop tab is active, clicking "New Conversation" would create + // a loop session (which also hides the textarea), perpetuating the // problem. Switching to Conversations first guarantees a regular session. const conversationsTab = page.getByRole("tab", { name: "Conversations" }); if (await conversationsTab.isVisible()) { @@ -356,7 +356,7 @@ export function uniqueMessage(prefix: string = "Test"): string { export async function navigateAndWait(page: Page): Promise<void> { // Clear the filter tab key BEFORE the page loads so the React app reads the // default (Conversations) on initialization. Without this a test such as - // periodic-prompt-pill.spec.ts can leave the Periodic tab selected in + // loop-prompt-pill.spec.ts can leave the Loop tab selected in // localStorage, causing subsequent tests to see the wrong tab. await page.addInitScript(() => { localStorage.removeItem("mitto_conversation_filter_tab"); diff --git a/tests/ui/utils/selectors.ts b/tests/ui/utils/selectors.ts index 98e6473b..197f626a 100644 --- a/tests/ui/utils/selectors.ts +++ b/tests/ui/utils/selectors.ts @@ -106,8 +106,10 @@ export const selectors = { // Copy as Markdown // Per-message hover-reveal copy button (present on both user and agent bubbles) copyMessageMarkdown: '[data-testid="copy-message-markdown"]', - // Header three-dot button that opens the conversation actions menu - headerConversationMenu: '[data-testid="header-conversation-menu"]', + // Header toolbar button that opens the conversation prompt-groups menu + headerConversationMenu: '[data-testid="header-conversation-prompts"]', + // Header toolbar button: copy the whole conversation as Markdown + headerCopyMarkdown: '[data-testid="header-copy-markdown"]', // Fixed-position daisyUI context menu rendered by the ContextMenu component // (shared by the conversation header menu and the sidebar row menu). contextMenu: '.menu.fixed.z-50.shadow-xl', diff --git a/web/static/app.js b/web/static/app.js index e197360a..5f7dd613 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -31,8 +31,8 @@ import { getArchiveReasonText, conversationToMarkdown, copyToClipboard, - PERIODIC_STOPPED_LABELS, - formatPeriodicMaxDuration, + LOOP_STOPPED_LABELS, + formatLoopMaxDuration, computeHeaderTriggerLabel, } from "./lib.js"; @@ -102,13 +102,14 @@ import { useSessionNavigation, useConversationMenu, useConversationSeeding, - decidePeriodicAction, - makePeriodicNow, + decideLoopAction, + makeLoopNow, } from "./hooks/index.js"; // Import components import { SessionItem } from "./components/SessionItem.js"; import { SessionList } from "./components/SessionList.js"; +import { Toolbar } from "./components/Toolbar.js"; import { MessageList } from "./components/MessageList.js"; import { Message } from "./components/Message.js"; import { ChatInput } from "./components/ChatInput.js"; @@ -122,7 +123,7 @@ import { } from "./components/AgentPlanPanel.js"; import { SessionPanel } from "./components/SessionPanel.js"; import { Drawer } from "./components/Drawer.js"; -import { PeriodicFrequencyPanel } from "./components/PeriodicFrequencyPanel.js"; +import { LoopFrequencyPanel } from "./components/LoopFrequencyPanel.js"; import { CountdownDisplay } from "./components/CountdownDisplay.js"; import { ToastContainer } from "./components/ToastContainer.js"; import { @@ -154,8 +155,9 @@ import { ArchiveIcon, ArchiveFilledIcon, ListIcon, - PeriodicIcon, - PeriodicFilledIcon, + LoopIcon, + LoopOffIcon, + LoopFilledIcon, CheckIcon, ClockIcon, StopIcon, @@ -167,7 +169,8 @@ import { TerminalIcon, FolderOpenIcon, BeadsIcon, - EllipsisIcon, + CopyIcon, + BroomIcon, } from "./components/Icons.js"; import { ContextMenu } from "./components/ContextMenu.js"; import { @@ -179,9 +182,9 @@ import { // Import constants import { CYCLING_MODE, - PERIODIC_PROGRESS_STYLE, - PERIODIC_PROGRESS_COLORS, - PERIODIC_PROGRESS_URGENT_THRESHOLD, + LOOP_PROGRESS_STYLE, + LOOP_PROGRESS_COLORS, + LOOP_PROGRESS_URGENT_THRESHOLD, } from "./constants.js"; // Import prompt utilities @@ -192,7 +195,7 @@ import { autofillConversationMenuArgs, fetchCachedParamNames, effectiveMissingParams, - promptResolveAsPeriodic, + promptResolveAsLoop, } from "./utils/prompts.js"; // Import global event handlers (registers side effects on module load) and predicates @@ -206,7 +209,7 @@ import { WorkspaceBadge, WorkspacePill } from "./components/WorkspaceBadge.js"; import { DeleteDialog } from "./components/DeleteDialog.js"; import { KeyboardShortcutsDialog } from "./components/KeyboardShortcutsDialog.js"; import { NewSessionWorkspaceDialog } from "./components/NewSessionWorkspaceDialog.js"; -import { PeriodicScheduleDialog } from "./components/PeriodicScheduleDialog.js"; +import { LoopScheduleDialog } from "./components/LoopScheduleDialog.js"; import { PromptParameterDialog } from "./components/PromptParameterDialog.js"; import { Tooltip } from "./components/Tooltip.js"; @@ -256,8 +259,8 @@ function App() { fetchStoredSessions, backgroundCompletion, clearBackgroundCompletion, - periodicStarted, - clearPeriodicStarted, + loopStarted, + clearLoopStarted, backgroundUIPrompt, clearBackgroundUIPrompt, backgroundUIPromptTimeout, @@ -431,9 +434,9 @@ function App() { const [keyboardShortcutsDialog, setKeyboardShortcutsDialog] = useState({ isOpen: false, }); // Keyboard shortcuts dialog - // Periodic schedule dialog: opened when a periodic prompt is selected from any menu. + // Loop schedule dialog: opened when a loop prompt is selected from any menu. // Shape: null | { prompt, onSchedule: async ({ value, unit, at? }) => void } - const [periodicScheduleDialog, setPeriodicScheduleDialog] = useState(null); + const [loopScheduleDialog, setLoopScheduleDialog] = useState(null); // Prompt parameter dialog: opened when a beadsIssues prompt has parameters that // the menu cannot auto-fill. Shape: null | { prompt, parameters, onSubmit } const [promptParamDialog, setPromptParamDialog] = useState(null); @@ -442,7 +445,7 @@ function App() { const { workspacePrompts, predefinedPrompts, - periodicPrompts, + loopPrompts, fetchWorkspacePrompts, fetchConversationPromptsForSession, } = useWorkspacePrompts({ @@ -456,7 +459,7 @@ function App() { // CommandExists("bd") && DirExists(".beads")) — no new fetch. If ANY workspace // prompt opts into the beadsIssues/beadsList menus, the backend has already // proven this workspace is beads-enabled for the active session's folder. - // Drives the "On tasks" periodic trigger tab's visibility (mitto-oja.4). + // Drives the "On tasks" loop trigger tab's visibility (mitto-oja.4). const hasBeadsWorkspace = useMemo( () => (workspacePrompts || []).some( @@ -523,8 +526,8 @@ function App() { setShowSidebar, setShowSidePanel, setSidePanelTab, - onOpenPeriodicDialog: (prompt, onSchedule) => - setPeriodicScheduleDialog({ prompt, onSchedule }), + onOpenLoopDialog: (prompt, onSchedule) => + setLoopScheduleDialog({ prompt, onSchedule }), onOpenPromptParamDialog: (prompt, parameters, onSubmit) => setPromptParamDialog({ prompt, parameters, onSubmit }), activeSessionId, @@ -539,14 +542,15 @@ function App() { }, [beadsIssueOpen]); // Conversation seeding: send a named prompt to an existing conversation via queue, - // or create a new (optionally periodic) conversation seeded with a named prompt. + // or create a new (optionally loop) conversation seeded with a named prompt. const { seedConversationWithPrompt, startConversationWithPrompt } = useConversationSeeding({ newSession }); // Launch a named prompt in a new conversation for the "prompts" upstream type in BeadsView. // action is "pull"|"push"|"sync"; conversationName is set to "Pull tasks" etc. + // args is an optional map of prompt argument name→value forwarded to the queue seed. const handleBeadsLaunchPrompt = useCallback( - async (action, promptName) => { + async (action, promptName, args) => { const names = { pull: "Pull tasks", push: "Push tasks", @@ -558,6 +562,7 @@ function App() { // omit acpServer — use the folder default name: conversationName, prompt: { name: promptName }, + arguments: args, }); if (!result?.sessionId) { showToast({ @@ -597,6 +602,45 @@ function App() { }; }, [handleOpenBeadsIssue, activeSessionId, sessionInfo?.working_dir]); + // Linked beads issue status for the conversation-toolbar "linked issue" button + // badge dot. Fetched asynchronously (bd show can be slow) and keyed on the + // active conversation's linked issue; cleared when there is no linked issue. + // Mirrors the fetch in SessionPanel.js so both surfaces stay in sync. + const [headerBeadsStatus, setHeaderBeadsStatus] = useState(null); + useEffect(() => { + const issueId = sessionInfo?.beads_issue; + const workingDir = sessionInfo?.working_dir; + if (!issueId || !workingDir) { + setHeaderBeadsStatus(null); + return; + } + let cancelled = false; + (async () => { + try { + const res = await authFetch( + endpoints.issues.show(issueId, { working_dir: workingDir }), + ); + if (!res.ok) { + if (!cancelled) setHeaderBeadsStatus(null); + return; + } + const data = await res.json(); + if (cancelled) return; + const issueObj = Array.isArray(data) ? data[0] : data; + if (issueObj && !issueObj.error && issueObj.status) { + setHeaderBeadsStatus(issueObj.status); + } else { + setHeaderBeadsStatus(null); + } + } catch (_err) { + if (!cancelled) setHeaderBeadsStatus(null); + } + })(); + return () => { + cancelled = true; + }; + }, [activeSessionId, sessionInfo?.beads_issue, sessionInfo?.working_dir]); + // Wire the active-conversation-removed callback consumed by useWebSocket. When // the active conversation is deleted or archived (in this window or via a // cross-window session_deleted / session_archived broadcast), navigate to that @@ -671,18 +715,18 @@ function App() { focusSession, ]); - // Show toast and native notification when a periodic prompt starts + // Show toast and native notification when a loop prompt starts useEffect(() => { - if (periodicStarted) { + if (loopStarted) { // Show native macOS notification (not sticky — auto-dismisses) if ( window.mittoNativeNotificationsEnabled && typeof window.mittoShowNativeNotification === "function" ) { window.mittoShowNativeNotification( - periodicStarted.sessionName || "Periodic Conversation", - "Periodic run started", - periodicStarted.sessionId, + loopStarted.sessionName || "Loop Conversation", + "Loop run started", + loopStarted.sessionId, false, ); } @@ -690,14 +734,14 @@ function App() { // Show in-app toast showToast({ style: "info", - title: periodicStarted.sessionName || "Periodic Conversation", - message: "periodic run started", + title: loopStarted.sessionName || "Loop Conversation", + message: "loop run started", duration: 5000, - onClick: () => focusSession(periodicStarted.sessionId), + onClick: () => focusSession(loopStarted.sessionId), }); - clearPeriodicStarted(); + clearLoopStarted(); } - }, [periodicStarted, clearPeriodicStarted, showToast, focusSession]); + }, [loopStarted, clearLoopStarted, showToast, focusSession]); // Show toast when a UI prompt arrives in a background session useEffect(() => { @@ -1874,42 +1918,39 @@ function App() { // (cross-window), mirroring how deletion defers to removeSession (mitto-17d). }; - // Convert an existing regular conversation to a periodic one by creating a - // draft periodic config (enabled:false). The periodic_updated WebSocket event - // sets periodic_configured=true (reveals the inline periodic editor in ChatInput) - // while periodic_enabled stays false (conversation remains in the Conversations - // group). The user must explicitly enable scheduling to move it to Periodic group. - const handleMakePeriodic = useCallback( + // Convert an existing regular conversation to a loop one by creating a + // draft loop config (enabled:false). The loop_updated WebSocket event + // sets loop_configured=true (reveals the inline loop editor in ChatInput) + // while loop_enabled stays false (conversation remains in the Conversations + // group). The user must explicitly enable scheduling to move it to Loop group. + const handleMakeLoop = useCallback( async (session) => { const sessionId = session?.session_id; if (!sessionId) return; try { - const res = await secureFetch( - apiUrl(`/api/sessions/${sessionId}/periodic`), - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - // Draft body: "(pending)" satisfies PeriodicPrompt.Validate() while - // enabled:false keeps it as DRAFT so nothing is scheduled yet. - body: JSON.stringify({ - prompt: "(pending)", - frequency: { value: 1, unit: "hours" }, - enabled: false, - }), - }, - ); + const res = await secureFetch(endpoints.sessions.loop(sessionId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + // Draft body: "(pending)" satisfies LoopPrompt.Validate() while + // enabled:false keeps it as DRAFT so nothing is scheduled yet. + body: JSON.stringify({ + prompt: "(pending)", + frequency: { value: 1, unit: "hours" }, + enabled: false, + }), + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); focusSession(sessionId); showToast({ style: "success", - title: "Conversation is now periodic", + title: "Conversation is now loop", message: "Choose a prompt and enable scheduling.", duration: 6000, }); } catch (e) { showToast({ style: "error", - title: "Failed to make conversation periodic", + title: "Failed to make conversation loop", duration: 5000, }); } @@ -1917,30 +1958,29 @@ function App() { [focusSession, showToast], ); - // Remove the periodic config from a conversation, reverting it to a regular one. - // DELETE /api/sessions/{id}/periodic broadcasts periodic_updated (nil), which - // sets both periodic_configured=false (hides the inline periodic editor) and - // periodic_enabled=false (moves conversation back to the Conversations group). - const handleMakeNonPeriodic = useCallback( + // Remove the loop config from a conversation, reverting it to a regular one. + // DELETE /api/sessions/{id}/loop broadcasts loop_updated (nil), which + // sets both loop_configured=false (hides the inline loop editor) and + // loop_enabled=false (moves conversation back to the Conversations group). + const handleMakeNonLoop = useCallback( async (session) => { const sessionId = session?.session_id; if (!sessionId) return; try { - const res = await secureFetch( - apiUrl(`/api/sessions/${sessionId}/periodic`), - { method: "DELETE" }, - ); + const res = await secureFetch(endpoints.sessions.loop(sessionId), { + method: "DELETE", + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); showToast({ style: "success", - title: "Periodic scheduling removed", + title: "Loop scheduling removed", message: "The conversation is now a regular conversation.", duration: 6000, }); } catch (e) { showToast({ style: "error", - title: "Failed to remove periodic scheduling", + title: "Failed to remove loop scheduling", duration: 5000, }); } @@ -1952,21 +1992,21 @@ function App() { // text. The queue delivers it to the agent when the conversation is idle, so // this works for any conversation (not just the active one). // - // When the chosen prompt declares `periodic`, the handler branches on the target - // conversation's state (decidePeriodicAction): - // "new-periodic" — no session: open schedule dialog → create NEW periodic conversation. - // "make-periodic" — regular conversation: configure as periodic + fire first run. - // "one-shot" — already periodic / child conversation: send prompt once, no config change. + // When the chosen prompt declares `loop`, the handler branches on the target + // conversation's state (decideLoopAction): + // "new-loop" — no session: open schedule dialog → create NEW loop conversation. + // "make-loop" — regular conversation: configure as loop + fire first run. + // "one-shot" — already a loop / child conversation: send prompt once, no config change. const handleSendPromptToConversation = useCallback( async (session, prompt, opts) => { if (!prompt?.name) return; - const asPeriodic = promptResolveAsPeriodic(prompt, opts?.asPeriodic); - if (asPeriodic) { - const action = decidePeriodicAction(session); + const asLoop = promptResolveAsLoop(prompt, opts?.asLoop); + if (asLoop) { + const action = decideLoopAction(session); - if (action === "make-periodic") { - // Regular conversation: configure it as periodic now and fire the first run. + if (action === "make-loop") { + // Regular conversation: configure it as loop now and fire the first run. const sessionId = session.session_id; let missing = getMissingPromptParameters(prompt, "conversation"); if (missing.length > 0 && sessionId) { @@ -1979,19 +2019,19 @@ function App() { parameters: missing, hostSessionId: sessionId, onSubmit: async (userArgs) => { - const result = await makePeriodicNow(sessionId, prompt, { + const result = await makeLoopNow(sessionId, prompt, { arguments: userArgs, }); if (result.success) { showToast({ style: "success", - title: `Made conversation periodic with "${prompt.name}"`, + title: `Made conversation loop with "${prompt.name}"`, duration: 3000, }); } else { showToast({ style: "warning", - title: "Failed to configure periodic schedule", + title: "Failed to configure loop schedule", duration: 4000, }); } @@ -1999,17 +2039,17 @@ function App() { }); return; } - const result = await makePeriodicNow(sessionId, prompt); + const result = await makeLoopNow(sessionId, prompt); if (result.success) { showToast({ style: "success", - title: `Made conversation periodic with "${prompt.name}"`, + title: `Made conversation loop with "${prompt.name}"`, duration: 3000, }); } else { showToast({ style: "warning", - title: "Failed to configure periodic schedule", + title: "Failed to configure loop schedule", duration: 4000, }); } @@ -2017,7 +2057,7 @@ function App() { } if (action === "one-shot") { - // Already-periodic or child conversation: enqueue a single run without touching config. + // Already-loop or child conversation: enqueue a single run without touching config. const sessionId = session?.session_id; if (!sessionId) return; let missing = getMissingPromptParameters(prompt, "conversation"); @@ -2070,13 +2110,13 @@ function App() { return; } - // action === "new-periodic": no session — open schedule dialog → create NEW periodic conversation. + // action === "new-loop": no session — open schedule dialog → create NEW loop conversation. // When the prompt has parameters, collect them first, then open the schedule dialog. const openScheduleDialog = (collectedArgs) => { - setPeriodicScheduleDialog({ + setLoopScheduleDialog({ prompt, onSchedule: async (schedule) => { - setPeriodicScheduleDialog(null); + setLoopScheduleDialog(null); const workingDir = session?.working_dir; const acpServer = session?.acp_server; const result = await startConversationWithPrompt({ @@ -2086,33 +2126,33 @@ function App() { ...(collectedArgs && Object.keys(collectedArgs).length > 0 ? { arguments: collectedArgs } : {}), - periodic: schedule, + loop: schedule, }); if (result?.sessionId) { focusSession(result.sessionId); showToast({ style: "success", - title: `Started periodic "${prompt.name}"`, + title: `Started loop "${prompt.name}"`, duration: 3000, }); } else { showToast({ style: "warning", - title: "Failed to start periodic conversation", + title: "Failed to start loop conversation", duration: 4000, }); } }, }); }; - const missingForNewPeriodic = getMissingPromptParameters( + const missingForNewLoop = getMissingPromptParameters( prompt, "conversation", ); - if (missingForNewPeriodic.length > 0) { + if (missingForNewLoop.length > 0) { setPromptParamDialog({ prompt, - parameters: missingForNewPeriodic, + parameters: missingForNewLoop, onSubmit: (userArgs) => openScheduleDialog(userArgs), }); return; @@ -2121,7 +2161,7 @@ function App() { return; } - // Non-periodic prompt: enqueue the named prompt to the existing conversation. + // Non-loop prompt: enqueue the named prompt to the existing conversation. const sessionId = session?.session_id; if (!sessionId) return; // Auto-fill what the host conversation can supply (e.g. a lone child for a @@ -2212,7 +2252,7 @@ function App() { [allSessions, activeSessionId], ); const headerIsArchived = activeSession?.archived || false; - const headerIsPeriodic = activeSession?.periodic_configured || false; + const headerIsLoop = activeSession?.loop_configured || false; const headerIsSpawned = !!(activeSession && activeSession.parent_session_id) && !activeHasChildren; // Only the active conversation can have queued messages; streaming state comes @@ -2227,21 +2267,21 @@ function App() { const headerWorkingDir = activeSession?.working_dir || sessionInfo?.working_dir || ""; - // Header subtitle: ACP server name (always) plus, for periodic conversations, a - // live countdown + next scheduled run time. The periodic fields live on the - // stored session object (GET /api/sessions + periodic_updated broadcasts carry + // Header subtitle: ACP server name (always) plus, for loop conversations, a + // live countdown + next scheduled run time. The loop fields live on the + // stored session object (GET /api/sessions + loop_updated broadcasts carry // next_scheduled_at + frequency; the per-session "connected" message does not). const headerAcpServer = sessionInfo?.acp_server || activeSession?.acp_server || ""; const headerNextScheduledAt = - (activeSession?.periodic_configured && activeSession?.next_scheduled_at) || + (activeSession?.loop_configured && activeSession?.next_scheduled_at) || null; - const headerPeriodicUnit = activeSession?.periodic_frequency?.unit || "hours"; - // Derive a single 3-state pill for the periodic status: running | paused | stopped | null. - // null means not periodic (no pill rendered). - const headerPeriodicState = (() => { - if (!activeSession?.periodic_configured) return null; - if (activeSession?.periodic_enabled) { + const headerLoopUnit = activeSession?.loop_frequency?.unit || "hours"; + // Derive a single 3-state pill for the loop status: running | paused | stopped | null. + // null means not loop (no pill rendered). + const headerLoopState = (() => { + if (!activeSession?.loop_configured) return null; + if (activeSession?.loop_enabled) { return { state: "running", label: "Auto", @@ -2249,8 +2289,7 @@ function App() { }; } // Loop is disabled — check the reason for stopped vs paused distinction - const entry = - PERIODIC_STOPPED_LABELS[activeSession?.periodic_stopped_reason]; + const entry = LOOP_STOPPED_LABELS[activeSession?.loop_stopped_reason]; if (entry && entry.kind === "stopped") { return { state: "stopped", @@ -2274,46 +2313,44 @@ function App() { })(); // Keep backwards-compat references used by cap-highlight logic below const headerStoppedReason = - (activeSession?.periodic_configured && - activeSession?.periodic_stopped_reason) || + (activeSession?.loop_configured && activeSession?.loop_stopped_reason) || null; - // Periodic "glance" badges shown in the subtitle for ALL periodic sessions + // Loop "glance" badges shown in the subtitle for ALL loop sessions // (running or stopped, schedule or onCompletion). - const headerPeriodicTrigger = activeSession?.periodic_trigger || null; - const headerIterationCount = activeSession?.periodic_iteration_count ?? 0; - const headerMaxIterations = activeSession?.periodic_max_iterations ?? 0; - const headerDelaySeconds = activeSession?.periodic_delay_seconds ?? 0; - const headerMaxDurationSecs = - activeSession?.periodic_max_duration_seconds ?? 0; + const headerLoopTrigger = activeSession?.loop_trigger || null; + const headerIterationCount = activeSession?.loop_iteration_count ?? 0; + const headerMaxIterations = activeSession?.loop_max_iterations ?? 0; + const headerDelaySeconds = activeSession?.loop_delay_seconds ?? 0; + const headerMaxDurationSecs = activeSession?.loop_max_duration_seconds ?? 0; // Trigger badge: "every 2h" for schedule, "after agent finishes [· +Ns]" for // onCompletion, "on task changes" for onTasks (mitto-oja.4). - const headerTriggerLabel = activeSession?.periodic_configured + const headerTriggerLabel = activeSession?.loop_configured ? computeHeaderTriggerLabel( - headerPeriodicTrigger, + headerLoopTrigger, headerDelaySeconds, - activeSession?.periodic_frequency, + activeSession?.loop_frequency, ) : null; // Run-count badge: "Run N of M" or "N run(s) · ∞". A compact variant ("N/M" or // "N·∞") is rendered alongside and CSS-swapped in on narrow screens (styles.css). - const headerRunCountLabel = activeSession?.periodic_configured + const headerRunCountLabel = activeSession?.loop_configured ? headerMaxIterations > 0 ? `Run ${headerIterationCount} of ${headerMaxIterations}` : `${headerIterationCount} run${headerIterationCount !== 1 ? "s" : ""} · ∞` : null; - const headerRunCountLabelShort = activeSession?.periodic_configured + const headerRunCountLabelShort = activeSession?.loop_configured ? headerMaxIterations > 0 ? `${headerIterationCount}/${headerMaxIterations}` : `${headerIterationCount}·∞` : null; // Max-time badge: "max 2h" etc; omitted when not set (0 means unlimited) const headerMaxTimeLabel = - activeSession?.periodic_configured && headerMaxDurationSecs > 0 - ? `max ${formatPeriodicMaxDuration(headerMaxDurationSecs)}` + activeSession?.loop_configured && headerMaxDurationSecs > 0 + ? `max ${formatLoopMaxDuration(headerMaxDurationSecs)}` : null; - // When a periodic loop is auto-stopped by a cap, soft-red highlight the + // When a loop loop is auto-stopped by a cap, soft-red highlight the // specific cap badge that was exceeded (and the Stopped badge) so the user // can see at a glance which limit was hit. const headerIterCapHit = @@ -2382,22 +2419,22 @@ function App() { const { contextMenu: headerMenu, - contextMenuItems: headerMenuItems, + promptGroupItems: headerPromptGroupItems, closeContextMenu: closeHeaderMenu, handleMenuButtonClick: handleHeaderMenuButtonClick, } = useConversationMenu({ session: activeSession, workingDir: headerWorkingDir, isArchived: headerIsArchived, - isPeriodicConfigured: headerIsPeriodic, + isLoopConfigured: headerIsLoop, isSpawned: headerIsSpawned, canArchive: headerCanArchive, archiveBlockedReason: headerArchiveBlockedReason, onRename: handleOpenSessionProperties, onDelete: handleDeleteSession, onArchive: handleArchiveSession, - onMakePeriodic: handleMakePeriodic, - onMakeNonPeriodic: handleMakeNonPeriodic, + onMakeLoop: handleMakeLoop, + onMakeNonLoop: handleMakeNonLoop, onFetchConversationPrompts: fetchConversationPromptsForSession, onSendPromptToConversation: handleSendPromptToConversation, onCopyConversation: activeSessionId ? handleCopyConversation : undefined, @@ -2405,6 +2442,291 @@ function App() { onFlushContext: activeSessionId ? handleFlushContext : undefined, }); + // Conversation toolbar items (rendered as a portable Toolbar pill below the + // title header). The actions that used to live inside the "…" conversation + // menu are now promoted to individual buttons (Copy, Flush, loop toggle, + // Archive, Delete), each gated exactly like its former menu entry. The + // hierarchical prompt groups (menus:conversation prompts) stay behind a + // single dropdown button that opens the shared ContextMenu (lazy-loaded). + // "Properties" is intentionally omitted — the Session-details side-panel + // toggle already covers it. The toggle carries an active state while the + // panel is open. + const conversationHasFlush = !!( + sessionInfo && sessionInfo.context_flush_command + ); + // Linked beads issue for the right-aligned toolbar button. The button is + // disabled when the conversation has no linked issue; the badge dot color + // reflects the fetched status (see headerBeadsStatus above). Dot colors use + // vivid 500-level tokens confirmed present in the precompiled tailwind.css. + const headerBeadsIssue = sessionInfo?.beads_issue || ""; + const headerBeadsDotColor = + { + open: "bg-green-500", + in_progress: "bg-blue-500", + blocked: "bg-red-500", + deferred: "bg-cyan-800", + closed: "bg-slate-400", + }[headerBeadsStatus] || "bg-slate-400"; + + // Per-folder shortcut buttons configured for this folder's `conversations` + // section (mirrors the `tasksList` shortcuts in BeadsView.js). Each button + // runs a `prompts`/`conversation`-menu prompt in the active conversation. A + // missing/renamed linked prompt renders disabled rather than erroring. + const [convShortcuts, setConvShortcuts] = useState([]); + const [convShortcutPromptMap, setConvShortcutPromptMap] = useState(new Map()); + + const loadConvShortcuts = useCallback( + async (isStale) => { + const wd = sessionInfo?.working_dir; + const sess = activeSession; + if (!wd || !activeSessionId) { + setConvShortcuts([]); + setConvShortcutPromptMap(new Map()); + return; + } + try { + // Merge global + folder shortcuts for the conversations section. Global + // buttons come first; folder buttons duplicating a global prompt drop out. + const [folderRes, globalRes] = await Promise.all([ + authFetch(endpoints.folders.shortcuts({ working_dir: wd })), + authFetch(endpoints.global.shortcuts()).catch(() => null), + ]); + const data = await folderRes.json().catch(() => ({})); + const globalData = globalRes + ? await globalRes.json().catch(() => ({})) + : {}; + const globalList = globalData?.sections?.conversations || []; + const folderList = data?.sections?.conversations || []; + const globalNames = new Set(globalList.map((s) => s.prompt)); + const list = [ + ...globalList, + ...folderList.filter((s) => !globalNames.has(s.prompt)), + ]; + if (isStale && isStale()) return; + setConvShortcuts(list); + if (list.length > 0) { + // Resolve against the union of the `prompts` (ChatInput dropup) and + // `conversation` menus so buttons configured from either list run. + const prompts = await fetchConversationPromptsForSession(sess, wd, [ + "prompts", + "conversation", + ]); + if (isStale && isStale()) return; + const map = new Map((prompts || []).map((p) => [p.name, p])); + setConvShortcutPromptMap(map); + } else { + setConvShortcutPromptMap(new Map()); + } + } catch (_err) { + if (isStale && isStale()) return; + setConvShortcuts([]); + setConvShortcutPromptMap(new Map()); + } + }, + [ + sessionInfo?.working_dir, + activeSessionId, + activeSession, + fetchConversationPromptsForSession, + ], + ); + + // Initial load (and reload on folder/conversation switch), with + // stale-fetch cancellation. + useEffect(() => { + let cancelled = false; + loadConvShortcuts(() => cancelled); + return () => { + cancelled = true; + }; + }, [loadConvShortcuts]); + + // Refresh shortcut buttons immediately when the Workspaces dialog saves new + // shortcuts for this folder, so no page reload is needed. + useEffect(() => { + const handler = (e) => { + const dir = e?.detail?.working_dir; + if (!dir || dir === sessionInfo?.working_dir) loadConvShortcuts(); + }; + // Global shortcuts changes affect every folder, so always refresh. + const globalHandler = () => loadConvShortcuts(); + window.addEventListener("mitto:folder_shortcuts_updated", handler); + window.addEventListener("mitto:global_shortcuts_updated", globalHandler); + return () => { + window.removeEventListener("mitto:folder_shortcuts_updated", handler); + window.removeEventListener( + "mitto:global_shortcuts_updated", + globalHandler, + ); + }; + }, [loadConvShortcuts, sessionInfo?.working_dir]); + + // Shortcut items for the conversation toolbar (mirrors shortcutItems in + // BeadsView.js). Each button runs its linked prompt in the active + // conversation via handleSendPromptToConversation. + const convShortcutItems = convShortcuts.map((sc, i) => { + const prompt = convShortcutPromptMap.get(sc.prompt); + const found = !!prompt; + const Icon = getPromptIconOrDefault(sc.icon || (prompt && prompt.icon)); + return { + kind: "button", + testId: `conversation-shortcut-btn-${i}`, + icon: html`<${Icon} className="w-4 h-4" />`, + tip: found ? sc.prompt : `Prompt "${sc.prompt}" not found`, + ariaLabel: found + ? `Run "${sc.prompt}"` + : `Prompt "${sc.prompt}" not found`, + disabled: !found, + onClick: () => + found && handleSendPromptToConversation(activeSession, prompt), + }; + }); + + const conversationToolbarItems = [ + ...(activeSessionId + ? [ + { + kind: "button", + testId: "header-conversation-prompts", + icon: html`<${LightningIcon} className="w-4 h-4" />`, + tip: "Conversation prompts", + ariaLabel: "Conversation prompts", + active: !!headerMenu, + onClick: handleHeaderMenuButtonClick, + }, + { + kind: "button", + testId: "header-copy-markdown", + icon: html`<${CopyIcon} className="w-4 h-4" />`, + tip: "Copy as Markdown", + ariaLabel: "Copy as Markdown", + onClick: handleCopyConversation, + }, + ...(conversationHasFlush + ? [ + { + kind: "button", + testId: "header-flush-context", + icon: html`<${BroomIcon} className="w-4 h-4" />`, + tip: `Flush context (${sessionInfo.context_flush_command})`, + ariaLabel: "Flush context", + onClick: () => handleFlushContext(activeSession), + }, + ] + : []), + ...(!headerIsLoop && !headerIsSpawned && !headerIsArchived + ? [ + { + kind: "button", + testId: "header-make-loop", + icon: html`<${LoopIcon} className="w-4 h-4" />`, + tip: "Loop", + ariaLabel: "Loop", + onClick: () => handleMakeLoop(activeSession), + }, + ] + : []), + ...(headerIsLoop && !headerIsSpawned + ? [ + { + kind: "button", + testId: "header-make-non-loop", + icon: html`<${LoopOffIcon} className="w-4 h-4" />`, + tip: "Unloop", + ariaLabel: "Unloop", + onClick: () => handleMakeNonLoop(activeSession), + }, + ] + : []), + // Per-folder configurable prompt shortcuts (conversations section). + ...(convShortcuts.length > 0 + ? [{ kind: "separator" }, ...convShortcutItems] + : []), + // Separator before the destructive group (archive + delete), keeping + // those two together but set apart from the actions above. + { kind: "separator" }, + ...(headerIsSpawned + ? [] + : [ + { + kind: "button", + testId: "header-archive", + icon: headerIsArchived + ? html`<${ArchiveFilledIcon} className="w-4 h-4" />` + : html`<${ArchiveIcon} className="w-4 h-4" />`, + tip: !headerCanArchive + ? headerArchiveBlockedReason + : headerIsArchived + ? "Unarchive" + : "Archive", + ariaLabel: headerIsArchived ? "Unarchive" : "Archive", + disabled: !headerCanArchive, + onClick: () => + handleArchiveSession(activeSession, !headerIsArchived), + }, + ]), + { + kind: "button", + testId: "header-delete", + icon: html`<${TrashIcon} className="w-4 h-4" />`, + tip: "Delete", + ariaLabel: "Delete", + danger: true, + onClick: () => handleDeleteSession(activeSession), + }, + ] + : []), + // Spacer pushes the right-aligned controls to the far right of the pill. + { kind: "spacer" }, + // Linked beads issue: opens the docked issue viewer for the conversation's + // associated issue. Disabled when there is no linked issue; a status badge + // dot appears once the (possibly slow) status fetch resolves. Sits just to + // the left of the Session-details toggle. + ...(activeSessionId + ? [ + { + kind: "button", + testId: "header-linked-issue", + icon: html`<span class="relative inline-flex"> + <${BeadsIcon} className="w-4 h-4" /> + ${headerBeadsIssue && headerBeadsStatus + ? html`<span + class="absolute -top-1 -right-1 w-2 h-2 rounded-full ${headerBeadsDotColor} border border-mitto-border-1" + ></span>` + : null} + </span>`, + tip: headerBeadsIssue + ? `Linked issue ${headerBeadsIssue}${ + headerBeadsStatus + ? ` (${headerBeadsStatus.replace(/_/g, " ")})` + : "" + }` + : "No linked issue", + ariaLabel: headerBeadsIssue + ? `Open linked issue ${headerBeadsIssue}` + : "No linked issue", + disabled: !headerBeadsIssue, + onClick: () => + handleOpenBeadsIssue( + headerBeadsIssue, + sessionInfo?.working_dir || "", + activeSessionId, + { reopenProperties: false }, + ), + }, + ] + : []), + { + kind: "button", + testId: "header-session-details", + icon: html`<${SidePanelIcon} className="w-4 h-4" />`, + tip: "Session details", + ariaLabel: "Session details", + active: showSidePanel, + onClick: handleToggleSidePanel, + }, + ]; + return html` <div class="drawer md:drawer-open h-screen-safe sidebar-shell"> <!-- Drawer toggle: Preact-controlled via showSidebar (mobile) + md:drawer-open (desktop) --> @@ -2566,6 +2888,14 @@ function App() { refreshWorkspaces(); invalidateConfigCache(); }} + onOpenPromptParamDialog=${(prompt, parameters, onSubmit, opts = {}) => + setPromptParamDialog({ + prompt, + parameters, + onSubmit, + initialValues: opts.initialValues, + hostSessionId: opts.hostSessionId, + })} /> <!-- Keyboard Shortcuts Dialog --> @@ -2574,16 +2904,16 @@ function App() { onClose=${() => setKeyboardShortcutsDialog({ isOpen: false })} /> - <!-- Periodic Schedule Dialog: opened when a periodic-declaring prompt is selected --> - <${PeriodicScheduleDialog} - isOpen=${periodicScheduleDialog !== null} - prompt=${periodicScheduleDialog?.prompt} + <!-- Loop Schedule Dialog: opened when a loop-declaring prompt is selected --> + <${LoopScheduleDialog} + isOpen=${loopScheduleDialog !== null} + prompt=${loopScheduleDialog?.prompt} onConfirm=${(schedule) => { - const { onSchedule } = periodicScheduleDialog || {}; - setPeriodicScheduleDialog(null); + const { onSchedule } = loopScheduleDialog || {}; + setLoopScheduleDialog(null); onSchedule?.(schedule); }} - onCancel=${() => setPeriodicScheduleDialog(null)} + onCancel=${() => setLoopScheduleDialog(null)} /> <!-- Prompt Parameter Dialog: opened when a menu (beads, conversation, or @@ -2610,123 +2940,120 @@ function App() { <!-- Main content area: beads view or conversation --> ${mainView === "beads" && beadsWorkingDir - ? html` - <div - class="flex-1 flex flex-col min-w-0 overflow-hidden bg-mitto-bg" - > - <${BeadsView} - workingDir=${beadsWorkingDir} - onClose=${() => setMainView("conversation")} - showToast=${showToast} - dismissToast=${dismissToast} - onFetchBeadsPrompts=${fetchBeadsPromptsForWorkspace} - onRunBeadsPrompt=${handleRunBeadsPrompt} - onFetchBeadsListPrompts=${fetchBeadsListPromptsForWorkspace} - onRunBeadsListPrompt=${handleRunBeadsListPrompt} - onShowSidebar=${() => setShowSidebar(true)} - onOpenConfig=${window.mittoIsExternal === true - ? undefined - : () => - handleShowWorkspacesForFolder( - beadsWorkingDir, - "beads", - )} - issueSessionMap=${beadsIssueSessionMap} - issueStreamingSet=${beadsIssueStreamingSet} - onOpenConversation=${handleSelectSession} - onLaunchPrompt=${handleBeadsLaunchPrompt} - initialCreateNonce=${beadsCreateNonce} - initialRefreshNonce=${beadsRefreshNonce} - initialCleanupNonce=${beadsCleanupNonce} - /> - </div> - ` - : html` + ? html` + <div + class="flex-1 flex flex-col min-w-0 overflow-hidden bg-mitto-bg" + > + <${BeadsView} + workingDir=${beadsWorkingDir} + onClose=${() => setMainView("conversation")} + showToast=${showToast} + dismissToast=${dismissToast} + onFetchBeadsPrompts=${fetchBeadsPromptsForWorkspace} + onRunBeadsPrompt=${handleRunBeadsPrompt} + onFetchBeadsListPrompts=${fetchBeadsListPromptsForWorkspace} + onRunBeadsListPrompt=${handleRunBeadsListPrompt} + onShowSidebar=${() => setShowSidebar(true)} + onOpenConfig=${window.mittoIsExternal === true + ? undefined + : () => + handleShowWorkspacesForFolder(beadsWorkingDir, "beads")} + issueSessionMap=${beadsIssueSessionMap} + issueStreamingSet=${beadsIssueStreamingSet} + onOpenConversation=${handleSelectSession} + onLaunchPrompt=${handleBeadsLaunchPrompt} + initialCreateNonce=${beadsCreateNonce} + initialRefreshNonce=${beadsRefreshNonce} + initialCleanupNonce=${beadsCleanupNonce} + /> + </div> + ` + : html` + <div + ref=${mainContentRef} + class="flex-1 flex flex-col min-w-0 overflow-hidden" + > + <!-- Header --> <div - ref=${mainContentRef} - class="flex-1 flex flex-col min-w-0 overflow-hidden" + class="relative pt-4 px-4 pb-2 bg-mitto-sidebar flex items-center gap-3 shrink-0" > - <!-- Header --> - <div - class="relative p-4 bg-mitto-sidebar border-b border-mitto-border-1 flex items-center gap-3 shrink-0" + <${Tooltip} + tip="Show conversations" + placement="bottom" + className="md:hidden" > - <${Tooltip} - tip="Show conversations" - placement="bottom" - className="md:hidden" + <button + class="p-2 hover:bg-mitto-surface-hover rounded-lg transition-colors" + onClick=${() => setShowSidebar(true)} + aria-label="Show conversations" + > + <${MenuIcon} className="w-6 h-6" /> + </button> + <//> + <div class="flex-1 min-w-0 flex flex-col justify-center"> + <h1 + class="font-bold text-xl truncate no-underline tooltip tooltip-bottom ${!activeSessionId + ? "text-mitto-text-muted" + : connected + ? "" + : "text-mitto-text-muted"}" + data-tip=${activeSessionId + ? sessionInfo?.name || "New conversation" + : ""} + aria-label=${activeSessionId + ? sessionInfo?.name || "New conversation" + : ""} + > + ${activeSessionId + ? sessionInfo?.name || "New conversation" + : "No Active Session"} + </h1> + ${activeSessionId && + (headerAcpServer || + headerNextScheduledAt || + headerLoopState || + activeSession?.loop_configured) && + html`<div + class="text-xs text-mitto-text-muted truncate flex items-center gap-2 min-w-0" + data-testid="conversation-header-subtitle" > - <button - class="p-2 hover:bg-mitto-surface-hover rounded-lg transition-colors" - onClick=${() => setShowSidebar(true)} - aria-label="Show conversations" - > - <${MenuIcon} className="w-6 h-6" /> - </button> - <//> - <div class="flex-1 min-w-0 flex flex-col justify-center"> - <h1 - class="font-bold text-xl truncate no-underline tooltip tooltip-bottom ${!activeSessionId - ? "text-mitto-text-muted" - : connected - ? "" - : "text-mitto-text-muted"}" - data-tip=${activeSessionId - ? sessionInfo?.name || "New conversation" - : ""} - aria-label=${activeSessionId - ? sessionInfo?.name || "New conversation" - : ""} - > - ${activeSessionId - ? sessionInfo?.name || "New conversation" - : "No Active Session"} - </h1> - ${activeSessionId && - (headerAcpServer || - headerNextScheduledAt || - headerPeriodicState || - activeSession?.periodic_configured) && - html`<div - class="text-xs text-mitto-text-muted truncate flex items-center gap-2 min-w-0" - data-testid="conversation-header-subtitle" - > - ${headerPeriodicState && - html`<span - class="badge badge-sm ${headerPeriodicState.badgeClass} whitespace-nowrap inline-flex items-center gap-1" - data-testid="periodic-status-pill" - title=${headerPeriodicState.state === "running" - ? "Periodic loop is iterating" - : (activeSession?.periodic_stopped_reason || "") + - (activeSession?.stopped_at - ? " · " + - new Date( - activeSession.stopped_at, - ).toLocaleString() - : "")} - >${headerPeriodicState.state === "running" - ? html`<${PeriodicIcon} className="w-3 h-3" />` - : headerPeriodicState.state === "stopped" - ? html`<${StopIcon} className="w-3 h-3" />` - : html`<${PauseFilledIcon} - className="w-3 h-3" - />`}<span class="badge-collapse-label" - >${headerPeriodicState.label}</span - ></span - >`} - ${headerAcpServer && - html`<span class="truncate min-w-0" - >${headerAcpServer}</span - >`} - ${headerTriggerLabel && - html`<${Fragment}> + ${headerLoopState && + html`<span + class="badge badge-sm ${headerLoopState.badgeClass} whitespace-nowrap inline-flex items-center gap-1" + data-testid="loop-status-pill" + title=${headerLoopState.state === "running" + ? "Loop loop is iterating" + : (activeSession?.loop_stopped_reason || "") + + (activeSession?.stopped_at + ? " · " + + new Date( + activeSession.stopped_at, + ).toLocaleString() + : "")} + >${headerLoopState.state === "running" + ? html`<${LoopIcon} className="w-3 h-3" />` + : headerLoopState.state === "stopped" + ? html`<${StopIcon} className="w-3 h-3" />` + : html`<${PauseFilledIcon} + className="w-3 h-3" + />`}<span class="badge-collapse-label" + >${headerLoopState.label}</span + ></span + >`} + ${headerAcpServer && + html`<span class="truncate min-w-0" + >${headerAcpServer}</span + >`} + ${headerTriggerLabel && + html`<${Fragment}> <span class="opacity-60">·</span> <span class="badge badge-sm badge-ghost whitespace-nowrap inline-flex items-center gap-1" - data-testid="periodic-trigger-badge" + data-testid="loop-trigger-badge" >${ - headerPeriodicTrigger === "onCompletion" + headerLoopTrigger === "onCompletion" ? html`<${CheckIcon} className="w-3 h-3" />` - : headerPeriodicTrigger === "onTasks" + : headerLoopTrigger === "onTasks" ? html`<${BeadsIcon} className="w-3 h-3" />` : html`<${ClockIcon} className="w-3 h-3" />` }<span @@ -2734,12 +3061,12 @@ function App() { >${headerTriggerLabel}</span ></span> </${Fragment}>`} - ${headerRunCountLabel !== null && - html`<${Fragment}> + ${headerRunCountLabel !== null && + html`<${Fragment}> <span class="opacity-60">·</span> <span class="badge badge-sm ${headerRunCountBadgeClass} whitespace-nowrap" - data-testid="periodic-run-count-badge" + data-testid="loop-run-count-badge" title=${ headerIterCapHit ? "Reached the maximum number of iterations" @@ -2749,20 +3076,20 @@ function App() { ><span class="runcount-short">${headerRunCountLabelShort}</span ></span> </${Fragment}>`} - ${headerMaxTimeLabel && - html`<${Fragment}> + ${headerMaxTimeLabel && + html`<${Fragment}> <span class="opacity-60">·</span> <span class="badge badge-sm ${headerMaxTimeBadgeClass} whitespace-nowrap" - data-testid="periodic-max-time-badge" + data-testid="loop-max-time-badge" title=${ headerTimeCapHit ? "Reached the maximum run time" : null } >${headerMaxTimeLabel}</span> </${Fragment}>`} - ${headerPeriodicState?.state === "running" && - headerNextScheduledAt && - html`<${Fragment}> + ${headerLoopState?.state === "running" && + headerNextScheduledAt && + html`<${Fragment}> ${ headerAcpServer || headerTriggerLabel || @@ -2773,271 +3100,244 @@ function App() { } <${CountdownDisplay} targetIso=${headerNextScheduledAt} - unit=${headerPeriodicUnit} + unit=${headerLoopUnit} active=${true} className="whitespace-nowrap" /> </${Fragment}>`} - </div>`} - </div> - <div class="ml-auto flex items-center gap-2"> - <!-- Conversation actions menu (mirrors the sidebar row menu) --> - ${activeSessionId - ? html` - <${Tooltip} - tip="Conversation actions" - placement="bottom" - portal - > - <button - type="button" - onClick=${handleHeaderMenuButtonClick} - class="p-1.5 rounded hover:bg-mitto-surface-hover transition-colors text-mitto-text-secondary hover:text-mitto-text-200" - aria-label="Conversation actions" - data-testid="header-conversation-menu" - > - <${EllipsisIcon} className="w-4 h-4" /> - </button> - <//> - ` - : null} - <!-- Unified side panel toggle --> - <${Tooltip} - tip="Session details" - placement="bottom" - portal - > - <button - onClick=${handleToggleSidePanel} - class="p-1.5 rounded hover:bg-mitto-surface-hover transition-colors ${showSidePanel - ? "bg-mitto-surface-3 text-mitto-accent" - : "text-mitto-text-secondary hover:text-mitto-text-200"}" - aria-label="Session details" - > - <${SidePanelIcon} className="w-4 h-4" /> - </button> - <//> - </div> - </div> - ${headerMenu && - html` - <${ContextMenu} - x=${headerMenu.x} - y=${headerMenu.y} - items=${headerMenuItems} - onClose=${closeHeaderMenu} - /> - `} - - <!-- Messages wrapper (for positioning scroll-to-bottom button and plan panel) --> - <div class="flex-1 relative min-h-0 overflow-hidden"> - <!-- Agent Plan Panel (floating overlay at top) --> - <${AgentPlanPanel} - isOpen=${showPlanPanel} - onClose=${handleClosePlanPanel} - onToggle=${handleTogglePlanPanel} - entries=${planEntries} - userPinned=${planUserPinned} - /> - <!-- Agent Plan Indicator (shown when panel is collapsed but has entries) --> - ${!showPlanPanel && - planEntries.length > 0 && - html` - <div - class="absolute top-2 left-1/2 transform -translate-x-1/2 z-10" - > - <${AgentPlanIndicator} - onClick=${handleTogglePlanPanel} - entries=${planEntries} - /> - </div> - `} - <!-- Messages list (scrollable container + scroll-to-bottom button) --> - <${MessageList} - displayMessages=${displayMessages} - messages=${messages} - hasMoreMessages=${hasMoreMessages} - hasReachedLimit=${hasReachedLimit} - isLoadingMore=${isLoadingMore} - isStreaming=${isStreaming} - agentWorking=${agentWorking} - onLoadMore=${handleLoadMore} - onScrollToBottom=${scrollToBottom} - isUserAtBottom=${isUserAtBottom} - hasNewMessages=${hasNewMessages} - sentinelRef=${sentinelRef} - onRetry=${handleSendPrompt} - activeSessionId=${activeSessionId} - swipeDirection=${swipeDirection} - swipeArrow=${swipeArrow} - connected=${connected} - sessionInfo=${sessionInfo} - workspaces=${workspaces} - messagesContainerRef=${messagesContainerRef} - /> + </div>`} </div> - <!-- End of messages wrapper --> + </div> - <!-- Persistent MCP-unavailable banner (global; survives reconnects). --> - ${mcpStatus && - mcpStatus.available === false && + <!-- Conversation toolbar: the portable Toolbar pill, sitting + right below the title header and vertically aligned with + the sidebar toolbar (both live in a px-3 wrapper directly + under their p-4 header). Holds the actions that used to + sit top-right in the header: the "…" conversation-actions + menu and the Session-details panel toggle. --> + <div + class="px-3 pb-2 shrink-0" + data-testid="conversation-toolbar" + > + <${Toolbar} + variant="block" + surface="bg-mitto-surface-3" + ariaLabel="Conversation actions" + items=${conversationToolbarItems} + /> + </div> + ${headerMenu && + html` + <${ContextMenu} + x=${headerMenu.x} + y=${headerMenu.y} + items=${headerPromptGroupItems} + onClose=${closeHeaderMenu} + /> + `} + + <!-- Messages wrapper (for positioning scroll-to-bottom button and plan panel) --> + <div class="flex-1 relative min-h-0 overflow-hidden"> + <!-- Agent Plan Panel (floating overlay at top) --> + <${AgentPlanPanel} + isOpen=${showPlanPanel} + onClose=${handleClosePlanPanel} + onToggle=${handleTogglePlanPanel} + entries=${planEntries} + userPinned=${planUserPinned} + /> + <!-- Agent Plan Indicator (shown when panel is collapsed but has entries) --> + ${!showPlanPanel && + planEntries.length > 0 && html` - <div class="flex justify-center my-2"> - <div - role="alert" - class="alert alert-warning max-w-2xl text-sm py-2" - > - <span> - MCP server - unavailable${mcpStatus.reason === "port_in_use" - ? ` — port ${mcpStatus.port} is already in use (another Mitto instance may be running)` - : mcpStatus.port - ? ` (port ${mcpStatus.port})` - : ""}. - Mitto continues without MCP tools. - </span> - </div> + <div + class="absolute top-2 left-1/2 transform -translate-x-1/2 z-10" + > + <${AgentPlanIndicator} + onClick=${handleTogglePlanPanel} + entries=${planEntries} + /> </div> `} - - <!-- ACP reconnecting banner (shown when ACP not ready and there are messages) --> - <!-- Only show when global WS is connected — during shutdown, WS disconnects and we don't want to show this --> - <!-- Skip for GC-suspended sessions — they are intentionally paused, not reconnecting --> - ${connected && - activeSessionId && - sessionInfo && - !sessionInfo.acp_ready && - !sessionInfo.archived && - !sessionInfo.gc_suspended && - messages.length > 0 && - html` - <div class="flex items-center justify-center py-2 text-sm"> - <span - class="skeleton skeleton-text skeleton-text-readable" - >Establishing ACP session...</span - > + <!-- Messages list (scrollable container + scroll-to-bottom button) --> + <${MessageList} + displayMessages=${displayMessages} + messages=${messages} + hasMoreMessages=${hasMoreMessages} + hasReachedLimit=${hasReachedLimit} + isLoadingMore=${isLoadingMore} + isStreaming=${isStreaming} + agentWorking=${agentWorking} + onLoadMore=${handleLoadMore} + onScrollToBottom=${scrollToBottom} + isUserAtBottom=${isUserAtBottom} + hasNewMessages=${hasNewMessages} + sentinelRef=${sentinelRef} + onRetry=${handleSendPrompt} + activeSessionId=${activeSessionId} + swipeDirection=${swipeDirection} + swipeArrow=${swipeArrow} + connected=${connected} + sessionInfo=${sessionInfo} + workspaces=${workspaces} + messagesContainerRef=${messagesContainerRef} + /> + </div> + <!-- End of messages wrapper --> + + <!-- Persistent MCP-unavailable banner (global; survives reconnects). --> + ${mcpStatus && + mcpStatus.available === false && + html` + <div class="flex justify-center my-2"> + <div + role="alert" + class="alert alert-warning max-w-2xl text-sm py-2" + > + <span> + MCP server + unavailable${mcpStatus.reason === "port_in_use" + ? ` — port ${mcpStatus.port} is already in use (another Mitto instance may be running)` + : mcpStatus.port + ? ` (port ${mcpStatus.port})` + : ""}. + Mitto continues without MCP tools. + </span> </div> - `} - - <!-- Archive reason banner (shown when conversation is archived and has a reason) --> - <!-- Uses the same balloon style as system messages for visual consistency --> - ${sessionInfo?.archived && - sessionInfo?.archive_reason && - html` - <div class="flex justify-center mb-3"> - <div - class="text-xs text-mitto-text-muted bg-mitto-surface-2/50 px-3 py-1 rounded-full" - > - ${getArchiveReasonText( - sessionInfo.archive_reason, - sessionInfo.archived_at, - )} - </div> + </div> + `} + + <!-- ACP reconnecting banner (shown when ACP not ready and there are messages) --> + <!-- Only show when global WS is connected — during shutdown, WS disconnects and we don't want to show this --> + <!-- Skip for GC-suspended sessions — they are intentionally paused, not reconnecting --> + ${connected && + activeSessionId && + sessionInfo && + !sessionInfo.acp_ready && + !sessionInfo.archived && + !sessionInfo.gc_suspended && + messages.length > 0 && + html` + <div class="flex items-center justify-center py-2 text-sm"> + <span class="skeleton skeleton-text skeleton-text-readable" + >Establishing ACP session...</span + > + </div> + `} + + <!-- Archive reason banner (shown when conversation is archived and has a reason) --> + <!-- Uses the same balloon style as system messages for visual consistency --> + ${sessionInfo?.archived && + sessionInfo?.archive_reason && + html` + <div class="flex justify-center mb-3"> + <div + class="text-xs text-mitto-text-muted bg-mitto-surface-2/50 px-3 py-1 rounded-full" + > + ${getArchiveReasonText( + sessionInfo.archive_reason, + sessionInfo.archived_at, + )} </div> - `} + </div> + `} + + <!-- Input Area Container (relative for QueueDropdown positioning) --> + <div class="relative shrink-0"> + <!-- Queue Dropdown (floating overlay above input) --> + <${QueueDropdown} + isOpen=${showQueueDropdown} + onClose=${handleCloseQueueDropdown} + messages=${queueMessages} + onDelete=${handleDeleteQueueMessage} + onMove=${handleMoveQueueMessage} + isDeleting=${isDeletingQueueMessage} + isMoving=${isMovingQueueMessage} + queueLength=${queueLength} + maxSize=${queueConfig.max_size} + /> - <!-- Input Area Container (relative for QueueDropdown positioning) --> - <div class="relative shrink-0"> - <!-- Queue Dropdown (floating overlay above input) --> - <${QueueDropdown} - isOpen=${showQueueDropdown} - onClose=${handleCloseQueueDropdown} - messages=${queueMessages} - onDelete=${handleDeleteQueueMessage} - onMove=${handleMoveQueueMessage} - isDeleting=${isDeletingQueueMessage} - isMoving=${isMovingQueueMessage} - queueLength=${queueLength} - maxSize=${queueConfig.max_size} - /> - - <!-- Input --> - <${ChatInput} - onSend=${handleSendPrompt} - onCancel=${cancelPrompt} - disabled=${!connected || !activeSessionId} - isStreaming=${isStreaming} - isRunning=${isRunning} - isReadOnly=${sessionInfo?.isReadOnly} - isArchived=${sessionInfo?.archived || false} - predefinedPrompts=${predefinedPrompts} - periodicPrompts=${periodicPrompts} - hasBeadsWorkspace=${hasBeadsWorkspace} - inputRef=${chatInputRef} - noSession=${!activeSessionId} - sessionId=${activeSessionId} - draft=${currentDraft} - onDraftChange=${updateDraft} - sessionDraftsRef=${sessionDraftsRef} - onPromptsOpen=${handlePromptsOpen} - onConfigurePrompts=${!configReadonly && - sessionInfo?.working_dir - ? () => - handleShowWorkspacesForFolder( - sessionInfo.working_dir, - "prompts", - ) - : undefined} - queueLength=${queueLength} - queueConfig=${queueConfig} - onAddToQueue=${handleAddToQueue} - onToggleQueue=${handleToggleQueueDropdown} - showQueueDropdown=${showQueueDropdown} - actionButtons=${actionButtons} - availableCommands=${availableCommands} - periodicConfigured=${sessionInfo?.periodic_configured || - false} - onPeriodicPrompt=${(prompt, opts) => - handleSendPromptToConversation( - activeSession, - prompt, - opts, - )} - onOpenPromptParamDialog=${( + <!-- Input --> + <${ChatInput} + onSend=${handleSendPrompt} + onCancel=${cancelPrompt} + disabled=${!connected || !activeSessionId} + isStreaming=${isStreaming} + isRunning=${isRunning} + isReadOnly=${sessionInfo?.isReadOnly} + isArchived=${sessionInfo?.archived || false} + predefinedPrompts=${predefinedPrompts} + loopPrompts=${loopPrompts} + hasBeadsWorkspace=${hasBeadsWorkspace} + inputRef=${chatInputRef} + noSession=${!activeSessionId} + sessionId=${activeSessionId} + draft=${currentDraft} + onDraftChange=${updateDraft} + sessionDraftsRef=${sessionDraftsRef} + onPromptsOpen=${handlePromptsOpen} + onConfigurePrompts=${!configReadonly && + sessionInfo?.working_dir + ? () => + handleShowWorkspacesForFolder( + sessionInfo.working_dir, + "prompts", + ) + : undefined} + queueLength=${queueLength} + queueConfig=${queueConfig} + onAddToQueue=${handleAddToQueue} + onToggleQueue=${handleToggleQueueDropdown} + showQueueDropdown=${showQueueDropdown} + actionButtons=${actionButtons} + availableCommands=${availableCommands} + loopConfigured=${sessionInfo?.loop_configured || false} + onLoopPrompt=${(prompt, opts) => + handleSendPromptToConversation( + activeSession, + prompt, + opts, + )} + onOpenPromptParamDialog=${( + prompt, + parameters, + onSubmit, + opts = {}, + ) => + setPromptParamDialog({ prompt, parameters, onSubmit, - opts = {}, - ) => - setPromptParamDialog({ - prompt, - parameters, - onSubmit, - initialValues: opts.initialValues, - hostSessionId: opts.hostSessionId, - })} - agentSupportsImages=${sessionInfo?.agent_supports_images ?? - false} - acpReady=${connected && sessionInfo - ? (sessionInfo.acp_ready ?? true) - : true} - gcSuspended=${sessionInfo?.gc_suspended || false} - onResume=${() => ensureResumed(activeSessionId)} - activeUIPrompt=${activeUIPrompt} - onUIPromptAnswer=${( + initialValues: opts.initialValues, + hostSessionId: opts.hostSessionId, + })} + agentSupportsImages=${sessionInfo?.agent_supports_images ?? + false} + acpReady=${connected && sessionInfo + ? (sessionInfo.acp_ready ?? true) + : true} + gcSuspended=${sessionInfo?.gc_suspended || false} + onResume=${() => ensureResumed(activeSessionId)} + activeUIPrompt=${activeUIPrompt} + onUIPromptAnswer=${(requestId, optionId, label, freeText) => + sendUIPromptAnswer( + activeSessionId, requestId, optionId, label, freeText, - ) => - sendUIPromptAnswer( - activeSessionId, - requestId, - optionId, - label, - freeText, - )} - workingDir=${sessionInfo?.working_dir || ""} - sendKeyMode=${sendKeyMode} - configOptions=${configOptions} - onSetConfigOption=${setConfigOption} - modelProfiles=${modelProfiles} - contextUsage=${sessionInfo?.context_usage ?? null} - tokenUsage=${sessionInfo?.usage ?? null} - /> - </div> + )} + workingDir=${sessionInfo?.working_dir || ""} + sendKeyMode=${sendKeyMode} + configOptions=${configOptions} + onSetConfigOption=${setConfigOption} + modelProfiles=${modelProfiles} + contextUsage=${sessionInfo?.context_usage ?? null} + tokenUsage=${sessionInfo?.usage ?? null} + /> </div> - `} + </div> + `} <!-- Unified Session Panel: docks to the right edge of drawer-content as a confined overlay (Drawer dock mode + styles.css), so it does NOT @@ -3112,9 +3412,13 @@ function App() { onClick=${() => setShowSidebar(false)} ></label> <!-- Panel: resizable on desktop (sidebarWidth), fixed w-80 class provides - fallback but inline style takes precedence when set via resize handle. --> + fallback but inline style takes precedence when set via resize handle. + Uses a soft grey surface (surface-2) rather than the white sidebar + tone so the conversation rail reads as a distinct panel; the sidebar + toolbar pill is bumped to the elevated surface-3 so it "floats" + above this panel (see SessionList.js Toolbar surface prop). --> <div - class="bg-mitto-sidebar border-r border-mitto-border-1 h-full relative" + class="bg-mitto-surface-2 border-r border-mitto-border-1 h-full relative" style="width: ${sidebarWidth}px;" > <${SessionList} @@ -3156,8 +3460,8 @@ function App() { queueLength=${queueLength} onFetchConversationPrompts=${fetchConversationPromptsForSession} onSendPromptToConversation=${handleSendPromptToConversation} - onMakePeriodic=${handleMakePeriodic} - onMakeNonPeriodic=${handleMakeNonPeriodic} + onMakeLoop=${handleMakeLoop} + onMakeNonLoop=${handleMakeNonLoop} isCreatingSession=${isCreatingSession} creatingWorkingDirs=${creatingWorkingDirs} /> diff --git a/web/static/components/BeadsView.js b/web/static/components/BeadsView.js index 61dba5fa..2019b9c4 100644 --- a/web/static/components/BeadsView.js +++ b/web/static/components/BeadsView.js @@ -47,9 +47,10 @@ import { SortIcon, CopyIcon, getPromptIconOrDefault, - PeriodicIcon, + LoopIcon, LinkIcon, ListIcon, + LightningIcon, BoldIcon, ItalicIcon, StrikethroughIcon, @@ -60,9 +61,9 @@ import { QuoteIcon, } from "./Icons.js"; import { - promptPeriodicMode, - promptPeriodicIsToggleable, - promptPeriodicDefaultOn, + promptLoopMode, + promptLoopIsToggleable, + promptLoopDefaultOn, } from "../utils/prompts.js"; import { CodeEditorField } from "./CodeEditorField.js"; import { @@ -73,6 +74,7 @@ import { import { ConfirmDialog } from "./ConfirmDialog.js"; import { Drawer } from "./Drawer.js"; import { Tooltip } from "./Tooltip.js"; +import { Toolbar } from "./Toolbar.js"; import { usePullToRefresh } from "../hooks/usePullToRefresh.js"; import { useSwipeToAction } from "../hooks/index.js"; @@ -417,6 +419,13 @@ export function BeadsDetailPanel({ // ~40rem side panel over the conversation; the toggle still lets the user // expand it to fill the area. const [fullscreen, setFullscreen] = useState(!!initialFullscreen); + // Shortcut buttons configured for this folder's beadsIssue section (mirrors + // the list toolbar's tasksList shortcuts, but keyed to the open issue). + const [issueShortcuts, setIssueShortcuts] = useState([]); + // Map from prompt name → prompt object, resolved from the beadsIssues menu. + const [issueShortcutPromptMap, setIssueShortcutPromptMap] = useState( + new Map(), + ); // Phone detection drives the panel width. We deliberately use the user agent // (not a viewport-width breakpoint like Tailwind's `md:`): the native macOS // app runs in a WKWebView that reports a Macintosh UA but can have a narrow @@ -516,6 +525,18 @@ export function BeadsDetailPanel({ const [depsBusy, setDepsBusy] = useState(false); const [newDepType, setNewDepType] = useState("blocks"); const [newDepId, setNewDepId] = useState(""); + // Labels shown in view mode. `labels` mirrors the issue's current labels + // (refreshed via fetchDeps); `labelsBusy` gates add/remove requests; + // `newLabel` backs the add-label input; `allLabels` holds the workspace-wide + // label suggestions rendered in the add-label datalist. + const [labels, setLabels] = useState([]); + const [labelsBusy, setLabelsBusy] = useState(false); + const [newLabel, setNewLabel] = useState(""); + const [allLabels, setAllLabels] = useState([]); + // `addingLabel` toggles the inline add-label input (revealed by the "+" + // button); `labelInputRef` lets us focus it as soon as it opens. + const [addingLabel, setAddingLabel] = useState(false); + const labelInputRef = useRef(null); const [comments, setComments] = useState([]); const [notes, setNotes] = useState(""); @@ -834,6 +855,87 @@ export function BeadsDetailPanel({ return () => document.removeEventListener("mousedown", onDocMouseDown); }, [isOpen, panelMenu, handleClose]); + // Load per-folder beadsIssue-section shortcut buttons for the detail toolbar. + // Mirrors BeadsView's tasksList loader: fetch section entries, then resolve + // each entry's prompt name against the beadsIssues menu via onFetchPrompts. + // `isStale` lets the mount effect below cancel a stale in-flight fetch when + // the folder changes mid-request. + const loadIssueShortcuts = useCallback( + async (isStale) => { + if (!workingDir) { + setIssueShortcuts([]); + setIssueShortcutPromptMap(new Map()); + return; + } + try { + // Merge global + folder shortcuts for the beadsIssue section. Global + // buttons come first; folder buttons duplicating a global prompt drop out. + const [folderRes, globalRes] = await Promise.all([ + authFetch(endpoints.folders.shortcuts({ working_dir: workingDir })), + authFetch(endpoints.global.shortcuts()).catch(() => null), + ]); + const cfg = await folderRes.json().catch(() => ({})); + const globalData = globalRes + ? await globalRes.json().catch(() => ({})) + : {}; + const globalList = globalData?.sections?.beadsIssue || []; + const folderList = cfg?.sections?.beadsIssue || []; + const globalNames = new Set(globalList.map((s) => s.prompt)); + const list = [ + ...globalList, + ...folderList.filter((s) => !globalNames.has(s.prompt)), + ]; + if (isStale && isStale()) return; + setIssueShortcuts(list); + if (list.length > 0 && onFetchPrompts) { + const prompts = await onFetchPrompts(workingDir); + if (isStale && isStale()) return; + const map = new Map((prompts || []).map((p) => [p.name, p])); + setIssueShortcutPromptMap(map); + } else { + setIssueShortcutPromptMap(new Map()); + } + } catch (_err) { + if (isStale && isStale()) return; + setIssueShortcuts([]); + setIssueShortcutPromptMap(new Map()); + } + }, + [workingDir, onFetchPrompts], + ); + + // Initial load (and reload on folder switch), with stale-fetch cancellation. + useEffect(() => { + let cancelled = false; + loadIssueShortcuts(() => cancelled); + return () => { + cancelled = true; + }; + }, [loadIssueShortcuts]); + + // Refresh shortcut buttons immediately when the Workspaces dialog saves new + // shortcuts for this folder, so no page reload is needed. + useEffect(() => { + const handler = (e) => { + const dir = e?.detail?.working_dir; + if (!dir || dir === workingDir) loadIssueShortcuts(); + }; + // Global shortcuts changes affect every folder, so always refresh. + const globalHandler = () => loadIssueShortcuts(); + window.addEventListener("mitto:folder_shortcuts_updated", handler); + window.addEventListener("mitto:global_shortcuts_updated", globalHandler); + return () => { + window.removeEventListener("mitto:folder_shortcuts_updated", handler); + window.removeEventListener( + "mitto:global_shortcuts_updated", + globalHandler, + ); + }; + }, [loadIssueShortcuts, workingDir]); + + // The panel context menu is now prompts-only: the former Close/Defer/Delete + // menu items are surfaced as direct buttons in the header Toolbar + // (headerToolbarItems below). The toolbar's "Run a prompt" button opens it. const panelMenuItems = useMemo(() => { if (!data) return []; const promptGroupItems = buildPromptGroupMenuItems( @@ -844,41 +946,106 @@ export function BeadsDetailPanel({ }, html`<${PlusIcon} />`, ); + if (promptGroupItems.length === 0) { + return [{ label: "No prompts available", disabled: true }]; + } + return promptGroupItems; + }, [data, prompts, onRunPrompt]); + + // Header action toolbar (view mode). Replaces the former "…" overflow menu and + // standalone fullscreen button: a prompts trigger, Close/Reopen, Defer/Undefer, + // a destructive Delete set apart by a separator, then the per-folder shortcut + // buttons (separated), a spacer, then fullscreen at the right edge. + const headerToolbarItems = useMemo(() => { + if (!data) return []; + // Per-folder shortcut buttons (beadsIssue section). A missing linked prompt + // is shown greyed/disabled, mirroring the list toolbar's tasksList shortcuts. + const issueShortcutItems = issueShortcuts.map((sc, i) => { + const prompt = issueShortcutPromptMap.get(sc.prompt); + const found = !!prompt; + const Icon = getPromptIconOrDefault(sc.icon || (prompt && prompt.icon)); + return { + kind: "button", + testId: `beads-issue-shortcut-btn-${i}`, + icon: html`<${Icon} className="w-4 h-4" />`, + tip: found ? sc.prompt : `Prompt "${sc.prompt}" not found`, + ariaLabel: found + ? `Run "${sc.prompt}"` + : `Prompt "${sc.prompt}" not found`, + disabled: !found, + onClick: () => found && onRunPrompt && onRunPrompt(prompt, data), + }; + }); return [ - ...promptGroupItems, { - label: data.status === "closed" ? "Reopen" : "Close", + kind: "button", + testId: "beads-panel-prompts", + icon: html`<${LightningIcon} className="w-4 h-4" />`, + tip: "Run a prompt", + ariaLabel: "Run a prompt", + onClick: openPanelMenu, + }, + { kind: "separator" }, + { + kind: "button", + testId: "beads-panel-status", icon: data.status === "closed" - ? html`<${RefreshIcon} />` - : html`<${CheckIcon} />`, - onClick: () => onToggleStatus && onToggleStatus(data), + ? html`<${RefreshIcon} className="w-4 h-4" />` + : html`<${CheckIcon} className="w-4 h-4" />`, + tip: data.status === "closed" ? "Reopen" : "Close", + ariaLabel: data.status === "closed" ? "Reopen" : "Close", disabled: statusBusy, + onClick: () => onToggleStatus && onToggleStatus(data), }, { - label: data.status === "deferred" ? "Undefer" : "Defer", + kind: "button", + testId: "beads-panel-defer", icon: data.status === "deferred" - ? html`<${SunIcon} />` - : html`<${MoonIcon} />`, - onClick: () => onToggleDefer && onToggleDefer(data), + ? html`<${SunIcon} className="w-4 h-4" />` + : html`<${MoonIcon} className="w-4 h-4" />`, + tip: data.status === "deferred" ? "Undefer" : "Defer", + ariaLabel: data.status === "deferred" ? "Undefer" : "Defer", disabled: statusBusy, + onClick: () => onToggleDefer && onToggleDefer(data), }, + { kind: "separator" }, { - label: "Delete", - icon: html`<${TrashIcon} />`, - onClick: () => onDelete && onDelete(data), + kind: "button", + testId: "beads-panel-delete", + icon: html`<${TrashIcon} className="w-4 h-4" />`, + tip: "Delete", + ariaLabel: "Delete", danger: true, + onClick: () => onDelete && onDelete(data), + }, + ...(issueShortcuts.length > 0 + ? [{ kind: "separator" }, ...issueShortcutItems] + : []), + { kind: "spacer" }, + { + kind: "button", + testId: "beads-panel-fullscreen", + icon: fullscreen + ? html`<${CollapseIcon} className="w-4 h-4" />` + : html`<${ExpandIcon} className="w-4 h-4" />`, + tip: fullscreen ? "Exit fullscreen" : "Fullscreen", + ariaLabel: fullscreen ? "Exit fullscreen" : "Fullscreen", + onClick: () => setFullscreen((f) => !f), }, ]; }, [ data, - prompts, statusBusy, - onRunPrompt, + fullscreen, + openPanelMenu, onToggleStatus, onToggleDefer, onDelete, + onRunPrompt, + issueShortcuts, + issueShortcutPromptMap, ]); // Seed non-notes fields whenever a different issue opens (notes come from @@ -1078,12 +1245,14 @@ export function BeadsDetailPanel({ const respData = await readBeadsResponse(res); if (!res.ok || respData.error) { setDeps([]); + setLabels([]); setComments([]); setNotes(""); if (seedDraftNotes) setViewDraft((prev) => ({ ...prev, notes: "" })); } else { const issueObj = Array.isArray(respData) ? respData[0] : respData; setDeps((issueObj && issueObj.dependencies) || []); + setLabels((issueObj && issueObj.labels) || []); setComments((issueObj && issueObj.comments) || []); const fetchedNotes = (issueObj && issueObj.notes) || ""; setNotes(fetchedNotes); @@ -1092,6 +1261,7 @@ export function BeadsDetailPanel({ } } catch (_err) { setDeps([]); + setLabels([]); setComments([]); setNotes(""); if (seedDraftNotes) setViewDraft((prev) => ({ ...prev, notes: "" })); @@ -1164,10 +1334,13 @@ export function BeadsDetailPanel({ // seedDraftNotes=true so the initial open seeds viewDraft.notes from the response. useEffect(() => { setDeps([]); + setLabels([]); setComments([]); setNotes(""); setNewDepId(""); setNewDepType("blocks"); + setNewLabel(""); + setAddingLabel(false); if (isOpen && !creating && data && data.id) { fetchDeps(true); } @@ -1231,6 +1404,102 @@ export function BeadsDetailPanel({ if (ok) setNewDepId(""); }, [newDepId, newDepType, depsBusy, mutateDep]); + // Fetch the workspace's unique labels to suggest when adding a label. bd + // returns [{label,count}, ...]; we keep only the names. Refreshed when the + // panel opens and after a label is added. Non-fatal on failure. + const fetchAllLabels = useCallback(async () => { + if (!workingDir) return; + try { + const res = await authFetch( + endpoints.issues.labelsAll({ working_dir: workingDir }), + ); + const respData = await readBeadsResponse(res); + if (res.ok && Array.isArray(respData)) { + setAllLabels( + respData + .map((l) => (typeof l === "string" ? l : l && l.label)) + .filter(Boolean), + ); + } + } catch (_err) { + // Non-fatal: label suggestions just won't populate. + } + }, [workingDir]); + + useEffect(() => { + if (isOpen && !creating) fetchAllLabels(); + }, [isOpen, creating, fetchAllLabels]); + + // Add or remove a label on the current issue, then refresh the issue (so the + // labels list stays current) and notify the parent list. Mirrors mutateDep. + const mutateLabel = useCallback( + async (action, label) => { + const value = (label || "").trim(); + if (!data || !data.id || !value) return false; + setLabelsBusy(true); + try { + const res = await secureFetch( + endpoints.issues.labels(data.id, { working_dir: workingDir }), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: value, action }), + }, + ); + const respData = await readBeadsResponse(res); + if (!res.ok || respData.error) { + showToast && + showToast({ + style: "error", + title: respData.error || `Failed to ${action} label`, + }); + return false; + } + showToast && + showToast({ + style: "success", + title: + action === "add" + ? `Added label "${value}"` + : `Removed label "${value}"`, + }); + await fetchDeps(false); + if (action === "add") fetchAllLabels(); + onUpdated && onUpdated(); + return true; + } catch (err) { + showToast && + showToast({ + style: "error", + title: err.message || `Failed to ${action} label`, + }); + return false; + } finally { + setLabelsBusy(false); + } + }, + [ + data && data.id, + workingDir, + showToast, + fetchDeps, + fetchAllLabels, + onUpdated, + ], + ); + + const handleAddLabel = useCallback(async () => { + const value = newLabel.trim(); + if (!value || labelsBusy) return; + const ok = await mutateLabel("add", value); + if (ok) setNewLabel(""); + }, [newLabel, labelsBusy, mutateLabel]); + + // Focus the add-label input as soon as the "+" reveals it. + useEffect(() => { + if (addingLabel && labelInputRef.current) labelInputRef.current.focus(); + }, [addingLabel]); + // Change the kind of an existing edge. bd has no in-place type update, so this // removes the edge and re-adds it with the new type. A single combined toast // and refresh is issued at the end. @@ -1315,7 +1584,7 @@ export function BeadsDetailPanel({ // is supplied: { text, setText } back the active field (create form or inline // edit draft) and `disabled` force-greys the row regardless (read-only view). const renderDescToolbar = ({ text, setText, disabled, editorApiRef }) => html` - <div class="flex items-center gap-1 mb-1"> + <div class="flex flex-wrap items-center gap-1 mb-1"> <button type="button" class="chat-input-action tooltip tooltip-bottom" @@ -1991,77 +2260,63 @@ ${viewDraft.description}</pre fullscreen ? "--dock-w:100%;--dock-maxw:100%" : isMobile - ? "--dock-w:100%" + ? "--dock-w:100%;--dock-maxw:100%" : "--dock-w:40rem;--dock-maxw:85%" } widthClass="w-full" panelClass="bg-mitto-sidebar shrink-0 h-full flex flex-col border-l border-mitto-border-1" > - <div class="flex items-center gap-2 p-4 border-b border-mitto-border shrink-0"> - <div class="flex-1 min-w-0"> + <div class="p-4 border-b border-mitto-border shrink-0"> + <div class="flex items-center gap-2"> + <div class="flex-1 min-w-0"> + ${ + creating + ? html`<${Fragment}> + ${TitleField("create")} + ${createParentId ? html`<div class="font-mono text-xs text-mitto-text-secondary">in ${createParentId}</div>` : null} + </${Fragment}>` + : html` + <h2 + class="font-semibold text-base text-mitto-text truncate" + title=${viewDraft.title || data.title || data.id} + > + ${viewDraft.title || data.title || data.id} + </h2> + ` + } + </div> ${ creating - ? html`<${Fragment}> - ${TitleField("create")} - ${createParentId ? html`<div class="font-mono text-xs text-mitto-text-secondary">in ${createParentId}</div>` : null} - </${Fragment}>` - : html` - <div class="flex items-center gap-1"> - <span class="font-mono text-xs text-mitto-text-secondary" - >${data.id}</span - > - <button - type="button" - onClick=${async () => { - const ok = await copyToClipboard(data.id); - showToast && - showToast( - ok - ? { style: "success", title: `Copied ${data.id}` } - : { - style: "error", - title: "Failed to copy issue ID", - }, - ); - }} - class="btn btn-ghost btn-xs btn-square inline-flex tooltip tooltip-bottom" - data-tip="Copy issue ID ${data.id}" - aria-label="Copy issue ID ${data.id}" - > - <${CopyIcon} className="w-3.5 h-3.5" /> - </button> - </div> - ${TitleField("view")} + ? html` + <button + onClick=${() => setFullscreen((f) => !f)} + class="btn btn-ghost btn-square btn-sm shrink-0 inline-flex tooltip tooltip-bottom" + data-tip=${fullscreen ? "Exit fullscreen" : "Fullscreen"} + aria-label=${fullscreen ? "Exit fullscreen" : "Fullscreen"} + > + ${fullscreen + ? html`<${CollapseIcon} className="w-5 h-5" />` + : html`<${ExpandIcon} className="w-5 h-5" />`} + </button> ` + : null } </div> ${ - !creating && - data && - html` - <button - type="button" - onClick=${openPanelMenu} - class="btn btn-ghost btn-square btn-sm shrink-0 inline-flex tooltip tooltip-bottom" - data-tip="More actions" - aria-label="More actions" - > - <${EllipsisIcon} className="w-5 h-5" /> - </button> - ` + !creating && data + ? html` + <div class="mt-6"> + <${Toolbar} + variant="block" + surface="bg-mitto-surface-3" + ariaLabel="Issue actions" + testId="beads-issue-toolbar" + items=${headerToolbarItems} + /> + </div> + ` + : null } - <button - onClick=${() => setFullscreen((f) => !f)} - class="btn btn-ghost btn-square btn-sm shrink-0 inline-flex tooltip tooltip-bottom" - data-tip=${fullscreen ? "Exit fullscreen" : "Fullscreen"} - aria-label=${fullscreen ? "Exit fullscreen" : "Fullscreen"} - > - ${ - fullscreen - ? html`<${CollapseIcon} className="w-5 h-5" />` - : html`<${ExpandIcon} className="w-5 h-5" />` - } - </button> </div> <div class="flex-1 overflow-y-auto p-4 space-y-4"> @@ -2124,6 +2379,37 @@ ${viewDraft.description}</pre </div> <div class="grid grid-cols-2 gap-3"> + <div> + <label class=${labelClass}>ID</label> + <div class="flex items-center gap-1"> + <span class="font-mono text-sm text-mitto-text" + >${data.id}</span + > + <button + type="button" + onClick=${async () => { + const ok = await copyToClipboard(data.id); + showToast && + showToast( + ok + ? { + style: "success", + title: `Copied ${data.id}`, + } + : { + style: "error", + title: "Failed to copy issue ID", + }, + ); + }} + class="btn btn-ghost btn-xs btn-square inline-flex tooltip tooltip-bottom" + data-tip="Copy issue ID ${data.id}" + aria-label="Copy issue ID ${data.id}" + > + <${CopyIcon} className="w-3.5 h-3.5" /> + </button> + </div> + </div> <div> <label class=${labelClass}>Assignee</label> ${AssigneeField("view")} @@ -2161,22 +2447,111 @@ ${viewDraft.description}</pre )} </div> - ${Array.isArray(data.labels) && - data.labels.length > 0 && - html` - <div> - <div class="text-xs text-mitto-text-secondary mb-0.5"> - Labels - </div> - <div class="flex flex-wrap gap-2"> - ${data.labels.map((l) => - badge(l, "bg-mitto-surface-4 text-mitto-text-strong"), - )} - </div> + <div> + <div class="text-xs text-mitto-text-secondary mb-1"> + Labels </div> - `} - - ${DescriptionField("view")} + <datalist id="beads-label-options"> + ${allLabels + .filter((l) => !labels.includes(l)) + .map((l) => html`<option key=${l} value=${l}></option>`)} + </datalist> + <div class="flex flex-wrap gap-2 items-center"> + ${labels.length === 0 && + !addingLabel && + html`<span class="text-xs text-mitto-text-secondary italic" + >No labels.</span + >`} + ${labels.map( + (l) => html` + <span + key=${l} + class="badge badge-sm font-medium bg-mitto-surface-4 text-mitto-text-strong" + > + ${l} + <button + type="button" + onClick=${() => { + if (labelsBusy) return; + mutateLabel("remove", l); + }} + aria-disabled=${labelsBusy ? "true" : "false"} + class="inline-flex items-center opacity-60 hover:opacity-100 hover:text-red-400 cursor-pointer tooltip tooltip-bottom ${labelsBusy + ? "opacity-40 pointer-events-none" + : ""}" + data-tip=${'Remove label "' + l + '"'} + aria-label=${'Remove label "' + l + '"'} + > + <${CloseIcon} className="w-3 h-3" /> + </button> + </span> + `, + )} + ${addingLabel + ? html` + <div class="join w-52 max-w-full"> + <input + ref=${labelInputRef} + type="text" + list="beads-label-options" + placeholder="add label…" + value=${newLabel} + disabled=${labelsBusy} + onInput=${(e) => setNewLabel(e.target.value)} + onKeyDown=${(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddLabel(); + } else if (e.key === "Escape") { + e.preventDefault(); + setNewLabel(""); + setAddingLabel(false); + } + }} + onBlur=${() => { + if (!newLabel.trim()) setAddingLabel(false); + }} + class="input input-xs flex-1 min-w-0 join-item" + /> + <button + type="button" + onMouseDown=${(e) => e.preventDefault()} + onClick=${() => { + if (labelsBusy || !newLabel.trim()) return; + handleAddLabel(); + }} + aria-disabled=${labelsBusy || !newLabel.trim() + ? "true" + : "false"} + class="btn btn-ghost btn-square btn-xs shrink-0 join-item inline-flex tooltip tooltip-bottom ${labelsBusy || + !newLabel.trim() + ? "opacity-40 pointer-events-none" + : ""}" + data-tip="Add label" + aria-label="Add label" + > + ${labelsBusy + ? html`<span + class="loading loading-spinner w-3 h-3" + ></span>` + : html`<${PlusIcon} className="w-3 h-3" />`} + </button> + </div> + ` + : html` + <button + type="button" + onClick=${() => setAddingLabel(true)} + class="btn btn-ghost btn-square btn-xs inline-flex tooltip tooltip-bottom" + data-tip="Add label" + aria-label="Add label" + > + <${PlusIcon} className="w-3 h-3" /> + </button> + `} + </div> + </div> + ${TitleField("view")} ${DescriptionField("view")} ${subtasks.length > 0 && html` <fieldset class="fieldset"> @@ -2918,6 +3293,10 @@ export function BeadsView({ const [pullPromptName, setPullPromptName] = useState(""); const [pushPromptName, setPushPromptName] = useState(""); const [syncPromptName, setSyncPromptName] = useState(""); + // Saved argument maps (name→string) for the configured pull/push/sync prompts. + const [pullPromptArgs, setPullPromptArgs] = useState({}); + const [pushPromptArgs, setPushPromptArgs] = useState({}); + const [syncPromptArgs, setSyncPromptArgs] = useState({}); // List-level "Prompts" dropdown state (footer toolbar). These are the // `menus: beadsList` prompts that operate on the whole issue list rather than @@ -2925,9 +3304,9 @@ export function BeadsView({ const [showListPrompts, setShowListPrompts] = useState(false); const [listPrompts, setListPrompts] = useState([]); const [listPromptsLoading, setListPromptsLoading] = useState(false); - // Per-send periodic override for beadsList prompts, keyed by prompt name. + // Per-send loop override for beadsList prompts, keyed by prompt name. // Reset whenever the list reloads (see effect below). - const [listPeriodicOn, setListPeriodicOn] = useState({}); + const [listLoopOn, setListLoopOn] = useState({}); // Shortcut buttons configured for this folder's tasksList section. const [shortcuts, setShortcuts] = useState([]); @@ -2995,6 +3374,9 @@ export function BeadsView({ setPullPromptName((data && data.pull_prompt) || ""); setPushPromptName((data && data.push_prompt) || ""); setSyncPromptName((data && data.sync_prompt) || ""); + setPullPromptArgs((data && data.pull_prompt_args) || {}); + setPushPromptArgs((data && data.push_prompt_args) || {}); + setSyncPromptArgs((data && data.sync_prompt_args) || {}); } } catch (_err) { if (!cancelled) setUpstream("none"); @@ -3018,11 +3400,24 @@ export function BeadsView({ return; } try { - const res = await authFetch( - endpoints.folders.shortcuts({ working_dir: workingDir }), - ); - const data = await res.json().catch(() => ({})); - const list = data?.sections?.tasksList || []; + // Merge global (settings.json) + folder (folders.json) shortcuts for the + // tasksList section. Global buttons come first; folder buttons that + // duplicate a global prompt are dropped. + const [folderRes, globalRes] = await Promise.all([ + authFetch(endpoints.folders.shortcuts({ working_dir: workingDir })), + authFetch(endpoints.global.shortcuts()).catch(() => null), + ]); + const folderData = await folderRes.json().catch(() => ({})); + const globalData = globalRes + ? await globalRes.json().catch(() => ({})) + : {}; + const globalList = globalData?.sections?.tasksList || []; + const folderList = folderData?.sections?.tasksList || []; + const globalNames = new Set(globalList.map((s) => s.prompt)); + const list = [ + ...globalList, + ...folderList.filter((s) => !globalNames.has(s.prompt)), + ]; if (isStale && isStale()) return; setShortcuts(list); if (list.length > 0 && onFetchBeadsListPrompts) { @@ -3058,9 +3453,17 @@ export function BeadsView({ const dir = e?.detail?.working_dir; if (!dir || dir === workingDir) loadShortcuts(); }; + // Global shortcuts changes affect every folder, so always refresh. + const globalHandler = () => loadShortcuts(); window.addEventListener("mitto:folder_shortcuts_updated", handler); - return () => + window.addEventListener("mitto:global_shortcuts_updated", globalHandler); + return () => { window.removeEventListener("mitto:folder_shortcuts_updated", handler); + window.removeEventListener( + "mitto:global_shortcuts_updated", + globalHandler, + ); + }; }, [loadShortcuts, workingDir]); // Auto-refresh the issue list when the backend fsnotify watcher reports @@ -3843,31 +4246,33 @@ export function BeadsView({ return () => document.removeEventListener("mousedown", onDocClick); }, [showListPrompts]); - // Toggle the list-level prompts dropdown, lazily loading the `menus: beadsList` - // prompts for this workspace the first time it is opened. - const toggleListPrompts = useCallback(() => { - setShowListPrompts((open) => { - const next = !open; - if (next && onFetchBeadsListPrompts && workingDir) { + // Open/close the list-level prompts dropdown, lazily loading the + // `menus: beadsList` prompts for this workspace the first time it is opened. + // Open-driven (receives the next open state) because the Toolbar dropdown is + // backed by a controlled <details> whose onToggle reports the new state. + const handleListPromptsToggle = useCallback( + (open) => { + if (open && onFetchBeadsListPrompts && workingDir) { setListPromptsLoading(true); onFetchBeadsListPrompts(workingDir) .then((list) => { const prompts = list || []; setListPrompts(prompts); - // Seed per-item periodic toggle defaults from each prompt's mode/default. + // Seed per-item loop toggle defaults from each prompt's mode/default. const seed = {}; for (const p of prompts) { - if (promptPeriodicIsToggleable(p)) { - seed[p.name] = promptPeriodicDefaultOn(p); + if (promptLoopIsToggleable(p)) { + seed[p.name] = promptLoopDefaultOn(p); } } - setListPeriodicOn(seed); + setListLoopOn(seed); }) .finally(() => setListPromptsLoading(false)); } - return next; - }); - }, [onFetchBeadsListPrompts, workingDir]); + setShowListPrompts(open); + }, + [onFetchBeadsListPrompts, workingDir], + ); // Run a list-level prompt in a new conversation (no per-issue context). const handleRunListPrompt = useCallback( @@ -4179,10 +4584,273 @@ export function BeadsView({ `; } + // ---- Top toolbar (moved from the former bottom footer) -------------------- + // The list-level actions now live in a portable Toolbar "pill" at the top of + // the view, vertically aligned with the sidebar toolbar (both sit right below + // their p-4 header, in a px-3 wrapper with no top padding). Order: new issue, + // list-prompts dropdown, refresh, clean up, | upstream sync group |, + // | shortcuts |, spacer, issue count, tasks-config. Conditional groups are + // set off by thin dividers. + const upstreamLabel = UPSTREAM_LABELS[upstream] || upstream; + const busySpinner = html`<span + class="loading loading-spinner w-4 h-4" + ></span>`; + + const cleanupTip = + cleaningUp && cleanupProgress && cleanupProgress.total > 0 + ? `Removing ${cleanupProgress.deleted}/${cleanupProgress.total}…` + : closedCount === 0 + ? "No closed issues to clean up" + : `Clean up ${closedCount} closed issue${closedCount === 1 ? "" : "s"}`; + const cleanupAria = + closedCount === 0 + ? "No closed issues to clean up" + : `Clean up ${closedCount} closed issue${closedCount === 1 ? "" : "s"}`; + + // Upstream pull/push/sync buttons. Two flavours: "prompts" runs configured + // prompts via onLaunchPrompt; a real backend (jira/github/…) calls handleSync + // and shows an inline spinner on the in-flight action. + const upstreamItems = + upstream === "prompts" + ? [ + { + kind: "button", + testId: "beads-pull-btn", + icon: html`<${ArrowDownIcon} className="w-4 h-4" />`, + tip: pullPromptName + ? `Pull: run "${pullPromptName}"` + : "No pull prompt configured", + ariaLabel: pullPromptName + ? `Pull: run "${pullPromptName}"` + : "No pull prompt configured", + disabled: !pullPromptName || !onLaunchPrompt, + onClick: () => + pullPromptName && + onLaunchPrompt && + onLaunchPrompt("pull", pullPromptName, pullPromptArgs), + }, + { + kind: "button", + testId: "beads-push-btn", + icon: html`<${ArrowUpIcon} className="w-4 h-4" />`, + tip: pushPromptName + ? `Push: run "${pushPromptName}"` + : "No push prompt configured", + ariaLabel: pushPromptName + ? `Push: run "${pushPromptName}"` + : "No push prompt configured", + disabled: !pushPromptName || !onLaunchPrompt, + onClick: () => + pushPromptName && + onLaunchPrompt && + onLaunchPrompt("push", pushPromptName, pushPromptArgs), + }, + { + kind: "button", + testId: "beads-sync-btn", + icon: html`<${SyncIcon} className="w-4 h-4" />`, + tip: syncPromptName + ? `Sync: run "${syncPromptName}"` + : "No sync prompt configured", + ariaLabel: syncPromptName + ? `Sync: run "${syncPromptName}"` + : "No sync prompt configured", + disabled: !syncPromptName || !onLaunchPrompt, + onClick: () => + syncPromptName && + onLaunchPrompt && + onLaunchPrompt("sync", syncPromptName, syncPromptArgs), + }, + ] + : [ + { + kind: "button", + testId: "beads-pull-btn", + icon: + syncAction === "pull" + ? busySpinner + : html`<${ArrowDownIcon} className="w-4 h-4" />`, + tip: `Pull from ${upstreamLabel}`, + ariaLabel: `Pull from ${upstreamLabel}`, + disabled: !!syncAction, + onClick: () => !syncAction && handleSync("pull"), + }, + { + kind: "button", + testId: "beads-push-btn", + icon: + syncAction === "push" + ? busySpinner + : html`<${ArrowUpIcon} className="w-4 h-4" />`, + tip: `Push to ${upstreamLabel}`, + ariaLabel: `Push to ${upstreamLabel}`, + disabled: !!syncAction, + onClick: () => !syncAction && handleSync("push"), + }, + { + kind: "button", + testId: "beads-sync-btn", + icon: + syncAction === "sync" + ? busySpinner + : html`<${SyncIcon} className="w-4 h-4" />`, + tip: `Sync with ${upstreamLabel} (pull then push)`, + ariaLabel: `Sync with ${upstreamLabel} (pull then push)`, + disabled: !!syncAction, + onClick: () => !syncAction && handleSync("sync"), + }, + ]; + + // Per-folder shortcut buttons (tasksList section). A missing linked prompt is + // shown greyed/disabled, mirroring the former footer behaviour. + const shortcutItems = shortcuts.map((sc, i) => { + const prompt = shortcutPromptMap.get(sc.prompt); + const found = !!prompt; + const Icon = getPromptIconOrDefault(sc.icon || (prompt && prompt.icon)); + return { + kind: "button", + testId: `beads-shortcut-btn-${i}`, + icon: html`<${Icon} className="w-4 h-4" />`, + tip: found ? sc.prompt : `Prompt "${sc.prompt}" not found`, + ariaLabel: found + ? `Run "${sc.prompt}"` + : `Prompt "${sc.prompt}" not found`, + disabled: !found, + onClick: () => found && handleRunListPrompt(prompt), + }; + }); + + // List-prompts dropdown menu (opens downward now that the toolbar is on top). + const listPromptsMenu = html` + <ul + class="dropdown-content menu w-64 max-h-72 overflow-y-auto flex-nowrap bg-base-200 rounded-box shadow-xl z-10 mt-1" + > + ${listPromptsLoading && + html` + <li class="px-3 py-2 flex items-center gap-2"> + <span class="loading loading-spinner w-4 h-4"></span> + Loading… + </li> + `} + ${!listPromptsLoading && + listPrompts.length === 0 && + html` <li class="px-3 py-2 opacity-60">No task prompts</li> `} + ${!listPromptsLoading && + listPrompts.map((p) => { + const PromptIcon = getPromptIconOrDefault(p.icon); + return html` + <li key=${p.name}> + <button + type="button" + onClick=${() => { + const mode = promptLoopMode(p); + const opts = + mode === "optional" + ? { + asLoop: + listLoopOn[p.name] !== undefined + ? listLoopOn[p.name] + : promptLoopDefaultOn(p), + } + : undefined; + handleRunListPrompt(p, opts); + }} + title=${p.description || p.name} + > + <span class="w-4 h-4 shrink-0" + ><${PromptIcon} className="w-4 h-4" + /></span> + <span class="truncate flex-1">${p.name}</span> + ${(() => { + const mode = promptLoopMode(p); + if (mode === "none") return null; + if (mode === "optional") { + const on = + listLoopOn[p.name] !== undefined + ? listLoopOn[p.name] + : promptLoopDefaultOn(p); + return html`<input + type="checkbox" + class="checkbox checkbox-sm shrink-0" + style="background-color: transparent" + checked=${on} + title=${on + ? "Loop: ON — click to disable recurring runs" + : "Loop: OFF — click to run as recurring conversation"} + onClick=${(e) => e.stopPropagation()} + onChange=${(e) => { + e.stopPropagation(); + setListLoopOn((m) => ({ + ...m, + [p.name]: e.target.checked, + })); + }} + />`; + } + // mode === "always": locked badge (unchanged look) + return html`<span + class="shrink-0 text-success opacity-80" + title="Loop prompt — always sets the conversation to recurring mode" + ><${LoopIcon} className="w-3.5 h-3.5" + /></span>`; + })()} + </button> + </li> + `; + })} + </ul> + `; + + const listToolbarItems = [ + { + kind: "button", + testId: "beads-new-issue-btn", + icon: html`<${PlusIcon} className="w-4 h-4" />`, + tip: "New issue", + ariaLabel: "New issue", + onClick: openCreate, + }, + { + kind: "dropdown", + testId: "beads-list-prompts-btn", + icon: html`<${LightningIcon} className="w-4 h-4" />`, + tip: "Run a prompt over the issue list in a new conversation", + ariaLabel: "Run a prompt over the issue list in a new conversation", + open: showListPrompts, + onToggle: handleListPromptsToggle, + menu: listPromptsMenu, + }, + { + kind: "button", + testId: "beads-refresh-btn", + icon: html`<${RefreshIcon} className="w-4 h-4" />`, + tip: "Refresh", + ariaLabel: "Refresh", + onClick: fetchList, + }, + { + kind: "button", + testId: "beads-cleanup-btn", + icon: html`<${BroomIcon} className="w-4 h-4 group-hover:text-red-400" />`, + tip: cleanupTip, + ariaLabel: cleanupAria, + className: "group", + disabled: closedCount === 0 || cleaningUp, + onClick: () => { + if (closedCount === 0 || cleaningUp) return; + setShowCleanupConfirm(true); + }, + }, + ...(upstream && upstream !== "none" + ? [{ kind: "separator" }, ...upstreamItems] + : []), + ...(shortcuts.length > 0 ? [{ kind: "separator" }, ...shortcutItems] : []), + ]; + return html` <div class="relative flex h-full overflow-hidden"> <div class="flex flex-col flex-1 min-w-0 overflow-hidden"> - <div class="flex items-center gap-2 p-4 border-b border-mitto-border shrink-0"> + <div class="flex items-center gap-2 p-4 shrink-0"> <button onClick=${() => onShowSidebar && onShowSidebar()} class="btn btn-ghost btn-square btn-sm md:hidden shrink-0 inline-flex tooltip tooltip-bottom" @@ -4191,7 +4859,24 @@ export function BeadsView({ > <${MenuIcon} className="w-6 h-6" /> </button> - <span class="font-semibold text-lg flex-1">Tasks — ${workspaceLabel}</span> + <span class="font-semibold text-2xl flex-1">Tasks — ${workspaceLabel}</span> + </div> + + <!-- List-level actions rendered via the portable Toolbar component + (components/Toolbar.js) as a floating "pill", vertically aligned with + the sidebar toolbar. The wrapper carries listPromptsRef so the + existing outside-click handler still closes the prompts dropdown. --> + <div + class="px-3 pb-2 shrink-0" + ref=${listPromptsRef} + data-testid="beads-actions-toolbar" + > + <${Toolbar} + variant="block" + surface="bg-mitto-surface-3" + ariaLabel="Task list actions" + items=${listToolbarItems} + /> </div> <div class="beads-toolbar flex items-center gap-2 px-4 border-b border-mitto-border shrink-0"> @@ -4270,9 +4955,11 @@ export function BeadsView({ }} > <span class="w-4 h-4 shrink-0"> - ${typeFilter === "all" - ? html`<${CheckIcon} className="w-4 h-4" />` - : null} + ${ + typeFilter === "all" + ? html`<${CheckIcon} className="w-4 h-4" />` + : null + } </span> <span class="flex-1">All types</span> </button> @@ -4439,7 +5126,7 @@ export function BeadsView({ No issues found </div> <div class="text-mitto-text-muted text-xs"> - Create a new issue by pressing the "+" button below. + Create a new issue by pressing the "+" button above. </div> </div> ` @@ -4463,297 +5150,15 @@ export function BeadsView({ } </div> - <div class="flex items-center gap-1 p-4 border-t border-mitto-border shrink-0"> - <button - onClick=${openCreate} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top" - data-tip="New issue" - aria-label="New issue" - > - <${PlusIcon} className="w-4 h-4" /> - </button> - <div class="relative" ref=${listPromptsRef}> - <button - type="button" - onClick=${toggleListPrompts} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top" - data-tip="Run a prompt over the issue list in a new conversation" - aria-label="Run a prompt over the issue list in a new conversation" - > - <${ChevronUpIcon} className="w-4 h-4" /> - </button> - ${ - showListPrompts && - html` - <ul - class="menu absolute bottom-full left-0 mb-2 w-64 max-h-72 overflow-y-auto flex-nowrap bg-base-200 rounded-box shadow-xl z-10" - > - ${listPromptsLoading && - html` - <li class="px-3 py-2 flex items-center gap-2"> - <span class="loading loading-spinner w-4 h-4"></span> - Loading… - </li> - `} - ${!listPromptsLoading && - listPrompts.length === 0 && - html` <li class="px-3 py-2 opacity-60">No task prompts</li> `} - ${!listPromptsLoading && - listPrompts.map((p) => { - const PromptIcon = getPromptIconOrDefault(p.icon); - return html` - <li key=${p.name}> - <button - type="button" - onClick=${() => { - const mode = promptPeriodicMode(p); - const opts = - mode === "optional" - ? { - asPeriodic: - listPeriodicOn[p.name] !== undefined - ? listPeriodicOn[p.name] - : promptPeriodicDefaultOn(p), - } - : undefined; - handleRunListPrompt(p, opts); - }} - title=${p.description || p.name} - > - <span class="w-4 h-4 shrink-0" - ><${PromptIcon} className="w-4 h-4" - /></span> - <span class="truncate flex-1">${p.name}</span> - ${(() => { - const mode = promptPeriodicMode(p); - if (mode === "none") return null; - if (mode === "optional") { - const on = - listPeriodicOn[p.name] !== undefined - ? listPeriodicOn[p.name] - : promptPeriodicDefaultOn(p); - return html`<input - type="checkbox" - class="checkbox checkbox-sm shrink-0" - style="background-color: transparent" - checked=${on} - title=${on - ? "Periodic: ON — click to disable recurring runs" - : "Periodic: OFF — click to run as recurring conversation"} - onClick=${(e) => e.stopPropagation()} - onChange=${(e) => { - e.stopPropagation(); - setListPeriodicOn((m) => ({ - ...m, - [p.name]: e.target.checked, - })); - }} - />`; - } - // mode === "always": locked badge (unchanged look) - return html`<span - class="shrink-0 text-success opacity-80" - title="Periodic prompt — always sets the conversation to recurring mode" - ><${PeriodicIcon} className="w-3.5 h-3.5" - /></span>`; - })()} - </button> - </li> - `; - })} - </ul> - ` - } - </div> - <button - onClick=${fetchList} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top" - data-tip="Refresh" - aria-label="Refresh" - > - <${RefreshIcon} className="w-4 h-4" /> - </button> - <button - onClick=${() => { - if (closedCount === 0 || cleaningUp) return; - setShowCleanupConfirm(true); - }} - aria-disabled=${closedCount === 0 || cleaningUp ? "true" : "false"} - class="btn btn-ghost btn-square btn-sm group inline-flex tooltip tooltip-top ${closedCount === 0 || cleaningUp ? "opacity-40 pointer-events-none" : ""}" - data-tip=${cleaningUp && cleanupProgress && cleanupProgress.total > 0 ? `Removing ${cleanupProgress.deleted}/${cleanupProgress.total}…` : closedCount === 0 ? "No closed issues to clean up" : `Clean up ${closedCount} closed issue${closedCount === 1 ? "" : "s"}`} - aria-label=${closedCount === 0 ? "No closed issues to clean up" : `Clean up ${closedCount} closed issue${closedCount === 1 ? "" : "s"}`} + <!-- Bottom status bar: issue count + tasks-configuration gear. The + action buttons moved to the top Toolbar pill; this area keeps only + the statistics and the config affordance. --> + <div + class="flex items-center gap-1 p-4 border-t border-mitto-border shrink-0" + > + <span class="text-xs text-mitto-text-secondary ml-auto" + >${filtered.length} issue${filtered.length === 1 ? "" : "s"}</span > - <${BroomIcon} className="w-4 h-4 group-hover:text-red-400" /> - </button> - - ${ - upstream && - upstream !== "none" && - html` - <div - class="flex items-center gap-1 pl-2 ml-1 border-l border-mitto-border" - > - ${upstream === "prompts" - ? html` - <button - onClick=${() => { - if (!pullPromptName || !onLaunchPrompt) return; - onLaunchPrompt("pull", pullPromptName); - }} - aria-disabled=${!pullPromptName || !onLaunchPrompt - ? "true" - : "false"} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top ${!pullPromptName || - !onLaunchPrompt - ? "opacity-40 pointer-events-none" - : ""}" - data-tip=${pullPromptName - ? `Pull: run "${pullPromptName}"` - : "No pull prompt configured"} - aria-label=${pullPromptName - ? `Pull: run "${pullPromptName}"` - : "No pull prompt configured"} - > - <${ArrowDownIcon} className="w-4 h-4" /> - </button> - <button - onClick=${() => { - if (!pushPromptName || !onLaunchPrompt) return; - onLaunchPrompt("push", pushPromptName); - }} - aria-disabled=${!pushPromptName || !onLaunchPrompt - ? "true" - : "false"} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top ${!pushPromptName || - !onLaunchPrompt - ? "opacity-40 pointer-events-none" - : ""}" - data-tip=${pushPromptName - ? `Push: run "${pushPromptName}"` - : "No push prompt configured"} - aria-label=${pushPromptName - ? `Push: run "${pushPromptName}"` - : "No push prompt configured"} - > - <${ArrowUpIcon} className="w-4 h-4" /> - </button> - <button - onClick=${() => { - if (!syncPromptName || !onLaunchPrompt) return; - onLaunchPrompt("sync", syncPromptName); - }} - aria-disabled=${!syncPromptName || !onLaunchPrompt - ? "true" - : "false"} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top ${!syncPromptName || - !onLaunchPrompt - ? "opacity-40 pointer-events-none" - : ""}" - data-tip=${syncPromptName - ? `Sync: run "${syncPromptName}"` - : "No sync prompt configured"} - aria-label=${syncPromptName - ? `Sync: run "${syncPromptName}"` - : "No sync prompt configured"} - > - <${SyncIcon} className="w-4 h-4" /> - </button> - ` - : html` - <button - onClick=${() => { - if (syncAction) return; - handleSync("pull"); - }} - aria-disabled=${syncAction ? "true" : "false"} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top ${syncAction - ? "opacity-40 pointer-events-none" - : ""}" - data-tip=${`Pull from ${UPSTREAM_LABELS[upstream] || upstream}`} - aria-label=${`Pull from ${UPSTREAM_LABELS[upstream] || upstream}`} - > - ${syncAction === "pull" - ? html`<span - class="loading loading-spinner w-4 h-4" - ></span>` - : html`<${ArrowDownIcon} className="w-4 h-4" />`} - </button> - <button - onClick=${() => { - if (syncAction) return; - handleSync("push"); - }} - aria-disabled=${syncAction ? "true" : "false"} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top ${syncAction - ? "opacity-40 pointer-events-none" - : ""}" - data-tip=${`Push to ${UPSTREAM_LABELS[upstream] || upstream}`} - aria-label=${`Push to ${UPSTREAM_LABELS[upstream] || upstream}`} - > - ${syncAction === "push" - ? html`<span - class="loading loading-spinner w-4 h-4" - ></span>` - : html`<${ArrowUpIcon} className="w-4 h-4" />`} - </button> - <button - onClick=${() => { - if (syncAction) return; - handleSync("sync"); - }} - aria-disabled=${syncAction ? "true" : "false"} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top ${syncAction - ? "opacity-40 pointer-events-none" - : ""}" - data-tip=${`Sync with ${UPSTREAM_LABELS[upstream] || upstream} (pull then push)`} - aria-label=${`Sync with ${UPSTREAM_LABELS[upstream] || upstream} (pull then push)`} - > - ${syncAction === "sync" - ? html`<span - class="loading loading-spinner w-4 h-4" - ></span>` - : html`<${SyncIcon} className="w-4 h-4" />`} - </button> - `} - </div> - ` - } - - ${shortcuts.length > 0 && - html` - <div - class="flex items-center gap-1 pl-2 ml-1 border-l border-mitto-border" - > - ${shortcuts.map((sc, i) => { - const prompt = shortcutPromptMap.get(sc.prompt); - const found = !!prompt; - // Empty shortcut icon → fall back to the linked prompt's own icon. - const Icon = getPromptIconOrDefault(sc.icon || prompt?.icon); - return html` - <button - key=${i} - type="button" - onClick=${() => found && handleRunListPrompt(prompt)} - aria-disabled=${found ? "false" : "true"} - class="btn btn-ghost btn-square btn-sm inline-flex tooltip tooltip-top ${found ? "" : "opacity-40 pointer-events-none"}" - data-tip=${found - ? `Run "${sc.prompt}"` - : `Prompt "${sc.prompt}" not found`} - aria-label=${found - ? `Run "${sc.prompt}"` - : `Prompt "${sc.prompt}" not found`} - > - <span class="w-4 h-4"> - <${Icon} className="w-4 h-4" /> - </span> - </button> - `; - })} - </div> - `} - - <span class="text-xs text-mitto-text-secondary ml-auto">${filtered.length} issue${filtered.length === 1 ? "" : "s"}</span> - ${ onOpenConfig && html` diff --git a/web/static/components/BeadsView.test.js b/web/static/components/BeadsView.test.js index 9d1ca96c..74209d52 100644 --- a/web/static/components/BeadsView.test.js +++ b/web/static/components/BeadsView.test.js @@ -8,9 +8,9 @@ */ import { - promptPeriodicMode, - promptPeriodicIsToggleable, - promptPeriodicDefaultOn, + promptLoopMode, + promptLoopIsToggleable, + promptLoopDefaultOn, } from "../utils/prompts.js"; // ============================================================================= @@ -353,7 +353,7 @@ describe("onLaunchPrompt call convention", () => { /** * Simulates what the Pull/Push/Sync buttons do when clicked with a configured prompt: * onLaunchPrompt(action, promptName) - * — no arguments object, no periodic, no acpServer (handled by handler in app.js). + * — no arguments object, no loop, no acpServer (handled by handler in app.js). */ function simulateButtonClick(action, promptName, onLaunchPrompt) { if (!promptName || !onLaunchPrompt) return; @@ -405,7 +405,7 @@ describe("onLaunchPrompt call convention", () => { test("launcher is NOT called with an arguments object (argument-free)", () => { const launcher = makeSpy(); simulateButtonClick("sync", "sync-prompt", launcher); - // Must have exactly 2 args: action + promptName (no args/periodic object) + // Must have exactly 2 args: action + promptName (no args/loop object) expect(launcher.lastCall()).toHaveLength(2); }); }); @@ -662,7 +662,7 @@ describe("cleanup progress toast — terminal outcomes reset state", () => { }); // ============================================================================= -// beadsList per-item periodic control — toggle vs locked badge vs nothing +// beadsList per-item loop control — toggle vs locked badge vs nothing // (mitto-92x.4) // ============================================================================= @@ -670,67 +670,67 @@ describe("cleanup progress toast — terminal outcomes reset state", () => { * Mirrors the IIFE used in BeadsView's beadsList dropdown item rendering: decides * whether to render an interactive toggle ("toggle"), a locked badge ("badge"), or * nothing ("none") for a given prompt + per-item toggle-state map. Uses the real - * promptPeriodicMode/promptPeriodicDefaultOn helpers (not a duplicate). + * promptLoopMode/promptLoopDefaultOn helpers (not a duplicate). */ -function decideListPromptPeriodicControl(p, listPeriodicOn) { - const mode = promptPeriodicMode(p); +function decideListPromptLoopControl(p, listLoopOn) { + const mode = promptLoopMode(p); if (mode === "none") return { kind: "none" }; if (mode === "optional") { const on = - listPeriodicOn[p.name] !== undefined - ? listPeriodicOn[p.name] - : promptPeriodicDefaultOn(p); + listLoopOn[p.name] !== undefined + ? listLoopOn[p.name] + : promptLoopDefaultOn(p); return { kind: "toggle", checked: on }; } return { kind: "badge" }; } -describe("beadsList per-item periodic control", () => { +describe("beadsList per-item loop control", () => { test("mode: optional, default:false renders an unchecked toggle", () => { - const p = { name: "maybe", periodic: { mode: "optional", default: false } }; - expect(promptPeriodicIsToggleable(p)).toBe(true); - expect(decideListPromptPeriodicControl(p, {})).toEqual({ + const p = { name: "maybe", loop: { mode: "optional", default: false } }; + expect(promptLoopIsToggleable(p)).toBe(true); + expect(decideListPromptLoopControl(p, {})).toEqual({ kind: "toggle", checked: false, }); }); test("mode: optional, default:true renders a checked toggle", () => { - const p = { name: "maybe", periodic: { mode: "optional", default: true } }; - expect(decideListPromptPeriodicControl(p, {})).toEqual({ + const p = { name: "maybe", loop: { mode: "optional", default: true } }; + expect(decideListPromptLoopControl(p, {})).toEqual({ kind: "toggle", checked: true, }); }); test("mode: optional, no default renders a checked toggle (default => true)", () => { - const p = { name: "maybe", periodic: { mode: "optional" } }; - expect(decideListPromptPeriodicControl(p, {})).toEqual({ + const p = { name: "maybe", loop: { mode: "optional" } }; + expect(decideListPromptLoopControl(p, {})).toEqual({ kind: "toggle", checked: true, }); }); - test("mode: optional honors the per-item listPeriodicOn override over the default", () => { - const p = { name: "maybe", periodic: { mode: "optional", default: true } }; + test("mode: optional honors the per-item listLoopOn override over the default", () => { + const p = { name: "maybe", loop: { mode: "optional", default: true } }; expect( - decideListPromptPeriodicControl(p, { maybe: false }), + decideListPromptLoopControl(p, { maybe: false }), ).toEqual({ kind: "toggle", checked: false }); }); test("mode: always renders the locked badge (no checkbox toggle)", () => { - const p = { name: "always-on", periodic: { mode: "always" } }; - expect(promptPeriodicIsToggleable(p)).toBe(false); - expect(decideListPromptPeriodicControl(p, {})).toEqual({ kind: "badge" }); + const p = { name: "always-on", loop: { mode: "always" } }; + expect(promptLoopIsToggleable(p)).toBe(false); + expect(decideListPromptLoopControl(p, {})).toEqual({ kind: "badge" }); }); - test("periodic block with no mode renders the locked badge (absent => always)", () => { - const p = { name: "legacy-periodic", periodic: { value: 1, unit: "hours" } }; - expect(decideListPromptPeriodicControl(p, {})).toEqual({ kind: "badge" }); + test("loop block with no mode renders the locked badge (absent => always)", () => { + const p = { name: "legacy-loop", loop: { value: 1, unit: "hours" } }; + expect(decideListPromptLoopControl(p, {})).toEqual({ kind: "badge" }); }); - test("non-periodic prompt renders neither toggle nor badge", () => { + test("non-loop prompt renders neither toggle nor badge", () => { const p = { name: "plain" }; - expect(decideListPromptPeriodicControl(p, {})).toEqual({ kind: "none" }); + expect(decideListPromptLoopControl(p, {})).toEqual({ kind: "none" }); }); }); diff --git a/web/static/components/ChatInput.js b/web/static/components/ChatInput.js index 7115dc87..0313da33 100644 --- a/web/static/components/ChatInput.js +++ b/web/static/components/ChatInput.js @@ -25,7 +25,7 @@ import { } from "../utils/storage.js"; import { useResizeHandle } from "../hooks/useResizeHandle.js"; import { SlashCommandPicker } from "./SlashCommandPicker.js"; -import { PeriodicFrequencyPanel } from "./PeriodicFrequencyPanel.js"; +import { LoopFrequencyPanel } from "./LoopFrequencyPanel.js"; import { SavePromptDialog } from "./SavePromptDialog.js"; import { GripIcon, SettingsIcon } from "./Icons.js"; import { ConfigOptionSelect } from "./ConfigOptionSelect.js"; @@ -36,7 +36,7 @@ import { fetchCachedParamNames, effectiveMissingParams, promptParameters, - promptResolveAsPeriodic, + promptResolveAsLoop, } from "../utils/prompts.js"; /** @@ -142,7 +142,7 @@ function PromptStopButton({ onStop }) { * @param {boolean} props.isArchived - Whether session is archived (disables input) * @param {boolean} props.isArchivePending - Whether archive is pending (waiting for agent to finish) * @param {Array} props.predefinedPrompts - Array of predefined prompts (ChatInput dropup) - * @param {Array} props.periodicPrompts - Array of prompts for the periodic prompt selector + * @param {Array} props.loopPrompts - Array of prompts for the loop prompt selector * @param {Object} props.inputRef - Ref for external focus control * @param {boolean} props.noSession - Whether there's no active session * @param {string} props.sessionId - Current session ID @@ -156,8 +156,8 @@ function PromptStopButton({ onStop }) { * @param {boolean} props.showQueueDropdown - Whether the queue dropdown is currently visible * @param {Array} props.actionButtons - Array of action buttons from agent response { label, response } * @param {Array} props.availableCommands - Array of available slash commands { name, description, input_hint } - * @param {boolean} props.periodicConfigured - Whether a periodic config exists (shows editor, disables queue buttons) - * @param {Function} [props.onPeriodicPrompt] - Called with (prompt, opts) when a periodic-flagged prompt is selected, where opts is { asPeriodic } (the resolved per-send override). Routes to app-level branching (decidePeriodicAction). When absent, periodic prompts fall through to the normal send path. + * @param {boolean} props.loopConfigured - Whether a loop config exists (shows editor, disables queue buttons) + * @param {Function} [props.onLoopPrompt] - Called with (prompt, opts) when a loop-flagged prompt is selected, where opts is { asLoop } (the resolved per-send override). Routes to app-level branching (decideLoopAction). When absent, loop prompts fall through to the normal send path. * @param {Object} props.activeUIPrompt - Active UI prompt from MCP tool { requestId, promptType, question, options, timeoutSeconds, receivedAt } * @param {Function} props.onUIPromptAnswer - Callback when user answers a UI prompt (requestId, optionId, label) * @param {string} props.workingDir - Workspace directory path (for smart file path insertion on native app drag & drop) @@ -173,7 +173,7 @@ export function ChatInput({ isArchived = false, isArchivePending = false, predefinedPrompts = [], - periodicPrompts = [], + loopPrompts = [], inputRef, noSession = false, sessionId, @@ -188,8 +188,8 @@ export function ChatInput({ showQueueDropdown = false, actionButtons = [], availableCommands = [], - periodicConfigured = false, - onPeriodicPrompt, + loopConfigured = false, + onLoopPrompt, agentSupportsImages = false, acpReady = true, gcSuspended = false, @@ -207,7 +207,7 @@ export function ChatInput({ tokenUsage = null, onOpenPromptParamDialog, // Whether the active workspace has beads (`.beads` + `bd`). Gates the "On - // tasks" periodic trigger tab in PeriodicFrequencyPanel (mitto-oja.4). + // tasks" loop trigger tab in LoopFrequencyPanel (mitto-oja.4). hasBeadsWorkspace = false, }) { // Use the draft from parent state instead of local state @@ -360,10 +360,10 @@ export function ChatInput({ const textboxRef = useRef(null); const [isPromptCollapsed, setIsPromptCollapsed] = useState(false); const prevCollapsedBeforeUIRef = useRef(false); - // Expand/collapse state for the periodic settings body (chevron). Lifted here so + // Expand/collapse state for the loop settings body (chevron). Lifted here so // it stays mutually exclusive with the prompt composition area: only one may be // expanded at a time. - const [periodicExpanded, setPeriodicExpanded] = useState(false); + const [loopExpanded, setLoopExpanded] = useState(false); // Resize handle for UI prompt panels (textbox, form, options) const { @@ -379,11 +379,11 @@ export function ChatInput({ }, }); - // Periodic prompt lock state - // When locked, the prompt is saved to the periodic config and textarea is read-only - const [isPeriodicLocked, setIsPeriodicLocked] = useState(false); - const [isPeriodicSaving, setIsPeriodicSaving] = useState(false); - const [periodicPromptName, setPeriodicPromptName] = useState(""); + // Loop prompt lock state + // When locked, the prompt is saved to the loop config and textarea is read-only + const [isLoopLocked, setIsLoopLocked] = useState(false); + const [isLoopSaving, setIsLoopSaving] = useState(false); + const [loopPromptName, setLoopPromptName] = useState(""); // Resize handle for textarea min height (controls the visual size of the input area) // Hard max for auto-grow (scrollbar appears beyond this) @@ -431,27 +431,27 @@ export function ChatInput({ ); }; }, []); - const [periodicPrompt, setPeriodicPrompt] = useState(""); // The saved periodic prompt - const [periodicFrequency, setPeriodicFrequency] = useState({ + const [loopPrompt, setLoopPrompt] = useState(""); // The saved loop prompt + const [loopFrequency, setLoopFrequency] = useState({ value: 1, unit: "hours", }); - const [periodicNextScheduledAt, setPeriodicNextScheduledAt] = useState(null); - const [periodicFreshContext, setPeriodicFreshContext] = useState(false); - const [periodicMaxIterations, setPeriodicMaxIterations] = useState(0); - const [periodicIterationCount, setPeriodicIterationCount] = useState(0); - const [periodicTrigger, setPeriodicTrigger] = useState("schedule"); - const [periodicDelaySeconds, setPeriodicDelaySeconds] = useState(5); - const [periodicMaxDurationSeconds, setPeriodicMaxDurationSeconds] = + const [loopNextScheduledAt, setLoopNextScheduledAt] = useState(null); + const [loopFreshContext, setLoopFreshContext] = useState(false); + const [loopMaxIterations, setLoopMaxIterations] = useState(0); + const [loopIterationCount, setLoopIterationCount] = useState(0); + const [loopTrigger, setLoopTrigger] = useState("schedule"); + const [loopDelaySeconds, setLoopDelaySeconds] = useState(5); + const [loopMaxDurationSeconds, setLoopMaxDurationSeconds] = useState(0); // onTasks trigger fields: CEL condition gating firing + the UI preset id // compiled into it (empty condition = fire on any beads/task change). - const [periodicCondition, setPeriodicCondition] = useState(""); - const [periodicConditionPreset, setPeriodicConditionPreset] = useState(""); - // Reason the periodic loop was auto-stopped (e.g. "maxDuration", "maxIterations", + const [loopCondition, setLoopCondition] = useState(""); + const [loopConditionPreset, setLoopConditionPreset] = useState(""); + // Reason the loop loop was auto-stopped (e.g. "maxDuration", "maxIterations", // "iterationSafeguard"); empty when running. Drives the restore-dialog wording. - const [periodicStoppedReason, setPeriodicStoppedReason] = useState(""); - const [periodicArguments, setPeriodicArguments] = useState({}); + const [loopStoppedReason, setLoopStoppedReason] = useState(""); + const [loopArguments, setLoopArguments] = useState({}); // Track window width for responsive placeholder const [isSmallWindow, setIsSmallWindow] = useState(window.innerWidth < 640); @@ -476,26 +476,26 @@ export function ChatInput({ setShowSlashPicker(false); setSlashSelectedIndex(0); setComboSelectedId(""); // Reset combo box selection - // Reset periodic lock state when session changes - setIsPeriodicLocked(false); - setIsPeriodicSaving(false); - setPeriodicPrompt(""); - setPeriodicPromptName(""); - setPeriodicFrequency({ value: 1, unit: "hours" }); - setPeriodicNextScheduledAt(null); - setPeriodicMaxIterations(0); - setPeriodicIterationCount(0); - setPeriodicTrigger("schedule"); - setPeriodicDelaySeconds(5); - setPeriodicMaxDurationSeconds(0); - setPeriodicCondition(""); - setPeriodicConditionPreset(""); - setPeriodicStoppedReason(""); - setPeriodicArguments({}); - // Collapse the periodic properties body by default when switching + // Reset loop lock state when session changes + setIsLoopLocked(false); + setIsLoopSaving(false); + setLoopPrompt(""); + setLoopPromptName(""); + setLoopFrequency({ value: 1, unit: "hours" }); + setLoopNextScheduledAt(null); + setLoopMaxIterations(0); + setLoopIterationCount(0); + setLoopTrigger("schedule"); + setLoopDelaySeconds(5); + setLoopMaxDurationSeconds(0); + setLoopCondition(""); + setLoopConditionPreset(""); + setLoopStoppedReason(""); + setLoopArguments({}); + // Collapse the loop properties body by default when switching // conversations (the prompt composition area is collapsed separately by - // the periodicConfigured effect below). - setPeriodicExpanded(false); + // the loopConfigured effect below). + setLoopExpanded(false); }, [sessionId]); // Reset combo box selection and free text input when UI prompt changes @@ -520,43 +520,43 @@ export function ChatInput({ prevCollapsedBeforeUIRef.current = prev; return true; }); - } else if (!periodicConfigured) { + } else if (!loopConfigured) { // Restore previous collapsed state when MCP UI dismisses setIsPromptCollapsed(prevCollapsedBeforeUIRef.current); } - }, [activeUIPrompt?.requestId, periodicConfigured]); + }, [activeUIPrompt?.requestId, loopConfigured]); - // Fetch periodic config when periodic is configured for this session + // Fetch loop config when loop is configured for this session useEffect(() => { - if (!periodicConfigured || !sessionId) { - setIsPeriodicLocked(false); - setPeriodicPrompt(""); - setPeriodicPromptName(""); - setPeriodicFrequency({ value: 1, unit: "hours" }); - setPeriodicNextScheduledAt(null); - setPeriodicTrigger("schedule"); - setPeriodicDelaySeconds(5); - setPeriodicMaxDurationSeconds(0); - setPeriodicCondition(""); - setPeriodicConditionPreset(""); - setPeriodicStoppedReason(""); - setPeriodicArguments({}); - // Don't clear the draft when disabling periodic - preserve user's text + if (!loopConfigured || !sessionId) { + setIsLoopLocked(false); + setLoopPrompt(""); + setLoopPromptName(""); + setLoopFrequency({ value: 1, unit: "hours" }); + setLoopNextScheduledAt(null); + setLoopTrigger("schedule"); + setLoopDelaySeconds(5); + setLoopMaxDurationSeconds(0); + setLoopCondition(""); + setLoopConditionPreset(""); + setLoopStoppedReason(""); + setLoopArguments({}); + // Don't clear the draft when disabling loop - preserve user's text return; } - // Default to collapsed prompt area for periodic conversations + // Default to collapsed prompt area for loop conversations setIsPromptCollapsed(true); - const fetchPeriodicConfig = async () => { + const fetchLoopConfig = async () => { try { const response = await authFetch( - endpoints.sessions.periodic(sessionId), + endpoints.sessions.loop(sessionId), ); const ct = response.headers.get("content-type"); if (!response.ok || !ct || !ct.includes("application/json")) { console.warn( - "Periodic config fetch returned non-JSON response:", + "Loop config fetch returned non-JSON response:", response.status, ct, ); @@ -565,51 +565,51 @@ export function ChatInput({ const config = await response.json(); // Always update frequency if (config.frequency) { - setPeriodicFrequency(config.frequency); + setLoopFrequency(config.frequency); } // Update next_scheduled_at (only set if enabled) if (config.enabled && config.next_scheduled_at) { - setPeriodicNextScheduledAt(config.next_scheduled_at); + setLoopNextScheduledAt(config.next_scheduled_at); } else { - setPeriodicNextScheduledAt(null); + setLoopNextScheduledAt(null); } // Update prompt name and fresh context from config - setPeriodicPromptName(config.prompt_name || ""); - setPeriodicFreshContext(config.fresh_context === true); - setPeriodicMaxIterations(config.max_iterations ?? 0); - setPeriodicIterationCount(config.iteration_count ?? 0); - setPeriodicTrigger(config.trigger || "schedule"); - setPeriodicDelaySeconds(config.delay_seconds ?? 5); - setPeriodicMaxDurationSeconds(config.max_duration_seconds ?? 0); - setPeriodicCondition(config.condition || ""); - setPeriodicConditionPreset(config.condition_preset || ""); - setPeriodicStoppedReason(config.stopped_reason || ""); - setPeriodicArguments(config.arguments || {}); + setLoopPromptName(config.prompt_name || ""); + setLoopFreshContext(config.fresh_context === true); + setLoopMaxIterations(config.max_iterations ?? 0); + setLoopIterationCount(config.iteration_count ?? 0); + setLoopTrigger(config.trigger || "schedule"); + setLoopDelaySeconds(config.delay_seconds ?? 5); + setLoopMaxDurationSeconds(config.max_duration_seconds ?? 0); + setLoopCondition(config.condition || ""); + setLoopConditionPreset(config.condition_preset || ""); + setLoopStoppedReason(config.stopped_reason || ""); + setLoopArguments(config.arguments || {}); // Set lock state based on the enabled field const isLocked = config.enabled === true; - setIsPeriodicLocked(isLocked); + setIsLoopLocked(isLocked); // Set prompt state based on config const isPendingPlaceholder = config.prompt === "(pending)"; if (config.prompt && !isPendingPlaceholder) { - setPeriodicPrompt(config.prompt); + setLoopPrompt(config.prompt); } else { - setPeriodicPrompt(""); + setLoopPrompt(""); } } catch (err) { - console.error("Failed to fetch periodic config:", err); + console.error("Failed to fetch loop config:", err); } }; - fetchPeriodicConfig(); - }, [periodicConfigured, sessionId]); + fetchLoopConfig(); + }, [loopConfigured, sessionId]); - // Listen for periodic config updates from other clients via WebSocket + // Listen for loop config updates from other clients via WebSocket useEffect(() => { - const handlePeriodicConfigUpdated = (event) => { + const handleLoopConfigUpdated = (event) => { const { sessionId: updatedSessionId, - periodicConfigured, - periodicEnabled: newPeriodicEnabled, + loopConfigured, + loopEnabled: newLoopEnabled, frequency, nextScheduledAt, iterationCount, @@ -621,45 +621,45 @@ export function ChatInput({ // Update frequency if provided if (frequency) { - setPeriodicFrequency(frequency); + setLoopFrequency(frequency); } if (iterationCount !== undefined) - setPeriodicIterationCount(iterationCount); - if (maxIterations !== undefined) setPeriodicMaxIterations(maxIterations); - - // If periodic config was deleted (not configured), reset state - if (periodicConfigured === false) { - setIsPeriodicLocked(false); - setPeriodicNextScheduledAt(null); - setPeriodicPrompt(""); + setLoopIterationCount(iterationCount); + if (maxIterations !== undefined) setLoopMaxIterations(maxIterations); + + // If loop config was deleted (not configured), reset state + if (loopConfigured === false) { + setIsLoopLocked(false); + setLoopNextScheduledAt(null); + setLoopPrompt(""); return; } - // If periodic run is disabled (unlocked), update lock state - if (newPeriodicEnabled === false) { - setIsPeriodicLocked(false); - setPeriodicNextScheduledAt(null); + // If loop run is disabled (unlocked), update lock state + if (newLoopEnabled === false) { + setIsLoopLocked(false); + setLoopNextScheduledAt(null); // Capture why the loop stopped so the restore dialog can offer to reset // the elapsed iterations/time when a max-iterations/max-duration cap was hit. - setPeriodicStoppedReason(stoppedReason || ""); + setLoopStoppedReason(stoppedReason || ""); // Don't clear the prompt - user may want to re-enable without re-typing return; } - // If periodic run is enabled (locked), fetch the full config for the prompt - if (newPeriodicEnabled === true) { + // If loop run is enabled (locked), fetch the full config for the prompt + if (newLoopEnabled === true) { // Update next scheduled time if (nextScheduledAt) { - setPeriodicNextScheduledAt(nextScheduledAt); + setLoopNextScheduledAt(nextScheduledAt); } // Fetch the full config to get the prompt name and fresh_context - authFetch(endpoints.sessions.periodic(sessionId)) + authFetch(endpoints.sessions.loop(sessionId)) .then(async (response) => { if (!response.ok) return null; const ct = response.headers.get("content-type"); if (!ct || !ct.includes("application/json")) { console.warn( - "Periodic config fetch returned non-JSON response:", + "Loop config fetch returned non-JSON response:", response.status, ct, ); @@ -669,37 +669,37 @@ export function ChatInput({ }) .then((config) => { if (!config) return; - setPeriodicPromptName(config.prompt_name || ""); - setPeriodicFreshContext(config.fresh_context === true); - setPeriodicMaxIterations(config.max_iterations ?? 0); - setPeriodicIterationCount(config.iteration_count ?? 0); - setPeriodicTrigger(config.trigger || "schedule"); - setPeriodicDelaySeconds(config.delay_seconds ?? 5); - setPeriodicMaxDurationSeconds(config.max_duration_seconds ?? 0); - setPeriodicCondition(config.condition || ""); - setPeriodicConditionPreset(config.condition_preset || ""); - setPeriodicStoppedReason(config.stopped_reason || ""); - setPeriodicArguments(config.arguments || {}); + setLoopPromptName(config.prompt_name || ""); + setLoopFreshContext(config.fresh_context === true); + setLoopMaxIterations(config.max_iterations ?? 0); + setLoopIterationCount(config.iteration_count ?? 0); + setLoopTrigger(config.trigger || "schedule"); + setLoopDelaySeconds(config.delay_seconds ?? 5); + setLoopMaxDurationSeconds(config.max_duration_seconds ?? 0); + setLoopCondition(config.condition || ""); + setLoopConditionPreset(config.condition_preset || ""); + setLoopStoppedReason(config.stopped_reason || ""); + setLoopArguments(config.arguments || {}); const isPendingPlaceholder = config.prompt === "(pending)"; if (config.prompt && !isPendingPlaceholder) { - setPeriodicPrompt(config.prompt); - setIsPeriodicLocked(true); + setLoopPrompt(config.prompt); + setIsLoopLocked(true); } }) .catch((err) => - console.error("Failed to fetch periodic config:", err), + console.error("Failed to fetch loop config:", err), ); } }; window.addEventListener( - "mitto:periodic_config_updated", - handlePeriodicConfigUpdated, + "mitto:loop_config_updated", + handleLoopConfigUpdated, ); return () => { window.removeEventListener( - "mitto:periodic_config_updated", - handlePeriodicConfigUpdated, + "mitto:loop_config_updated", + handleLoopConfigUpdated, ); }; }, [sessionId]); @@ -774,7 +774,7 @@ export function ChatInput({ }, [showDropup]); // Adjust textarea height when draft changes (e.g., switching sessions) - // Also re-adjusts when periodic lock state changes (collapse when locked, expand when unlocked) + // Also re-adjusts when loop lock state changes (collapse when locked, expand when unlocked) // Auto-sizing: grow to content, but respect min-height from resize handle and hard max useEffect(() => { if (isTextareaDragging) return; // Skip auto-sizing during drag (onHeightChange handles it) @@ -900,9 +900,9 @@ export function ChatInput({ if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - // In periodic conversations, hide the composition area after a + // In loop conversations, hide the composition area after a // successful enqueue; the user re-opens it via the Mitto bubble. - if (periodicConfigured) setIsPromptCollapsed(true); + if (loopConfigured) setIsPromptCollapsed(true); } } catch (err) { console.error("Failed to add to queue:", err); @@ -933,9 +933,9 @@ export function ChatInput({ if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - // In periodic conversations, hide the composition area after a + // In loop conversations, hide the composition area after a // successful send; the user re-opens it via the Mitto bubble. - if (periodicConfigured) setIsPromptCollapsed(true); + if (loopConfigured) setIsPromptCollapsed(true); } catch (err) { // Failed - show error and keep text for retry console.error("Failed to send message:", err); @@ -973,9 +973,9 @@ export function ChatInput({ if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - // In periodic conversations, hide the composition area after a + // In loop conversations, hide the composition area after a // successful enqueue; the user re-opens it via the Mitto bubble. - if (periodicConfigured) setIsPromptCollapsed(true); + if (loopConfigured) setIsPromptCollapsed(true); } } catch (err) { console.error("Failed to add to queue:", err); @@ -983,14 +983,14 @@ export function ChatInput({ } }; - // Handle locking the periodic prompt (saves to backend and enables periodic run) - const handleLockPeriodicPrompt = useCallback(async () => { - if (!sessionId || !text.trim() || isPeriodicSaving) return; + // Handle locking the loop prompt (saves to backend and enables loop run) + const handleLockLoopPrompt = useCallback(async () => { + if (!sessionId || !text.trim() || isLoopSaving) return; - setIsPeriodicSaving(true); + setIsLoopSaving(true); try { const response = await secureFetch( - endpoints.sessions.periodic(sessionId), + endpoints.sessions.loop(sessionId), { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -999,30 +999,30 @@ export function ChatInput({ ); if (response.ok) { const data = await response.json(); - setPeriodicPrompt(text.trim()); - setIsPeriodicLocked(true); + setLoopPrompt(text.trim()); + setIsLoopLocked(true); // Update next scheduled time from server response (keep as ISO string for consistency) if (data.next_scheduled_at) { - setPeriodicNextScheduledAt(data.next_scheduled_at); + setLoopNextScheduledAt(data.next_scheduled_at); } } else { - console.error("Failed to lock periodic prompt"); + console.error("Failed to lock loop prompt"); } } catch (err) { - console.error("Failed to lock periodic prompt:", err); + console.error("Failed to lock loop prompt:", err); } finally { - setIsPeriodicSaving(false); + setIsLoopSaving(false); } - }, [sessionId, text, isPeriodicSaving]); + }, [sessionId, text, isLoopSaving]); - // Handle unlocking the periodic prompt (allows editing and disables periodic run) - const handleUnlockPeriodicPrompt = useCallback(async () => { - if (!sessionId || isPeriodicSaving) return; + // Handle unlocking the loop prompt (allows editing and disables loop run) + const handleUnlockLoopPrompt = useCallback(async () => { + if (!sessionId || isLoopSaving) return; - setIsPeriodicSaving(true); + setIsLoopSaving(true); try { const response = await secureFetch( - endpoints.sessions.periodic(sessionId), + endpoints.sessions.loop(sessionId), { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -1030,37 +1030,37 @@ export function ChatInput({ }, ); if (response.ok) { - setIsPeriodicLocked(false); - setPeriodicNextScheduledAt(null); // Clear next scheduled time when disabled + setIsLoopLocked(false); + setLoopNextScheduledAt(null); // Clear next scheduled time when disabled // Focus the textarea so user can start editing if (textareaRef.current) { textareaRef.current.focus(); } } else { - console.error("Failed to unlock periodic prompt"); + console.error("Failed to unlock loop prompt"); } } catch (err) { - console.error("Failed to unlock periodic prompt:", err); + console.error("Failed to unlock loop prompt:", err); } finally { - setIsPeriodicSaving(false); + setIsLoopSaving(false); } - }, [sessionId, isPeriodicSaving]); + }, [sessionId, isLoopSaving]); - // Handle periodic prompt selection from PeriodicPromptSelector - const handlePeriodicPromptSelect = useCallback( + // Handle loop prompt selection from LoopPromptSelector + const handleLoopPromptSelect = useCallback( async (promptName) => { - if (!sessionId || isPeriodicSaving) return; + if (!sessionId || isLoopSaving) return; // Helper that performs the actual PATCH, optionally with arguments. const doPatch = async (extraArgs) => { - setIsPeriodicSaving(true); + setIsLoopSaving(true); try { const body = { prompt_name: promptName, enabled: true }; if (extraArgs && Object.keys(extraArgs).length > 0) { body.arguments = extraArgs; } const response = await secureFetch( - endpoints.sessions.periodic(sessionId), + endpoints.sessions.loop(sessionId), { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -1069,21 +1069,34 @@ export function ChatInput({ ); if (response.ok) { const data = await response.json(); - setPeriodicPromptName(promptName); - setIsPeriodicLocked(true); + setLoopPromptName(promptName); + setIsLoopLocked(true); if (data.next_scheduled_at) { - setPeriodicNextScheduledAt(data.next_scheduled_at); + setLoopNextScheduledAt(data.next_scheduled_at); } } } catch (err) { - console.error("Failed to save periodic prompt selection:", err); + console.error("Failed to save loop prompt selection:", err); } finally { - setIsPeriodicSaving(false); + setIsLoopSaving(false); } }; // Check if the prompt declares parameters that need user input before saving. - const fullPrompt = periodicPrompts.find((p) => p.name === promptName); + const fullPrompt = loopPrompts.find((p) => p.name === promptName); + + // Pre-populate the local condition state from the prompt's onTasks + // frontmatter default so the LoopFrequencyPanel reflects it immediately + // (mitto-pei). The PATCH below only sends prompt_name, so the backend's + // stored trigger/condition are left untouched until the user explicitly + // saves via the panel. + if ( + fullPrompt?.loop?.trigger === "onTasks" && + fullPrompt?.loop?.condition + ) { + setLoopCondition(fullPrompt.loop.condition); + } + let missing = fullPrompt ? getMissingPromptParameters(fullPrompt, "conversation") : []; @@ -1100,13 +1113,13 @@ export function ChatInput({ await doPatch(undefined); }, - [sessionId, isPeriodicSaving, periodicPrompts, onOpenPromptParamDialog], + [sessionId, isLoopSaving, loopPrompts, onOpenPromptParamDialog], ); - // Open the PromptParameterDialog pre-filled with current periodic arguments - const handleEditPeriodicArguments = useCallback(() => { - const prompt = (periodicPrompts || []).find( - (p) => p.name === periodicPromptName, + // Open the PromptParameterDialog pre-filled with current loop arguments + const handleEditLoopArguments = useCallback(() => { + const prompt = (loopPrompts || []).find( + (p) => p.name === loopPromptName, ); if (!prompt) return; const params = promptParameters(prompt); @@ -1118,50 +1131,50 @@ export function ChatInput({ async (userArgs) => { try { const resp = await secureFetch( - endpoints.sessions.periodic(sessionId), + endpoints.sessions.loop(sessionId), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ arguments: userArgs }), }, ); - if (resp.ok) setPeriodicArguments(userArgs); - else console.error("Failed to save periodic arguments"); + if (resp.ok) setLoopArguments(userArgs); + else console.error("Failed to save loop arguments"); } catch (err) { - console.error("Failed to save periodic arguments:", err); + console.error("Failed to save loop arguments:", err); } }, - { initialValues: periodicArguments, hostSessionId: sessionId }, + { initialValues: loopArguments, hostSessionId: sessionId }, ); }, [ - periodicPrompts, - periodicPromptName, - periodicArguments, + loopPrompts, + loopPromptName, + loopArguments, sessionId, onOpenPromptParamDialog, ]); - // Handle frequency change from the PeriodicFrequencyPanel - const handlePeriodicFrequencyChange = useCallback( + // Handle frequency change from the LoopFrequencyPanel + const handleLoopFrequencyChange = useCallback( (newFrequency, newNextScheduledAt) => { - setPeriodicFrequency(newFrequency); + setLoopFrequency(newFrequency); if (newNextScheduledAt) { - setPeriodicNextScheduledAt(newNextScheduledAt); + setLoopNextScheduledAt(newNextScheduledAt); } }, [], ); - // Handle max iterations change from the PeriodicFrequencyPanel - const handlePeriodicMaxIterationsChange = useCallback((newValue) => { - setPeriodicMaxIterations(newValue); + // Handle max iterations change from the LoopFrequencyPanel + const handleLoopMaxIterationsChange = useCallback((newValue) => { + setLoopMaxIterations(newValue); }, []); - // Handle pause/resume toggle from the PeriodicFrequencyPanel - const handlePeriodicEnabledChange = useCallback((newEnabled) => { - setIsPeriodicLocked(newEnabled); + // Handle pause/resume toggle from the LoopFrequencyPanel + const handleLoopEnabledChange = useCallback((newEnabled) => { + setIsLoopLocked(newEnabled); if (!newEnabled) { - setPeriodicNextScheduledAt(null); + setLoopNextScheduledAt(null); } }, []); @@ -1317,11 +1330,11 @@ export function ChatInput({ return; } - // Periodic-flagged prompts: route to app-level branching (decidePeriodicAction). - // This handles make-periodic / one-shot / new-periodic without duplicating logic here. - const asPeriodic = prompt && promptResolveAsPeriodic(prompt, opts?.asPeriodic); - if (asPeriodic && onPeriodicPrompt) { - onPeriodicPrompt(prompt, { asPeriodic }); + // Loop-flagged prompts: route to app-level branching (decideLoopAction). + // This handles make-loop / one-shot / new-loop without duplicating logic here. + const asLoop = prompt && promptResolveAsLoop(prompt, opts?.asLoop); + if (asLoop && onLoopPrompt) { + onLoopPrompt(prompt, { asLoop }); return; } @@ -2449,58 +2462,58 @@ ${activeUIPrompt.text || ""}</textarea </div> `} - <!-- Periodic settings card (shown when periodic is enabled) --> + <!-- Loop settings card (shown when loop is enabled) --> <!-- Part of normal document flow - pushes conversation area up. --> <!-- Single merged card: compact header always visible; body expands on demand. --> <div class="max-w-4xl mx-auto"> - <${PeriodicFrequencyPanel} - isOpen=${periodicConfigured && !hasActiveUIPrompt} - disabled=${isPeriodicLocked} + <${LoopFrequencyPanel} + isOpen=${loopConfigured && !hasActiveUIPrompt} + disabled=${isLoopLocked} sessionId=${sessionId} - frequency=${periodicFrequency} - onFrequencyChange=${handlePeriodicFrequencyChange} - nextScheduledAt=${periodicNextScheduledAt} + frequency=${loopFrequency} + onFrequencyChange=${handleLoopFrequencyChange} + nextScheduledAt=${loopNextScheduledAt} isStreaming=${isStreaming} - freshContext=${periodicFreshContext} - onFreshContextChange=${setPeriodicFreshContext} - maxIterations=${periodicMaxIterations} - iterationCount=${periodicIterationCount} - onMaxIterationsChange=${handlePeriodicMaxIterationsChange} - onPeriodicEnabledChange=${handlePeriodicEnabledChange} - prompts=${periodicPrompts} - selectedPromptName=${periodicPromptName} - selectedPromptBody=${periodicPrompt} - onPromptSelect=${handlePeriodicPromptSelect} + freshContext=${loopFreshContext} + onFreshContextChange=${setLoopFreshContext} + maxIterations=${loopMaxIterations} + iterationCount=${loopIterationCount} + onMaxIterationsChange=${handleLoopMaxIterationsChange} + onLoopEnabledChange=${handleLoopEnabledChange} + prompts=${loopPrompts} + selectedPromptName=${loopPromptName} + selectedPromptBody=${loopPrompt} + onPromptSelect=${handleLoopPromptSelect} isPromptAreaVisible=${!isPromptCollapsed} onTogglePromptArea=${() => setIsPromptCollapsed((v) => { const nextCollapsed = !v; - // Expanding the prompt area collapses the periodic properties. - if (!nextCollapsed) setPeriodicExpanded(false); + // Expanding the prompt area collapses the loop properties. + if (!nextCollapsed) setLoopExpanded(false); return nextCollapsed; })} - expanded=${periodicExpanded} + expanded=${loopExpanded} onToggleExpanded=${() => - setPeriodicExpanded((v) => { + setLoopExpanded((v) => { const next = !v; - // Expanding the periodic properties collapses the prompt area. + // Expanding the loop properties collapses the prompt area. if (next) setIsPromptCollapsed(true); return next; })} - trigger=${periodicTrigger} - delaySeconds=${periodicDelaySeconds} - maxDurationSeconds=${periodicMaxDurationSeconds} - condition=${periodicCondition} - conditionPreset=${periodicConditionPreset} + trigger=${loopTrigger} + delaySeconds=${loopDelaySeconds} + maxDurationSeconds=${loopMaxDurationSeconds} + condition=${loopCondition} + conditionPreset=${loopConditionPreset} hasBeadsWorkspace=${hasBeadsWorkspace} - stoppedReason=${periodicStoppedReason} + stoppedReason=${loopStoppedReason} minDelaySeconds=${5} - onTriggerChange=${setPeriodicTrigger} - onDelayChange=${setPeriodicDelaySeconds} - onMaxDurationChange=${setPeriodicMaxDurationSeconds} - onConditionChange=${setPeriodicCondition} - onConditionPresetChange=${setPeriodicConditionPreset} - onEditArguments=${handleEditPeriodicArguments} + onTriggerChange=${setLoopTrigger} + onDelayChange=${setLoopDelaySeconds} + onMaxDurationChange=${setLoopMaxDurationSeconds} + onConditionChange=${setLoopCondition} + onConditionPresetChange=${setLoopConditionPreset} + onEditArguments=${handleEditLoopArguments} /> </div> @@ -2508,7 +2521,7 @@ ${activeUIPrompt.text || ""}</textarea !isStreaming && !isReadOnly && !noSession && - !periodicConfigured && + !loopConfigured && !isResuming && html` <div class="max-w-4xl mx-auto mb-3"> @@ -2669,7 +2682,7 @@ ${activeUIPrompt.text || ""}</textarea </div> </div> `} - ${!(isPromptCollapsed && (periodicConfigured || hasActiveUIPrompt)) && + ${!(isPromptCollapsed && (loopConfigured || hasActiveUIPrompt)) && html` <div class="max-w-4xl mx-auto chat-input-container"> <div class="chat-input-box" ref=${dropupRef}> @@ -3085,19 +3098,19 @@ ${activeUIPrompt.text || ""}</textarea <button type="button" onClick=${() => { - if (!periodicConfigured && onToggleQueue) onToggleQueue(); + if (!loopConfigured && onToggleQueue) onToggleQueue(); }} - disabled=${periodicConfigured} + disabled=${loopConfigured} data-queue-toggle class="chat-input-action relative tooltip tooltip-top" - style="${showQueueDropdown && !periodicConfigured + style="${showQueueDropdown && !loopConfigured ? "background: #2563eb !important; color: white !important;" : ""}" - data-tip=${periodicConfigured - ? "Queue disabled for periodic sessions" + data-tip=${loopConfigured + ? "Queue disabled for loop sessions" : `${queueLength}/${queueConfig.max_size} queued - Click to ${showQueueDropdown ? "hide" : "show"} queue`} - aria-label=${periodicConfigured - ? "Queue disabled for periodic sessions" + aria-label=${loopConfigured + ? "Queue disabled for loop sessions" : `${queueLength}/${queueConfig.max_size} queued - Click to ${showQueueDropdown ? "hide" : "show"} queue`} > <svg @@ -3113,7 +3126,7 @@ ${activeUIPrompt.text || ""}</textarea d="M4 6h16M4 10h16M4 14h16M4 18h16" /> </svg> - ${!periodicConfigured && + ${!loopConfigured && html`<span class="absolute -top-1 -right-1 pointer-events-none" style="display:flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;border-radius:9999px;font-size:10px;font-weight:600;line-height:1;background:var(--mitto-accent,#dc2626);color:var(--mitto-accent-fg,#ffffff);box-sizing:border-box;" @@ -3193,7 +3206,7 @@ ${activeUIPrompt.text || ""}</textarea handlePredefinedPrompt(prompt, e, opts)} showSourceBadge=${true} shiftHeld=${shiftHeld} - periodicToggle=${true} + loopToggle=${true} placeholder="Filter prompts..." emptyText="No matching prompts" keyPrefix="chat-prompts" @@ -3266,13 +3279,13 @@ ${activeUIPrompt.text || ""}</textarea (!text.trim() && !hasPendingAttachments) || isReadOnly || isImproving || - periodicConfigured} + loopConfigured} class="chat-input-action tooltip tooltip-top" - data-tip=${periodicConfigured - ? "Queue disabled for periodic sessions" + data-tip=${loopConfigured + ? "Queue disabled for loop sessions" : "Add to queue (⌘/Ctrl+Enter)"} - aria-label=${periodicConfigured - ? "Queue disabled for periodic sessions" + aria-label=${loopConfigured + ? "Queue disabled for loop sessions" : "Add to queue (⌘/Ctrl+Enter)"} > <svg diff --git a/web/static/components/ContextMenu.js b/web/static/components/ContextMenu.js index a8c8f79a..801e287a 100644 --- a/web/static/components/ContextMenu.js +++ b/web/static/components/ContextMenu.js @@ -6,7 +6,7 @@ const { html, useState, useEffect, useLayoutEffect, useRef, render } = window.preact; import { ChevronRightIcon, getPromptIconOrDefault } from "./Icons.js"; -import { flattenPrompts, promptPeriodicMode, promptPeriodicDefaultOn } from "../utils/prompts.js"; +import { flattenPrompts, promptLoopMode, promptLoopDefaultOn } from "../utils/prompts.js"; // Build ContextMenu submenu items that group `prompts` by their `group` // attribute (ungrouped prompts fall under "Other"), each group sorted by name. @@ -14,7 +14,7 @@ import { flattenPrompts, promptPeriodicMode, promptPeriodicDefaultOn } from "../ // `onRun(prompt, opts)` handles selection; `groupIcon` is shown on each group // entry. Returns [] when there are no prompts. Shared by the conversation menu // and the Beads issue menus so all three surfaces present identical grouped -// submenus. Each submenu item carries `periodicMode`/`periodicDefaultOn` so +// submenus. Each submenu item carries `loopMode`/`loopDefaultOn` so // ContextMenuItem can render a mode-aware toggle/badge (mitto-92x.5) instead of // a static trailing element. export function buildPromptGroupMenuItems(prompts, onRun, groupIcon) { @@ -25,9 +25,9 @@ export function buildPromptGroupMenuItems(prompts, onRun, groupIcon) { submenu: g.prompts.map((p) => ({ label: p.name, icon: html`<${getPromptIconOrDefault(p.icon)} className="w-4 h-4" />`, - periodicMode: promptPeriodicMode(p), - periodicDefaultOn: promptPeriodicDefaultOn(p), - trailing: null, // periodic visual now derives from periodicMode in ContextMenuItem + loopMode: promptLoopMode(p), + loopDefaultOn: promptLoopDefaultOn(p), + trailing: null, // loop visual now derives from loopMode in ContextMenuItem onClick: (opts) => onRun(p, opts), })), })); @@ -76,7 +76,13 @@ export function Portal({ children }) { // off any edge. `text` may contain "\n"; rendered with white-space: pre-line. export function PortalTooltip({ x, y, text }) { const ref = useRef(null); - const [pos, setPos] = useState({ x: x + 14, y: y + 18 }); + // Round to whole pixels: fractional left/top under position:fixed lands the + // bubble on a half-pixel, which WebKit renders with sub-pixel anti-aliasing + // → blurry tooltip text (same class of bug as the Leaflet tooltip fix). + const [pos, setPos] = useState({ + x: Math.round(x + 14), + y: Math.round(y + 18), + }); // Clamp inside the viewport before paint (useLayoutEffect runs synchronously // after the Portal child mounts but before the browser paints, so the parked @@ -98,6 +104,10 @@ export function PortalTooltip({ x, y, text }) { ny = window.innerHeight - rect.height - margin; } if (ny < margin) ny = margin; + // Snap to whole pixels (rect.width/height and viewport math can be + // fractional) so the bubble never lands on a half-pixel and blurs. + nx = Math.round(nx); + ny = Math.round(ny); setPos((prev) => prev.x === nx && prev.y === ny ? prev : { x: nx, y: ny }, ); @@ -124,8 +134,8 @@ function ContextMenuItem({ item, onClose }) { const submenuCount = hasSubmenu ? item.submenu.length : 0; const [submenuOpen, setSubmenuOpen] = useState(false); const [submenuPos, setSubmenuPos] = useState({ left: 0, top: 0 }); - // Per-submenu-item periodic override (mode "optional" only), keyed by sub.label. - const [periodicOverrides, setPeriodicOverrides] = useState({}); + // Per-submenu-item loop override (mode "optional" only), keyed by sub.label. + const [loopOverrides, setLoopOverrides] = useState({}); const itemRef = useRef(null); const submenuRef = useRef(null); const closeTimerRef = useRef(null); @@ -172,13 +182,25 @@ function ContextMenuItem({ item, onClose }) { // Prefer opening to the right of the parent item; flip to the left when the // flyout would overflow the right edge of the viewport. let left = rect.right - 4; + let top = rect.top; if (left + sub.width > window.innerWidth - margin) { - left = rect.left - sub.width + 4; + const flippedLeft = rect.left - sub.width + 4; + if (flippedLeft >= margin) { + // Room on the left edge: open as a left-side flyout. + left = flippedLeft; + } else { + // Narrow (mobile) viewport: the flyout fits on NEITHER side, so a + // horizontal placement would sit on top of and hide the parent menu. + // Drop it BELOW the parent item instead, left-aligned to that item, so + // the tapped row (and everything above it) stays visible — a natural + // drill-down feel rather than an occluding overlay. + left = Math.min(rect.left, window.innerWidth - sub.width - margin); + if (left < margin) left = margin; + top = rect.bottom + 4; + } } - // If flipping left pushed it past the left edge, pin it back inside. - if (left < margin) left = margin; - // Shift up if it would overflow the bottom of the viewport. - let top = rect.top; + // Shift up if it would overflow the bottom of the viewport (its + // max-height/scroll caps very tall lists first). if (top + sub.height > window.innerHeight - margin) { top = Math.max(margin, window.innerHeight - sub.height - margin); } @@ -232,13 +254,13 @@ function ContextMenuItem({ item, onClose }) { onClick=${(e) => { e.stopPropagation(); if (!sub.disabled) { - const asPeriodic = - sub.periodicMode === "optional" - ? periodicOverrides[sub.label] !== undefined - ? periodicOverrides[sub.label] - : sub.periodicDefaultOn + const asLoop = + sub.loopMode === "optional" + ? loopOverrides[sub.label] !== undefined + ? loopOverrides[sub.label] + : sub.loopDefaultOn : undefined; - sub.onClick({ asPeriodic }); + sub.onClick({ asLoop }); onClose(); } }} @@ -248,38 +270,38 @@ function ContextMenuItem({ item, onClose }) { ${sub.icon && html`<span class="w-4 h-4">${sub.icon}</span>`} <span class="flex-1">${sub.label}</span> - ${sub.periodicMode === "optional" + ${sub.loopMode === "optional" ? html`<input type="checkbox" class="checkbox checkbox-sm shrink-0" style="background-color: transparent" - checked=${periodicOverrides[sub.label] !== undefined - ? periodicOverrides[sub.label] - : sub.periodicDefaultOn} + checked=${loopOverrides[sub.label] !== undefined + ? loopOverrides[sub.label] + : sub.loopDefaultOn} title=${( - periodicOverrides[sub.label] !== undefined - ? periodicOverrides[sub.label] - : sub.periodicDefaultOn + loopOverrides[sub.label] !== undefined + ? loopOverrides[sub.label] + : sub.loopDefaultOn ) - ? "Periodic: ON — click to disable recurring runs" - : "Periodic: OFF — click to run as recurring conversation"} + ? "Loop: ON — click to disable recurring runs" + : "Loop: OFF — click to run as recurring conversation"} onClick=${(e) => e.stopPropagation()} onChange=${(e) => { e.stopPropagation(); - setPeriodicOverrides((m) => ({ + setLoopOverrides((m) => ({ ...m, [sub.label]: e.target.checked, })); }} />` - : sub.periodicMode === "always" + : sub.loopMode === "always" ? html`<input type="checkbox" class="checkbox checkbox-sm shrink-0" style="background-color: transparent" checked=${true} disabled - title="Always periodic — this prompt always runs as a recurring conversation (cannot be changed)" + title="Always loop — this prompt always runs as a recurring conversation (cannot be changed)" onClick=${(e) => e.stopPropagation()} />` : sub.trailing} diff --git a/web/static/components/ConversationPropertiesPanel.js b/web/static/components/ConversationPropertiesPanel.js index 3b8d3c4c..5d0e8563 100644 --- a/web/static/components/ConversationPropertiesPanel.js +++ b/web/static/components/ConversationPropertiesPanel.js @@ -10,7 +10,7 @@ import { EditIcon, CheckIcon, FolderIcon, - PeriodicFilledIcon, + LoopFilledIcon, } from "./Icons.js"; import { apiUrl, errorMessageFromData } from "../utils/api.js"; import { secureFetch, authFetch } from "../utils/csrf.js"; @@ -35,6 +35,21 @@ function formatTokenCount(count) { return count.toString(); } +/** + * Format a duration given in milliseconds into a compact human-readable string. + * @param {number} ms + * @returns {string} e.g. "850ms", "12.3s", "1m2s" + */ +function formatDuration(ms) { + if (!ms || ms < 0) return "0ms"; + if (ms < 1000) return `${Math.round(ms)}ms`; + const totalSeconds = ms / 1000; + if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = Math.round(totalSeconds % 60); + return `${minutes}m${seconds}s`; +} + /** * TriStateCheckbox - A checkbox with three states: unset, enabled, disabled * @param {Object} props @@ -225,8 +240,8 @@ export function ConversationPropertiesPanel({ const [isSavingTitle, setIsSavingTitle] = useState(false); const titleInputRef = useRef(null); - // Periodic config state - const [periodicConfig, setPeriodicConfig] = useState(null); + // Loop config state + const [loopConfig, setLoopConfig] = useState(null); const [callbackConfig, setCallbackConfig] = useState(null); const [callbackCopied, setCallbackCopied] = useState(false); @@ -257,7 +272,7 @@ export function ConversationPropertiesPanel({ // Update relative time display every 30 seconds while panel is open useEffect(() => { - if (!isOpen || !periodicConfig?.next_scheduled_at) { + if (!isOpen || !loopConfig?.next_scheduled_at) { return; } @@ -266,19 +281,19 @@ export function ConversationPropertiesPanel({ }, 30000); // Update every 30 seconds return () => clearInterval(intervalId); - }, [isOpen, periodicConfig?.next_scheduled_at]); + }, [isOpen, loopConfig?.next_scheduled_at]); // Reset state when session changes or panel closes useEffect(() => { setIsEditingTitle(false); - setPeriodicConfig(null); + setLoopConfig(null); setCallbackConfig(null); setCallbackCopied(false); setFlagsError(null); setSavingFlags({}); }, [sessionId, isOpen]); - // Fetch periodic config, callback config, flags, and session settings when panel opens + // Fetch loop config, callback config, flags, and session settings when panel opens useEffect(() => { if (!isOpen || !sessionId) return; @@ -286,30 +301,30 @@ export function ConversationPropertiesPanel({ setIsLoadingFlags(true); setFlagsError(null); - // Periodic + callback endpoints only exist for periodic conversations. - // Gating on periodic_configured avoids 404 noise on regular sessions. - const periodicConfigured = sessionInfo?.periodic_configured === true; + // Loop + callback endpoints only exist for loop conversations. + // Gating on loop_configured avoids 404 noise on regular sessions. + const loopConfigured = sessionInfo?.loop_configured === true; try { - // Fetch periodic config, callback config, available flags, and session settings in parallel - const [periodicRes, callbackRes, flagsRes, settingsRes] = + // Fetch loop config, callback config, available flags, and session settings in parallel + const [loopRes, callbackRes, flagsRes, settingsRes] = await Promise.all([ - periodicConfigured - ? authFetch(endpoints.sessions.periodic(sessionId)) + loopConfigured + ? authFetch(endpoints.sessions.loop(sessionId)) : Promise.resolve(null), - periodicConfigured + loopConfigured ? authFetch(endpoints.sessions.callback(sessionId)) : Promise.resolve(null), authFetch(endpoints.misc.advancedFlags()), authFetch(endpoints.sessions.settings(sessionId)), ]); - if (periodicRes && periodicRes.ok) { - const periodic = await periodicRes.json(); - setPeriodicConfig(periodic); + if (loopRes && loopRes.ok) { + const loop = await loopRes.json(); + setLoopConfig(loop); } else { - // No periodic config or error - clear state - setPeriodicConfig(null); + // No loop config or error - clear state + setLoopConfig(null); } if (callbackRes && callbackRes.ok) { @@ -337,7 +352,7 @@ export function ConversationPropertiesPanel({ }; fetchData(); - }, [isOpen, sessionId, sessionInfo?.periodic_configured]); + }, [isOpen, sessionId, sessionInfo?.loop_configured]); // Focus title input when entering edit mode useEffect(() => { @@ -374,16 +389,16 @@ export function ConversationPropertiesPanel({ }; }, [isOpen, sessionId]); - // Listen for WebSocket periodic_updated events so the periodic section (and the + // Listen for WebSocket loop_updated events so the loop section (and the // fresh-context toggle) stays in sync when changed from another panel/client. useEffect(() => { if (!isOpen || !sessionId) return; - const handlePeriodicUpdated = (event) => { + const handleLoopUpdated = (event) => { const { sessionId: updatedSessionId, - periodicConfigured, - periodicEnabled, + loopConfigured, + loopEnabled, frequency, nextScheduledAt, freshContext, @@ -392,18 +407,18 @@ export function ConversationPropertiesPanel({ } = event.detail || {}; if (updatedSessionId !== sessionId) return; - // Periodic config was deleted — clear local state. - if (periodicConfigured === false) { - setPeriodicConfig(null); + // Loop config was deleted — clear local state. + if (loopConfigured === false) { + setLoopConfig(null); return; } // Merge into existing config (the panel fetches the full config on open). - setPeriodicConfig((prev) => + setLoopConfig((prev) => prev ? { ...prev, - enabled: periodicEnabled, + enabled: loopEnabled, frequency: frequency || prev.frequency, next_scheduled_at: nextScheduledAt ?? prev.next_scheduled_at, fresh_context: @@ -422,13 +437,13 @@ export function ConversationPropertiesPanel({ }; window.addEventListener( - "mitto:periodic_config_updated", - handlePeriodicUpdated, + "mitto:loop_config_updated", + handleLoopUpdated, ); return () => { window.removeEventListener( - "mitto:periodic_config_updated", - handlePeriodicUpdated, + "mitto:loop_config_updated", + handleLoopUpdated, ); }; }, [isOpen, sessionId]); @@ -509,7 +524,7 @@ export function ConversationPropertiesPanel({ [sessionId], ); - // Toggle "fresh context" for a periodic conversation. PATCHes the periodic + // Toggle "fresh context" for a loop conversation. PATCHes the loop // config so each scheduled run starts with a clean agent context (no history // injection, new ACP session). Updates local state optimistically from the // server-authoritative response. @@ -518,14 +533,14 @@ export function ConversationPropertiesPanel({ const newValue = e.target.checked; if (!sessionId) return; try { - const res = await secureFetch(endpoints.sessions.periodic(sessionId), { + const res = await secureFetch(endpoints.sessions.loop(sessionId), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fresh_context: newValue }), }); if (res.ok) { const data = await res.json(); - setPeriodicConfig((prev) => + setLoopConfig((prev) => prev ? { ...prev, fresh_context: data.fresh_context ?? newValue } : prev, @@ -874,6 +889,119 @@ export function ConversationPropertiesPanel({ </div> ` } + ${ + sessionInfo?.mcp_calls_total > 0 && + html` + <div class="flex justify-between"> + <span>Mitto MCP calls</span> + <span class="text-mitto-text-300" + >${sessionInfo.mcp_calls_total}</span + > + </div> + ` + } + ${ + sessionInfo?.mcp_ui_calls > 0 && + html` + <div class="flex justify-between"> + <span>Mitto UI calls</span> + <span class="text-mitto-text-300" + >${sessionInfo.mcp_ui_calls}</span + > + </div> + ` + } + ${ + sessionInfo?.mcp_children_wait_calls > 0 && + html` + <div class="flex justify-between"> + <span>Wait-for-children calls</span> + <span class="text-mitto-text-300" + >${sessionInfo.mcp_children_wait_calls}</span + > + </div> + ` + } + ${ + sessionInfo?.children_spawned > 0 && + html` + <div class="flex justify-between"> + <span>Children</span> + <span class="text-mitto-text-300" + >${sessionInfo.children_spawned}</span + > + </div> + ` + } + ${ + sessionInfo?.child_wait_count > 0 && + html` + <div class="flex justify-between"> + <span>Child wait</span> + <span class="text-mitto-text-300" + >${formatDuration( + sessionInfo.child_wait_total_ms / + sessionInfo.child_wait_count, + )} + avg (${formatDuration(sessionInfo.child_wait_total_ms)} + total)</span + > + </div> + ` + } + ${ + sessionInfo?.turns > 0 && + html` + <div class="flex justify-between"> + <span>Turns</span> + <span class="text-mitto-text-300">${sessionInfo.turns}</span> + </div> + ` + } + ${ + sessionInfo?.acp_tool_calls > 0 && + html` + <div class="flex justify-between"> + <span>Agent tool calls</span> + <span class="text-mitto-text-300" + >${sessionInfo.acp_tool_calls}</span + > + </div> + ` + } + ${ + (sessionInfo?.permissions_allowed > 0 || + sessionInfo?.permissions_denied > 0) && + html` + <div class="flex justify-between"> + <span>Permissions</span> + <span class="text-mitto-text-300" + >${sessionInfo.permissions_allowed || 0} allowed / + ${sessionInfo.permissions_denied || 0} denied</span + > + </div> + ` + } + ${ + sessionInfo?.errors > 0 && + html` + <div class="flex justify-between"> + <span>Errors</span> + <span class="text-mitto-text-300">${sessionInfo.errors}</span> + </div> + ` + } + ${ + sessionInfo?.images_uploaded > 0 && + html` + <div class="flex justify-between"> + <span>Images</span> + <span class="text-mitto-text-300" + >${sessionInfo.images_uploaded}</span + > + </div> + ` + } </div> ${ @@ -1000,6 +1128,45 @@ export function ConversationPropertiesPanel({ </div> ` } + + ${ + sessionInfo?.usage_cumulative && + html` + <div class="mt-2 pt-2 border-t border-mitto-border-1/50"> + <label + class="block text-xs font-medium text-mitto-text-500 mb-1" + > + Cumulative Tokens + </label> + <div class="text-xs text-mitto-text-secondary space-y-0.5"> + <div class="flex justify-between"> + <span>Input</span> + <span class="text-mitto-text-300" + >${formatTokenCount( + sessionInfo.usage_cumulative.input_tokens, + )}</span + > + </div> + <div class="flex justify-between"> + <span>Output</span> + <span class="text-mitto-text-300" + >${formatTokenCount( + sessionInfo.usage_cumulative.output_tokens, + )}</span + > + </div> + <div class="flex justify-between"> + <span>Total</span> + <span class="text-mitto-text-300 font-medium" + >${formatTokenCount( + sessionInfo.usage_cumulative.total_tokens, + )}</span + > + </div> + </div> + </div> + ` + } </div> <!-- Workspace Section --> @@ -1113,50 +1280,50 @@ export function ConversationPropertiesPanel({ ) } - <!-- Periodic Prompts Section (only shown when configured and enabled) --> + <!-- Loop Prompts Section (only shown when configured and enabled) --> ${ - periodicConfig?.enabled && + loopConfig?.enabled && html` <div> <label class="block text-sm font-medium text-mitto-text-secondary mb-2" > - Periodic Prompts + Loop Prompts </label> <div class="flex items-center gap-2 text-sm text-mitto-text-300"> - <${PeriodicFilledIcon} + <${LoopFilledIcon} className="w-4 h-4 shrink-0 text-mitto-accent" /> - <span>${formatFrequency(periodicConfig.frequency)}</span> + <span>${formatFrequency(loopConfig.frequency)}</span> </div> - ${periodicConfig.last_sent_at && + ${loopConfig.last_sent_at && html` <p class="mt-1 text-xs text-mitto-text-500"> Last run: - ${new Date(periodicConfig.last_sent_at).toLocaleString()} + ${new Date(loopConfig.last_sent_at).toLocaleString()} </p> `} - ${periodicConfig.next_scheduled_at && + ${loopConfig.next_scheduled_at && html` <p class="mt-1 text-xs text-mitto-text-500"> Next run: - ${new Date(periodicConfig.next_scheduled_at).toLocaleString()} + ${new Date(loopConfig.next_scheduled_at).toLocaleString()} <span class="text-mitto-text-secondary ml-1"> - (${formatRelativeTime(periodicConfig.next_scheduled_at)}) + (${formatRelativeTime(loopConfig.next_scheduled_at)}) </span> </p> `} <p class="mt-1 text-xs text-mitto-text-500"> - ${(periodicConfig.max_iterations ?? 0) > 0 - ? `Run ${periodicConfig.iteration_count ?? 0} of ${periodicConfig.max_iterations}` - : `${periodicConfig.iteration_count ?? 0} run${(periodicConfig.iteration_count ?? 0) !== 1 ? "s" : ""} · unlimited`} + ${(loopConfig.max_iterations ?? 0) > 0 + ? `Run ${loopConfig.iteration_count ?? 0} of ${loopConfig.max_iterations}` + : `${loopConfig.iteration_count ?? 0} run${(loopConfig.iteration_count ?? 0) !== 1 ? "s" : ""} · unlimited`} </p> <!-- Fresh context toggle: each scheduled run starts with a clean agent context --> <div class="mt-3 flex items-center gap-2 text-sm"> <input type="checkbox" id="properties-fresh-context-checkbox-${sessionId}" - checked=${!!periodicConfig.fresh_context} + checked=${!!loopConfig.fresh_context} onInput=${handleFreshContextChange} class="w-4 h-4 rounded border-mitto-border-3 text-mitto-accent focus:ring-mitto-accent-500 cursor-pointer shrink-0" data-testid="properties-fresh-context-checkbox" @@ -1229,22 +1396,22 @@ export function ConversationPropertiesPanel({ } function renderAdvancedSection() { - // Only show if there are available flags or periodic config (for callback URL) - if ((!availableFlags || availableFlags.length === 0) && !periodicConfig) { + // Only show if there are available flags or loop config (for callback URL) + if ((!availableFlags || availableFlags.length === 0) && !loopConfig) { return null; } return html` <div class="pt-4"> - <!-- Callback URL Section (only for periodic conversations) --> - ${periodicConfig && + <!-- Callback URL Section (only for loop conversations) --> + ${loopConfig && html` <div class="mb-4"> <label class="block text-sm font-medium text-mitto-text-secondary mb-2" >Callback URL</label > - ${periodicConfig.enabled + ${loopConfig.enabled ? html` ${callbackConfig?.callback_url ? html` @@ -1259,7 +1426,7 @@ export function ConversationPropertiesPanel({ </div> ` : html` - <${Tooltip} tip="Generate a callback URL for triggering this periodic conversation externally" placement="top"> + <${Tooltip} tip="Generate a callback URL for triggering this loop conversation externally" placement="top"> <button onClick=${handleEnableCallback} class="text-xs px-2 py-1 rounded bg-mitto-surface-3 hover:bg-mitto-surface-hover text-mitto-text-300 transition-colors"> 🔗 Enable Callback URL </button> @@ -1270,7 +1437,7 @@ export function ConversationPropertiesPanel({ ${callbackConfig?.callback_url ? html` <p class="text-xs text-mitto-text-muted mb-1.5 italic"> - Preserved but inactive while periodic is disabled + Preserved but inactive while loop is disabled </p> <div class="flex items-center gap-1.5"> <button diff --git a/web/static/components/IconPicker.js b/web/static/components/IconPicker.js index bf71e6d2..28b88f2d 100644 --- a/web/static/components/IconPicker.js +++ b/web/static/components/IconPicker.js @@ -54,7 +54,9 @@ export function IconPicker({ aria-label="Pick icon" aria-haspopup="true" aria-disabled=${disabled ? "true" : "false"} - class="btn btn-ghost btn-square btn-sm ${className} ${disabled ? "btn-disabled" : ""}" + class="btn btn-ghost btn-square btn-sm ${className} ${disabled + ? "btn-disabled" + : ""}" > <span class="w-4 h-4 ${hasIcon ? "" : "opacity-40"}"> <${CurrentIcon} className="w-4 h-4" /> @@ -62,47 +64,58 @@ export function IconPicker({ </div> <div tabindex="0" - class="dropdown-content z-50 flex flex-wrap gap-1 p-2 w-64 bg-base-200 rounded-box shadow-xl" + class="dropdown-content z-50 p-2 w-64 bg-base-200 rounded-box shadow-xl" role="listbox" aria-label="Available icons" > - <!-- Default option: clears the override so the prompt's own icon is - used. Styled distinctively (accent dashed border + accent tint) to - stand apart from the concrete icon choices. --> - <button - type="button" - role="option" - aria-selected=${!hasIcon} - aria-label="Use the prompt's own icon" - title="Use the prompt's own icon" - onClick=${(ev) => handleSelect(ev, "")} - class="btn btn-ghost btn-square btn-sm border-2 border-dashed border-mitto-accent text-mitto-accent ${!hasIcon ? "bg-base-300 ring-1 ring-mitto-accent" : ""}" - > - <span class="w-4 h-4"> - <${DefaultIcon} className="w-4 h-4" /> - </span> - </button> - ${iconNames.map((name) => { - const Icon = PROMPT_ICONS[name]; - const isSelected = - (value || "").trim().toLowerCase() === name.trim().toLowerCase(); - return html` - <button - key=${name} - type="button" - role="option" - aria-selected=${isSelected} - aria-label=${name} - title=${name} - onClick=${(ev) => handleSelect(ev, name)} - class="btn btn-ghost btn-square btn-sm ${isSelected ? "bg-base-300" : ""}" - > - <span class="w-4 h-4"> - <${Icon} className="w-4 h-4" /> - </span> - </button> - `; - })} + <!-- The flex grid lives on an INNER wrapper, never on .dropdown-content + itself: a display utility (flex) placed directly on + .dropdown-content lands in Tailwind's utilities layer and would + override daisyUI's display:none collapse rule, leaving the grid + permanently laid out (invisible but click-intercepting). --> + <div class="flex flex-wrap gap-1"> + <!-- Default option: clears the override so the prompt's own icon is + used. Styled distinctively (accent dashed border + accent tint) to + stand apart from the concrete icon choices. --> + <button + type="button" + role="option" + aria-selected=${!hasIcon} + aria-label="Use the prompt's own icon" + title="Use the prompt's own icon" + onClick=${(ev) => handleSelect(ev, "")} + class="btn btn-ghost btn-square btn-sm border-2 border-dashed border-mitto-accent text-mitto-accent ${!hasIcon + ? "bg-base-300 ring-1 ring-mitto-accent" + : ""}" + > + <span class="w-4 h-4"> + <${DefaultIcon} className="w-4 h-4" /> + </span> + </button> + ${iconNames.map((name) => { + const Icon = PROMPT_ICONS[name]; + const isSelected = + (value || "").trim().toLowerCase() === name.trim().toLowerCase(); + return html` + <button + key=${name} + type="button" + role="option" + aria-selected=${isSelected} + aria-label=${name} + title=${name} + onClick=${(ev) => handleSelect(ev, name)} + class="btn btn-ghost btn-square btn-sm ${isSelected + ? "bg-base-300" + : ""}" + > + <span class="w-4 h-4"> + <${Icon} className="w-4 h-4" /> + </span> + </button> + `; + })} + </div> </div> </div> `; diff --git a/web/static/components/Icons.js b/web/static/components/Icons.js index 18a51f6f..2324aabd 100644 --- a/web/static/components/Icons.js +++ b/web/static/components/Icons.js @@ -868,10 +868,11 @@ export function ArchiveFilledIcon({ className = "w-4 h-4" }) { } /** - * Periodic/repeat icon (circular arrow) for recurring conversations + * Loop-off icon (single circular arrow with a diagonal slash) for removing the + * recurring/loop state — the visual opposite of LoopIcon. * @param {string} className - CSS classes (default: 'w-4 h-4') */ -export function PeriodicIcon({ className = "w-4 h-4" }) { +export function LoopOffIcon({ className = "w-4 h-4" }) { return html` <svg class="${className}" @@ -883,19 +884,25 @@ export function PeriodicIcon({ className = "w-4 h-4" }) { stroke-linecap="round" stroke-linejoin="round" stroke-width="2" - d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" + d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10M23 4v6h-6" + /> + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M4 20L20 4" /> </svg> `; } /** - * Periodic filled icon for active periodic state + * Loop filled icon for active loop state * Shows a filled circular badge with contrasting white arrows inside - * Creates an "inverted" look compared to the outline PeriodicIcon + * Creates an "inverted" look compared to the outline LoopIcon * @param {string} className - CSS classes (default: 'w-4 h-4') */ -export function PeriodicFilledIcon({ className = "w-4 h-4" }) { +export function LoopFilledIcon({ className = "w-4 h-4" }) { return html` <svg class="${className}" viewBox="0 0 24 24"> <!-- Filled circle background using currentColor (will be blue/colored) --> @@ -919,10 +926,32 @@ export function PeriodicFilledIcon({ className = "w-4 h-4" }) { `; } +/** + * Loop/repeat icon (single circular arrow) for recurring conversations + * @param {string} className - CSS classes (default: 'w-4 h-4') + */ +export function LoopIcon({ className = "w-4 h-4" }) { + return html` + <svg + class="${className}" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10M23 4v6h-6" + /> + </svg> + `; +} + /** * Play filled icon for "run now" action * Shows a filled circular badge with a white triangle (play arrow) inside - * Similar style to PeriodicFilledIcon but indicates "run/play" action + * Similar style to LoopFilledIcon but indicates "run/play" action * @param {string} className - CSS classes (default: 'w-4 h-4') */ export function PlayFilledIcon({ className = "w-4 h-4" }) { @@ -998,7 +1027,7 @@ export function ChevronRightIcon({ className = "w-4 h-4" }) { `; } -// Lock icon (closed padlock) - for locked periodic prompt state +// Lock icon (closed padlock) - for locked loop prompt state export function LockIcon({ className = "w-5 h-5" }) { return html` <svg @@ -1029,7 +1058,7 @@ export function LockIcon({ className = "w-5 h-5" }) { `; } -// Unlock icon (open padlock) - for unlocked periodic prompt state +// Unlock icon (open padlock) - for unlocked loop prompt state export function UnlockIcon({ className = "w-5 h-5" }) { return html` <svg @@ -1451,7 +1480,7 @@ export function MittoIcon({ className = "w-4 h-4" }) { } /** - * Clock icon for periodic (recurring) conversations + * Clock icon for loop (recurring) conversations * @param {string} className - CSS classes (default: 'w-4 h-4') */ export function ClockIcon({ className = "w-4 h-4" }) { @@ -1565,7 +1594,7 @@ export const PROMPT_ICONS = { duplicate: DuplicateIcon, pin: PinIcon, archive: ArchiveIcon, - periodic: PeriodicIcon, + loop: LoopIcon, queue: QueueIcon, play: PlayFilledIcon, }; diff --git a/web/static/components/PeriodicFrequencyPanel.js b/web/static/components/LoopFrequencyPanel.js similarity index 88% rename from web/static/components/PeriodicFrequencyPanel.js rename to web/static/components/LoopFrequencyPanel.js index 957aaedd..7cfaf9f6 100644 --- a/web/static/components/PeriodicFrequencyPanel.js +++ b/web/static/components/LoopFrequencyPanel.js @@ -1,18 +1,18 @@ -// Mitto Web Interface - Periodic Frequency Panel Component +// Mitto Web Interface - Loop Frequency Panel Component // Single merged card: compact header (always visible) + collapsible body (settings). const { useState, useEffect, useCallback, useMemo, useRef, html, Fragment } = window.preact; import { - PeriodicFilledIcon, + LoopFilledIcon, PlayFilledIcon, PauseFilledIcon, ChatBubbleIcon, SlidersIcon, } from "./Icons.js"; import { promptParameters } from "../utils/prompts.js"; -import { PeriodicPromptSelector } from "./PeriodicPromptSelector.js"; +import { LoopPromptSelector } from "./LoopPromptSelector.js"; import { ConfirmDialog } from "./ConfirmDialog.js"; import { secureFetch, authFetch } from "../utils/csrf.js"; import { apiUrl, errorMessageFromData } from "../utils/api.js"; @@ -30,7 +30,7 @@ const MIN_COMPLETION_DELAY_SECONDS = 5; /** * Schedules that repeat more frequently than this (in seconds) are considered - * "too frequent" for an unbounded periodic conversation and trigger the + * "too frequent" for an unbounded loop conversation and trigger the * dangerous-config warning on save. 5 minutes. */ const DANGEROUS_FREQUENCY_SECONDS = 5 * 60; @@ -38,13 +38,13 @@ const DANGEROUS_FREQUENCY_SECONDS = 5 * 60; // Hover-only tooltips are pointless on touch devices (no hover); gate the portal // header tooltips the same way daisyUI gates its CSS tooltips so taps never // trigger a stuck bubble. -const PERIODIC_SUPPORTS_HOVER = +const LOOP_SUPPORTS_HOVER = typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(hover: hover)").matches; // Delay before a header tooltip appears on hover (ms). -const PERIODIC_TOOLTIP_DELAY_MS = 250; +const LOOP_TOOLTIP_DELAY_MS = 250; /** * Convert a numeric value + unit string into total seconds. @@ -130,13 +130,13 @@ function localToUtcTime(localTime) { } /** - * PeriodicFrequencyPanel component - merged periodic settings card. + * LoopFrequencyPanel component - merged loop settings card. * Header (always visible when isOpen): run-now, prompt selector, status, pause/resume, expand toggle. * Body (collapsed by default): frequency inputs, fresh-context, max-runs. * * @param {Object} props - * @param {boolean} props.isOpen - Whether the card is visible (shown when periodic is enabled) - * @param {boolean} props.disabled - true when periodic is active/enabled (controls pause vs resume label) + * @param {boolean} props.isOpen - Whether the card is visible (shown when loop is enabled) + * @param {boolean} props.disabled - true when loop is active/enabled (controls pause vs resume label) * @param {string} props.sessionId - Current session ID * @param {Object} props.frequency - Current frequency config { value, unit, at } (at is in UTC) * @param {Function} props.onFrequencyChange - Callback when frequency is updated @@ -147,15 +147,15 @@ function localToUtcTime(localTime) { * @param {number} props.maxIterations - Maximum number of runs (0 = unlimited) * @param {number} props.iterationCount - Number of runs delivered so far * @param {Function} props.onMaxIterationsChange - Callback when max iterations is updated - * @param {Function} props.onPeriodicEnabledChange - Callback when periodic is paused/resumed + * @param {Function} props.onLoopEnabledChange - Callback when loop is paused/resumed * @param {Array} props.prompts - Available workspace prompts for the inline selector - * @param {string} props.selectedPromptName - Currently selected periodic prompt name - * @param {string} props.selectedPromptBody - Free-text periodic prompt body (used when no named prompt is set) + * @param {string} props.selectedPromptName - Currently selected loop prompt name + * @param {string} props.selectedPromptBody - Free-text loop prompt body (used when no named prompt is set) * @param {Function} props.onPromptSelect - Callback when a prompt is selected: (promptName) => void * @param {boolean} props.isPromptAreaVisible - Whether the prompt composition area is visible * @param {Function} props.onTogglePromptArea - Callback to toggle prompt composition area visibility */ -export function PeriodicFrequencyPanel({ +export function LoopFrequencyPanel({ isOpen, disabled = false, sessionId, @@ -168,7 +168,7 @@ export function PeriodicFrequencyPanel({ maxIterations = 0, iterationCount = 0, onMaxIterationsChange, - onPeriodicEnabledChange, + onLoopEnabledChange, prompts = [], selectedPromptName = "", selectedPromptBody = "", @@ -215,9 +215,9 @@ export function PeriodicFrequencyPanel({ const [isTriggering, setIsTriggering] = useState(false); // Confirmation dialog state const [showConfirmDialog, setShowConfirmDialog] = useState(false); - // Restore-periodic confirmation dialog state (shown when re-enabling a paused schedule) + // Restore-loop confirmation dialog state (shown when re-enabling a paused schedule) const [showRestoreDialog, setShowRestoreDialog] = useState(false); - // Dangerous-config confirmation dialog state (shown on Save for new, unbounded periodics) + // Dangerous-config confirmation dialog state (shown on Save for new, unbounded loops) const [showDangerDialog, setShowDangerDialog] = useState(false); // Reset timer checkbox state (default true = reset the countdown after manual run) const [resetTimer, setResetTimer] = useState(true); @@ -275,13 +275,13 @@ export function PeriodicFrequencyPanel({ const [headerTip, setHeaderTip] = useState(null); const headerTipTimerRef = useRef(null); const showHeaderTip = useCallback((e, text) => { - if (!PERIODIC_SUPPORTS_HOVER || !text) return; + if (!LOOP_SUPPORTS_HOVER || !text) return; const x = e.clientX; const y = e.clientY; clearTimeout(headerTipTimerRef.current); headerTipTimerRef.current = setTimeout( () => setHeaderTip({ x, y, text }), - PERIODIC_TOOLTIP_DELAY_MS, + LOOP_TOOLTIP_DELAY_MS, ); }, []); const hideHeaderTip = useCallback(() => { @@ -357,7 +357,7 @@ export function PeriodicFrequencyPanel({ setLocalMaxDurUnit(unit); }, [maxDurationSeconds]); // Sync onTasks condition/preset from props (server-authoritative updates, - // e.g. GET on load or a periodic_updated broadcast from another client). + // e.g. GET on load or a loop_updated broadcast from another client). useEffect(() => { const id = resolveConditionPresetId(condition, conditionPreset); setLocalCondition(condition || ""); @@ -403,15 +403,15 @@ export function PeriodicFrequencyPanel({ conditionPreset, ]); - // Derived: whether this periodic is in on-completion / on-tasks mode + // Derived: whether this loop is in on-completion / on-tasks mode const isOnCompletion = localTrigger === "onCompletion"; const isOnTasks = localTrigger === "onTasks"; - // A "new" periodic conversation is one that has never delivered a run yet + // A "new" loop conversation is one that has never delivered a run yet // (iteration_count is incremented only on actual delivery). Safety pre-fills // and the dangerous-config warning apply only while it is still new — once it // has started running we respect whatever the user has configured. - const isNewPeriodic = iterationCount === 0; + const isNewLoop = iterationCount === 0; // Staged config has no upper bound on runs or wall-clock time. const stagedHasNoLimits = useMemo( @@ -432,10 +432,10 @@ export function PeriodicFrequencyPanel({ ); }, [localTrigger, localValue, localUnit]); - // Warn before saving a brand-new periodic conversation that could loop + // Warn before saving a brand-new loop conversation that could loop // indefinitely (dangerous cadence with no run/time limit). const needsDangerWarning = - isNewPeriodic && stagedHasDangerousCadence && stagedHasNoLimits; + isNewLoop && stagedHasDangerousCadence && stagedHasNoLimits; // Human-readable reason shown in the dangerous-config confirmation dialog. const dangerReason = isOnCompletion @@ -444,7 +444,7 @@ export function PeriodicFrequencyPanel({ ? "it fires every time a matching task change occurs" : `it repeats every ${localValue} ${localUnit}`; const dangerMessage = - `This periodic conversation has no limit on the number of runs or total ` + + `This loop conversation has no limit on the number of runs or total ` + `time, and ${dangerReason}. It could keep running indefinitely. ` + `Set a "Max runs" or "Max time" limit, or save anyway?`; @@ -485,7 +485,7 @@ export function PeriodicFrequencyPanel({ } const response = await secureFetch( - endpoints.sessions.periodic(sessionId), + endpoints.sessions.loop(sessionId), { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -518,9 +518,9 @@ export function PeriodicFrequencyPanel({ const errorData = await response.json().catch(() => ({})); const msg = errorMessageFromData( errorData, - "Failed to save periodic settings", + "Failed to save loop settings", ); - console.error("Failed to save periodic settings:", msg); + console.error("Failed to save loop settings:", msg); // Surface invalid-CEL (and other onTasks) rejections inline near the // condition editor instead of failing silently. if (localTrigger === "onTasks") { @@ -528,7 +528,7 @@ export function PeriodicFrequencyPanel({ } } } catch (err) { - console.error("Failed to save periodic settings:", err); + console.error("Failed to save loop settings:", err); } finally { setIsSaving(false); } @@ -558,7 +558,7 @@ export function PeriodicFrequencyPanel({ onConditionPresetChange, ]); - // Save entry point (Save button). For a brand-new periodic conversation with + // Save entry point (Save button). For a brand-new loop conversation with // a dangerous, unbounded cadence, confirm first; otherwise persist directly. const handleSaveAll = useCallback(() => { if (isSaving) return; @@ -614,7 +614,7 @@ export function PeriodicFrequencyPanel({ setIsTriggering(true); try { const response = await secureFetch( - endpoints.sessions.periodicRunNow(sessionId), + endpoints.sessions.loopRunNow(sessionId), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -637,7 +637,7 @@ export function PeriodicFrequencyPanel({ } return; // Don't close dialog on error } - // Success - the WebSocket will notify us of the periodic_started event + // Success - the WebSocket will notify us of the loop_started event setShowConfirmDialog(false); } catch (err) { console.error("Failed to trigger immediate delivery:", err); @@ -681,11 +681,11 @@ export function PeriodicFrequencyPanel({ ); } // Pre-fill safety limits (5 runs, 1h max time) only for brand-new - // periodic conversations switching to an event-driven trigger; never + // loop conversations switching to an event-driven trigger; never // override an established config. if ( (newTrigger === "onCompletion" || newTrigger === "onTasks") && - isNewPeriodic + isNewLoop ) { setLocalMaxIterations((prev) => (prev > 0 ? prev : 5)); if (valueUnitToSeconds(localMaxDurValue, localMaxDurUnit) === 0) { @@ -694,7 +694,7 @@ export function PeriodicFrequencyPanel({ } } }, - [isNewPeriodic, localMaxDurValue, localMaxDurUnit, minDelaySeconds], + [isNewLoop, localMaxDurValue, localMaxDurUnit, minDelaySeconds], ); // Clamp the on-completion delay to the minimum on blur (staged) @@ -754,7 +754,7 @@ export function PeriodicFrequencyPanel({ setIsSavingEnabled(true); try { const response = await secureFetch( - endpoints.sessions.periodic(sessionId), + endpoints.sessions.loop(sessionId), { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -762,16 +762,16 @@ export function PeriodicFrequencyPanel({ }, ); if (response.ok) { - if (onPeriodicEnabledChange) onPeriodicEnabledChange(newEnabled); + if (onLoopEnabledChange) onLoopEnabledChange(newEnabled); } else { - console.error("Failed to update periodic enabled"); + console.error("Failed to update loop enabled"); } } catch (err) { - console.error("Failed to update periodic enabled:", err); + console.error("Failed to update loop enabled:", err); } finally { setIsSavingEnabled(false); } - }, [sessionId, disabled, isSavingEnabled, onPeriodicEnabledChange]); + }, [sessionId, disabled, isSavingEnabled, onLoopEnabledChange]); // Handle click on the play button while paused - show restore confirmation const handleRestoreClick = useCallback(() => { @@ -779,7 +779,7 @@ export function PeriodicFrequencyPanel({ setShowRestoreDialog(true); }, [isSavingEnabled, sessionId]); - // Handle confirmation of restoring (re-enabling) the periodic schedule + // Handle confirmation of restoring (re-enabling) the loop schedule const handleConfirmRestore = useCallback(async () => { if (!sessionId) return; setIsSavingEnabled(true); @@ -795,7 +795,7 @@ export function PeriodicFrequencyPanel({ body.reset_counters = true; } const response = await secureFetch( - endpoints.sessions.periodic(sessionId), + endpoints.sessions.loop(sessionId), { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -803,23 +803,23 @@ export function PeriodicFrequencyPanel({ }, ); if (response.ok) { - if (onPeriodicEnabledChange) onPeriodicEnabledChange(true); + if (onLoopEnabledChange) onLoopEnabledChange(true); setShowRestoreDialog(false); } else { - console.error("Failed to restore periodic schedule"); + console.error("Failed to restore loop schedule"); setErrorMessage( - "Failed to restore the periodic schedule. Please try again.", + "Failed to restore the loop schedule. Please try again.", ); } } catch (err) { - console.error("Failed to restore periodic schedule:", err); + console.error("Failed to restore loop schedule:", err); setErrorMessage( - "Failed to restore the periodic schedule. Please try again.", + "Failed to restore the loop schedule. Please try again.", ); } finally { setIsSavingEnabled(false); } - }, [sessionId, onPeriodicEnabledChange, stoppedReason, resetCounters]); + }, [sessionId, onLoopEnabledChange, stoppedReason, resetCounters]); // Handle cancellation of the restore confirmation dialog const handleCancelRestore = useCallback(() => { @@ -828,7 +828,7 @@ export function PeriodicFrequencyPanel({ // Panel classes - part of normal document flow (not absolute positioned). // overflow-visible allows the prompt-selector dropdown to escape the card boundary upward. - const panelClasses = `periodic-frequency-panel w-full bg-mitto-surface-hover dark:bg-mitto-surface-3/95 backdrop-blur-sm border border-mitto-border dark:border-mitto-border-2 rounded-lg overflow-visible transition-all duration-300 ease-out ${ + const panelClasses = `loop-frequency-panel w-full bg-mitto-surface-hover dark:bg-mitto-surface-3/95 backdrop-blur-sm border border-mitto-border dark:border-mitto-border-2 rounded-lg overflow-visible transition-all duration-300 ease-out ${ isOpen ? "opacity-100 mb-3" : "opacity-0 pointer-events-none h-0 border-0 mb-0" @@ -836,10 +836,10 @@ export function PeriodicFrequencyPanel({ const panelStyle = isOpen ? "" : "height: 0px;"; - // The `disabled` prop is true when periodic is ACTIVE/enabled. When the schedule has - // been paused (e.g. the conversation disabled its own periodic via MCP), the + // The `disabled` prop is true when loop is ACTIVE/enabled. When the schedule has + // been paused (e.g. the conversation disabled its own loop via MCP), the // play button restores the schedule and the pause button is greyed out. - const periodicPaused = !disabled; + const loopPaused = !disabled; // When the loop was auto-stopped by a cap (max-duration / max-iterations), the // restore dialog offers to reset the elapsed iterations and elapsed time so the @@ -863,7 +863,7 @@ export function PeriodicFrequencyPanel({ : "maximum number of iterations"; const restoreMessage = limitStopped ? `This conversation stopped because it reached its ${stoppedReasonText}. Restore it to keep iterating.` - : "Do you want to restore the periodic schedule for this conversation?"; + : "Do you want to restore the loop schedule for this conversation?"; // Compute whether the edit-arguments button should be enabled const selectedPrompt = selectedPromptName @@ -900,10 +900,10 @@ export function PeriodicFrequencyPanel({ </label> </${ConfirmDialog}> - <!-- Confirmation dialog for restoring a paused periodic schedule --> + <!-- Confirmation dialog for restoring a paused loop schedule --> <${ConfirmDialog} isOpen=${showRestoreDialog} - title="Restore periodic schedule" + title="Restore loop schedule" message=${restoreMessage} confirmLabel="Restore" cancelLabel="Cancel" @@ -956,26 +956,26 @@ export function PeriodicFrequencyPanel({ <div class="${panelClasses}" style="${panelStyle}" - data-testid="periodic-frequency-panel" + data-testid="loop-frequency-panel" > <!-- HEADER: always visible when isOpen (single ~44px row) --> <div class="h-11 px-3 flex items-center gap-2 text-sm"> - <!-- Play button: runs the prompt now when periodic is active, or - restores (re-enables) the schedule when periodic is paused. --> + <!-- Play button: runs the prompt now when loop is active, or + restores (re-enables) the schedule when loop is paused. --> <button type="button" - onClick=${periodicPaused ? handleRestoreClick : handleIconClick} - onMouseEnter=${(e) => showHeaderTip(e, periodicPaused ? "Restore periodic schedule" : isStreaming ? "Wait for agent to finish responding" : "Run this periodic prompt now")} + onClick=${loopPaused ? handleRestoreClick : handleIconClick} + onMouseEnter=${(e) => showHeaderTip(e, loopPaused ? "Restore loop schedule" : isStreaming ? "Wait for agent to finish responding" : "Run this loop prompt now")} onMouseLeave=${hideHeaderTip} onMouseDown=${hideHeaderTip} - disabled=${periodicPaused ? isSavingEnabled : isTriggering || isStreaming} - class="shrink-0 p-1.5 rounded border border-mitto-border dark:border-mitto-border-2 bg-white dark:bg-mitto-surface-2 transition-colors ${(periodicPaused ? isSavingEnabled : isTriggering || isStreaming) ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:bg-mitto-surface-hover dark:hover:bg-mitto-surface-3"}" - data-tip=${periodicPaused ? "Restore periodic schedule" : isStreaming ? "Wait for agent to finish responding" : "Run this periodic prompt now"} - aria-label=${periodicPaused ? "Restore periodic schedule" : isStreaming ? "Wait for agent to finish responding" : "Run this periodic prompt now"} - data-testid="periodic-run-now-button" + disabled=${loopPaused ? isSavingEnabled : isTriggering || isStreaming} + class="shrink-0 p-1.5 rounded border border-mitto-border dark:border-mitto-border-2 bg-white dark:bg-mitto-surface-2 transition-colors ${(loopPaused ? isSavingEnabled : isTriggering || isStreaming) ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:bg-mitto-surface-hover dark:hover:bg-mitto-surface-3"}" + data-tip=${loopPaused ? "Restore loop schedule" : isStreaming ? "Wait for agent to finish responding" : "Run this loop prompt now"} + aria-label=${loopPaused ? "Restore loop schedule" : isStreaming ? "Wait for agent to finish responding" : "Run this loop prompt now"} + data-testid="loop-run-now-button" > ${ - (periodicPaused ? isSavingEnabled : isTriggering) + (loopPaused ? isSavingEnabled : isTriggering) ? html`<span class="loading loading-spinner w-4 h-4 text-mitto-text-secondary" ></span>` @@ -985,22 +985,22 @@ export function PeriodicFrequencyPanel({ } </button> - <!-- Pause button: pauses periodic runs when active; greyed out when + <!-- Pause button: pauses loop runs when active; greyed out when already paused (use the play button to restore the schedule). --> <button type="button" onClick=${handlePauseResume} - onMouseEnter=${(e) => showHeaderTip(e, periodicPaused ? "Periodic runs are paused" : "Pause periodic runs")} + onMouseEnter=${(e) => showHeaderTip(e, loopPaused ? "Loop runs are paused" : "Pause loop runs")} onMouseLeave=${hideHeaderTip} onMouseDown=${hideHeaderTip} - disabled=${periodicPaused || isSavingEnabled} - class="shrink-0 p-1.5 rounded border border-mitto-border dark:border-mitto-border-2 bg-white dark:bg-mitto-surface-2 transition-colors ${periodicPaused || isSavingEnabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:bg-mitto-surface-hover dark:hover:bg-mitto-surface-3"}" - data-tip=${periodicPaused ? "Periodic runs are paused" : "Pause periodic runs"} - aria-label=${periodicPaused ? "Periodic runs are paused" : "Pause periodic runs"} - data-testid="periodic-pause-resume-button" + disabled=${loopPaused || isSavingEnabled} + class="shrink-0 p-1.5 rounded border border-mitto-border dark:border-mitto-border-2 bg-white dark:bg-mitto-surface-2 transition-colors ${loopPaused || isSavingEnabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:bg-mitto-surface-hover dark:hover:bg-mitto-surface-3"}" + data-tip=${loopPaused ? "Loop runs are paused" : "Pause loop runs"} + aria-label=${loopPaused ? "Loop runs are paused" : "Pause loop runs"} + data-testid="loop-pause-resume-button" > ${ - !periodicPaused && isSavingEnabled + !loopPaused && isSavingEnabled ? html`<span class="loading loading-spinner w-4 h-4 text-mitto-text-secondary" ></span>` @@ -1014,7 +1014,7 @@ export function PeriodicFrequencyPanel({ breakpoints so the prompt stays reachable without expanding the properties section. --> <div class="min-w-0"> - <${PeriodicPromptSelector} + <${LoopPromptSelector} prompts=${prompts} selectedPromptName=${selectedPromptName} selectedPromptBody=${selectedPromptBody} @@ -1036,7 +1036,7 @@ export function PeriodicFrequencyPanel({ class="shrink-0 p-1.5 rounded border border-mitto-border dark:border-mitto-border-2 bg-white dark:bg-mitto-surface-2 transition-colors ${!canEditArgs ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:bg-mitto-surface-hover dark:hover:bg-mitto-surface-3"}" data-tip="Set prompt arguments" aria-label="Set prompt arguments" - data-testid="periodic-edit-args-button" + data-testid="loop-edit-args-button" > <${SlidersIcon} className="w-4 h-4 text-mitto-text-secondary" /> </button> @@ -1054,7 +1054,7 @@ export function PeriodicFrequencyPanel({ onClick=${handleSaveAll} disabled=${isSaving} class="btn btn-primary btn-sm shrink-0" - data-testid="periodic-save-button" + data-testid="loop-save-button" > ${isSaving ? html`<span class="loading loading-spinner w-4 h-4"></span>` @@ -1088,7 +1088,7 @@ export function PeriodicFrequencyPanel({ aria-label=${isPromptAreaVisible ? "Hide message input" : "Show message input"} - data-testid="periodic-toggle-prompt-area" + data-testid="loop-toggle-prompt-area" > <${ChatBubbleIcon} className="w-4 h-4 text-mitto-text-secondary" @@ -1106,7 +1106,7 @@ export function PeriodicFrequencyPanel({ class="shrink-0 p-1.5 rounded border border-mitto-border dark:border-mitto-border-2 bg-white dark:bg-mitto-surface-2 cursor-pointer hover:bg-mitto-surface-hover dark:hover:bg-mitto-surface-3 transition-colors" data-tip=${expanded ? "Collapse settings" : "Expand settings"} aria-label=${expanded ? "Collapse settings" : "Expand settings"} - data-testid="periodic-expand-toggle" + data-testid="loop-expand-toggle" > <svg class="w-4 h-4 text-mitto-text-secondary transition-transform duration-200 ${expanded ? "rotate-180" : ""}" @@ -1142,35 +1142,35 @@ export function PeriodicFrequencyPanel({ <div class="tabs tabs-border px-4 pt-2"> <input type="radio" - name="periodic-trigger-${sessionId}" + name="loop-trigger-${sessionId}" role="tab" aria-label="Schedule" class="tab text-sm" checked=${localTrigger === "schedule"} onChange=${() => handleTriggerSelect("schedule")} - data-testid="periodic-trigger-tab-schedule" + data-testid="loop-trigger-tab-schedule" /> <input type="radio" - name="periodic-trigger-${sessionId}" + name="loop-trigger-${sessionId}" role="tab" aria-label="On completion" class="tab text-sm" checked=${localTrigger === "onCompletion"} onChange=${() => handleTriggerSelect("onCompletion")} - data-testid="periodic-trigger-tab-oncompletion" + data-testid="loop-trigger-tab-oncompletion" /> ${hasBeadsWorkspace && html` <input type="radio" - name="periodic-trigger-${sessionId}" + name="loop-trigger-${sessionId}" role="tab" aria-label="On tasks" class="tab text-sm" checked=${localTrigger === "onTasks"} onChange=${() => handleTriggerSelect("onTasks")} - data-testid="periodic-trigger-tab-ontasks" + data-testid="loop-trigger-tab-ontasks" /> `} </div> @@ -1195,7 +1195,7 @@ export function PeriodicFrequencyPanel({ )} onBlur=${handleDelayBlur} class="input input-sm w-20 shrink-0 text-center" - data-testid="periodic-delay-input" + data-testid="loop-delay-input" /> <span class="text-xs text-mitto-text-muted dark:text-mitto-text-300 shrink-0" @@ -1207,7 +1207,7 @@ export function PeriodicFrequencyPanel({ ? html` <!-- On-tasks: condition editor (preset + advanced CEL) --> <div class="px-4 pt-2 pb-2 text-sm" - data-testid="periodic-condition-editor" + data-testid="loop-condition-editor" > <div class="flex items-center gap-3"> <span @@ -1218,7 +1218,7 @@ export function PeriodicFrequencyPanel({ value=${localPresetId} onChange=${handlePresetSelect} class="select select-sm shrink-0 flex-1" - data-testid="periodic-condition-preset-select" + data-testid="loop-condition-preset-select" > ${CONDITION_PRESETS.map( (p) => html`<option value=${p.id}>${p.label}</option>`, @@ -1244,7 +1244,7 @@ export function PeriodicFrequencyPanel({ onInput=${handlePresetParamChange} placeholder=${preset.paramPlaceholder} class="input input-sm flex-1" - data-testid="periodic-condition-preset-param" + data-testid="loop-condition-preset-param" /> </div> ` @@ -1267,7 +1267,7 @@ export function PeriodicFrequencyPanel({ placeholder="Empty = fire on any task change" rows="2" class="textarea textarea-sm w-full font-mono" - data-testid="periodic-condition-textarea" + data-testid="loop-condition-textarea" ></textarea> <div class="mt-2 text-mitto-text-muted dark:text-mitto-text-300"> Variables: @@ -1287,7 +1287,7 @@ export function PeriodicFrequencyPanel({ html` <div class="mt-2 text-xs text-mitto-danger" - data-testid="periodic-condition-error" + data-testid="loop-condition-error" > ${conditionError} </div> @@ -1372,7 +1372,7 @@ export function PeriodicFrequencyPanel({ value=${localMaxIterations} onInput=${handleMaxIterationsChange} class="input input-sm w-20 text-center shrink-0" - data-testid="periodic-panel-max-iterations" + data-testid="loop-panel-max-iterations" /> <span class="text-xs text-mitto-text-muted dark:text-mitto-text-300 shrink-0">(0 =${" "} <span class="text-lg leading-none align-middle">∞</span>)</span> @@ -1403,13 +1403,13 @@ export function PeriodicFrequencyPanel({ value=${localMaxDurValue} onInput=${(e) => setLocalMaxDurValue(Math.max(0, parseInt(e.target.value, 10) || 0))} class="input input-sm w-20 text-center shrink-0" - data-testid="periodic-max-duration-value" + data-testid="loop-max-duration-value" /> <select value=${localMaxDurUnit} onChange=${(e) => setLocalMaxDurUnit(e.target.value)} class="select select-sm shrink-0 w-24" - data-testid="periodic-max-duration-unit" + data-testid="loop-max-duration-unit" > <option value="minutes">minutes</option> <option value="hours">hours</option> diff --git a/web/static/components/PeriodicPromptSelector.js b/web/static/components/LoopPromptSelector.js similarity index 92% rename from web/static/components/PeriodicPromptSelector.js rename to web/static/components/LoopPromptSelector.js index ccb2113f..c8053e9b 100644 --- a/web/static/components/PeriodicPromptSelector.js +++ b/web/static/components/LoopPromptSelector.js @@ -1,6 +1,6 @@ -// Mitto Web Interface - Periodic Prompt Selector Component -// Dropdown for selecting a workspace prompt as the periodic prompt. -// Renders inline (no outer panel chrome) — meant to be embedded in PeriodicFrequencyPanel header. +// Mitto Web Interface - Loop Prompt Selector Component +// Dropdown for selecting a workspace prompt as the loop prompt. +// Renders inline (no outer panel chrome) — meant to be embedded in LoopFrequencyPanel header. const { useState, useEffect, useCallback, useRef, html } = window.preact; @@ -12,19 +12,19 @@ import { getPromptSortMode } from "../utils/storage.js"; const FREE_TEXT_PREVIEW_MAX = 40; /** - * PeriodicPromptSelector - inline dropdown for selecting a workspace prompt as the periodic prompt. + * LoopPromptSelector - inline dropdown for selecting a workspace prompt as the loop prompt. * Renders just the trigger button + dropdown popover (no outer panel chrome). - * The parent card (PeriodicFrequencyPanel) controls visibility via its own isOpen logic. + * The parent card (LoopFrequencyPanel) controls visibility via its own isOpen logic. * * @param {Object} props * @param {Array} props.prompts - Available workspace prompts (same as predefinedPrompts) - * @param {string} props.selectedPromptName - Currently selected prompt name (from periodic config) - * @param {string} props.selectedPromptBody - Free-text periodic prompt body (used when no named prompt is set) + * @param {string} props.selectedPromptName - Currently selected prompt name (from loop config) + * @param {string} props.selectedPromptBody - Free-text loop prompt body (used when no named prompt is set) * @param {boolean} props.disabled - Whether the selector is read-only * @param {Function} props.onSelect - Callback when a prompt is selected: (promptName) => void * @param {boolean} props.isOpen - Kept for API compat; parent card controls visibility now (ignored here) */ -export function PeriodicPromptSelector({ +export function LoopPromptSelector({ prompts = [], selectedPromptName = "", selectedPromptBody = "", @@ -36,7 +36,7 @@ export function PeriodicPromptSelector({ fullWidth = false, // Testid roots. Distinct prefixes let multiple instances (header + mobile // body) coexist in the DOM without breaking strict-mode Playwright locators. - idPrefix = "periodic-prompt-selector", + idPrefix = "loop-prompt-selector", }) { const [showDropdown, setShowDropdown] = useState(false); const [filterText, setFilterText] = useState(""); @@ -203,7 +203,7 @@ export function PeriodicPromptSelector({ showSourceBadge=${true} placeholder="Filter prompts..." emptyText="No matching prompts" - keyPrefix="periodic-prompts" + keyPrefix="loop-prompts" filterTestId="${idPrefix}-search" listTestId="${idPrefix}-list" /> diff --git a/web/static/components/PeriodicScheduleDialog.js b/web/static/components/LoopScheduleDialog.js similarity index 89% rename from web/static/components/PeriodicScheduleDialog.js rename to web/static/components/LoopScheduleDialog.js index 95f5fb4c..8352c61f 100644 --- a/web/static/components/PeriodicScheduleDialog.js +++ b/web/static/components/LoopScheduleDialog.js @@ -1,6 +1,6 @@ -// Mitto Web Interface - Periodic Schedule Dialog Component -// A modal dialog for collecting a periodic schedule (value, unit, optional at time) -// pre-filled from a prompt's `periodic` frontmatter defaults. +// Mitto Web Interface - Loop Schedule Dialog Component +// A modal dialog for collecting a loop schedule (value, unit, optional at time) +// pre-filled from a prompt's `loop` frontmatter defaults. const { useState, useEffect, useCallback, html, Fragment } = window.preact; import { Modal } from "./Modal.js"; @@ -85,25 +85,25 @@ function localToUtcTime(localTime) { } /** - * PeriodicScheduleDialog — modal to collect a periodic schedule for a prompt. + * LoopScheduleDialog — modal to collect a loop schedule for a prompt. * - * Pre-fills from `prompt.periodic` defaults (if present). + * Pre-fills from `prompt.loop` defaults (if present). * Calls `onConfirm({ value, unit, at? })` with `at` in UTC HH:MM (days only). * Calls `onCancel()` when dismissed. * * @param {Object} props * @param {boolean} props.isOpen - * @param {Object|null} props.prompt - Prompt object with optional .periodic defaults + * @param {Object|null} props.prompt - Prompt object with optional .loop defaults * @param {Function} props.onConfirm - Called with { value, unit, at? } on confirm * @param {Function} props.onCancel - Called on cancel / close */ -export function PeriodicScheduleDialog({ +export function LoopScheduleDialog({ isOpen, prompt, onConfirm, onCancel, }) { - const defaults = prompt?.periodic || {}; + const defaults = prompt?.loop || {}; const [value, setValue] = useState(defaults.value || 1); const [unit, setUnit] = useState(defaults.unit || "hours"); // `at` stored in local time for display; defaults.at is in UTC — convert on init. @@ -127,7 +127,7 @@ export function PeriodicScheduleDialog({ // Reset to prompt defaults whenever the prompt changes (dialog re-opened). useEffect(() => { - const d = prompt?.periodic || {}; + const d = prompt?.loop || {}; setValue(d.value || 1); setUnit(d.unit || "hours"); setAt(utcToLocalTime(d.at) || ""); @@ -178,16 +178,16 @@ export function PeriodicScheduleDialog({ <button onClick=${handleCancel} class="btn btn-ghost btn-sm" - data-testid="periodic-schedule-cancel" + data-testid="loop-schedule-cancel" > Cancel </button> <button onClick=${handleConfirm} class="btn btn-primary btn-sm" - data-testid="periodic-schedule-confirm" + data-testid="loop-schedule-confirm" > - Start periodic conversation + Start loop conversation </button> `; @@ -197,7 +197,7 @@ export function PeriodicScheduleDialog({ onClose=${handleCancel} title="Set up recurring schedule" footer=${footer} - testid="periodic-schedule-dialog" + testid="loop-schedule-dialog" > <div class="flex flex-col gap-4 text-sm"> ${ @@ -213,23 +213,23 @@ export function PeriodicScheduleDialog({ <div class="tabs tabs-border"> <input type="radio" - name="periodic-schedule-trigger" + name="loop-schedule-trigger" role="tab" aria-label="Schedule" class="tab" checked=${trigger === "schedule"} onChange=${() => setTrigger("schedule")} - data-testid="periodic-schedule-trigger-tab-schedule" + data-testid="loop-schedule-trigger-tab-schedule" /> <input type="radio" - name="periodic-schedule-trigger" + name="loop-schedule-trigger" role="tab" aria-label="On completion" class="tab" checked=${trigger === "onCompletion"} onChange=${() => setTrigger("onCompletion")} - data-testid="periodic-schedule-trigger-tab-oncompletion" + data-testid="loop-schedule-trigger-tab-oncompletion" /> </div> @@ -248,13 +248,13 @@ export function PeriodicScheduleDialog({ value=${value} onInput=${(e) => setValue(parseInt(e.target.value, 10) || 1)} class="input input-sm w-20 text-center shrink-0" - data-testid="periodic-schedule-value" + data-testid="loop-schedule-value" /> <select value=${unit} onChange=${handleUnitChange} class="select select-sm w-28 shrink-0" - data-testid="periodic-schedule-unit" + data-testid="loop-schedule-unit" > <option value="minutes">minutes</option> <option value="hours">hours</option> @@ -272,7 +272,7 @@ export function PeriodicScheduleDialog({ onInput=${(e) => setAt(e.target.value)} class="h-8 px-2 min-w-16 shrink-0 bg-white dark:bg-mitto-surface-2 border border-mitto-border dark:border-mitto-border-2 rounded text-sm focus:outline-none focus:ring-1 focus:ring-mitto-accent-500" placeholder="HH:MM" - data-testid="periodic-schedule-at" + data-testid="loop-schedule-at" /> `} </div>` @@ -288,7 +288,7 @@ export function PeriodicScheduleDialog({ onInput=${(e) => setDelay(Math.max(5, parseInt(e.target.value, 10) || 5))} class="input input-sm w-20 text-center shrink-0" - data-testid="periodic-schedule-delay" + data-testid="loop-schedule-delay" /> <span class="text-xs text-mitto-text-muted dark:text-mitto-text-300 shrink-0" @@ -307,7 +307,7 @@ export function PeriodicScheduleDialog({ value=${maxIterations} onInput=${(e) => setMaxIterations(Math.max(0, parseInt(e.target.value, 10) || 0))} class="input input-sm w-20 text-center shrink-0" - data-testid="periodic-schedule-max-iterations" + data-testid="loop-schedule-max-iterations" /> <span class="text-xs text-mitto-text-muted dark:text-mitto-text-300 shrink-0">(0 = unlimited)</span> </div> @@ -321,13 +321,13 @@ export function PeriodicScheduleDialog({ value=${maxDurValue} onInput=${(e) => setMaxDurValue(Math.max(0, parseInt(e.target.value, 10) || 0))} class="input input-sm w-20 text-center shrink-0" - data-testid="periodic-schedule-max-duration-value" + data-testid="loop-schedule-max-duration-value" /> <select value=${maxDurUnit} onChange=${(e) => setMaxDurUnit(e.target.value)} class="select select-sm w-28 shrink-0" - data-testid="periodic-schedule-max-duration-unit" + data-testid="loop-schedule-max-duration-unit" > <option value="minutes">minutes</option> <option value="hours">hours</option> diff --git a/web/static/components/ModelTagSelect.js b/web/static/components/ModelTagSelect.js new file mode 100644 index 00000000..c87142b0 --- /dev/null +++ b/web/static/components/ModelTagSelect.js @@ -0,0 +1,59 @@ +// Mitto Web Interface - Model Tag Select Component +const { html } = window.preact; + +// Sentinel for the disabled hint shown when no tags are defined on any profile. +const HINT_VALUE = "__hint__"; + +/** + * ModelTagSelect — single dropdown for choosing a capability Tag (e.g. "Fast", + * "Cheap") that any Model profile carrying that tag can satisfy. + * + * Mirrors ModelProfileSelect but for the tag axis. Used alongside + * ModelProfileSelect as a mutually-exclusive way to pick an auxiliary model: + * select a specific profile by name, or delegate to whichever profile matches + * a requested tag. + * + * Props: + * value {string} — currently selected tag ("" = none) + * profiles {Array} — model profiles from config.models: {name, criteria, tags} + * onChange {function} — called with the newly selected tag ("" = none) + */ +export function ModelTagSelect({ value, profiles = [], onChange }) { + const handleChange = (e) => { + const v = e.target.value; + if (v === HINT_VALUE) return; + onChange(v); + }; + + // Deduplicated, case-insensitively unique, sorted union of tags across all + // profiles. Preserves the first-seen original casing for each tag. + const seen = new Map(); // lowercased -> original + for (const p of profiles) { + const tags = Array.isArray(p && p.tags) ? p.tags : []; + for (const t of tags) { + if (typeof t !== "string") continue; + const trimmed = t.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (!seen.has(key)) seen.set(key, trimmed); + } + } + const tags = Array.from(seen.values()).sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); + + return html` + <select + value=${value || ""} + onInput=${handleChange} + class="select select-sm" + > + <option value="">-- None --</option> + ${tags.length === 0 && + html`<option value=${HINT_VALUE} disabled> + (no tags defined on any profile) + </option>`} + ${tags.map((t) => html`<option key=${t} value=${t}>${t}</option>`)} + </select> + `; +} diff --git a/web/static/components/PromptsMenu.js b/web/static/components/PromptsMenu.js index ad07497e..d54561f4 100644 --- a/web/static/components/PromptsMenu.js +++ b/web/static/components/PromptsMenu.js @@ -1,17 +1,17 @@ // Mitto Web Interface - Shared Prompts Menu // A single searchable, grouped, color-aware prompt picker reused by the -// ChatInput prompts dropup and the periodic-conversation prompt selector. +// ChatInput prompts dropup and the loop-conversation prompt selector. const { html, Fragment, useState } = window.preact; -import { getPromptIcon, PeriodicIcon } from "./Icons.js"; +import { getPromptIcon, LoopIcon } from "./Icons.js"; import { getContrastColor, flattenPrompts, resolvePromptModelOverride, currentModelName, - promptPeriodicMode, - promptPeriodicDefaultOn, + promptLoopMode, + promptLoopDefaultOn, } from "../utils/prompts.js"; // Source badge (W/F/S) shown on the right of each item when enabled. @@ -51,7 +51,7 @@ function getBadgeInfo(source) { * @param {number} [props.selectedIndex] - flat index highlighted via keyboard (-1 = none) * @param {Object} [props.selectedItemRef] - ref attached to the keyboard-highlighted item * @param {Function} props.onSelect - (prompt, event, opts?) => void. When - * periodicToggle is true, opts is { asPeriodic } for "optional"-mode prompts. + * loopToggle is true, opts is { asLoop } for "optional"-mode prompts. * @param {string} [props.selectedName] - name of the currently-chosen prompt (shows a check) * @param {boolean} [props.showSourceBadge] - show the W/F/S source badge * @param {Object} [props.modelOption] - the "model" config option ({ current_value, @@ -67,9 +67,9 @@ function getBadgeInfo(source) { * @param {string} [props.keyPrefix] - key namespace to keep instances distinct * @param {string} [props.filterTestId] - data-testid for the filter input * @param {string} [props.listTestId] - data-testid for the scrollable list container - * @param {boolean} [props.periodicToggle] - when true, render a mode-aware periodic + * @param {boolean} [props.loopToggle] - when true, render a mode-aware loop * control (toggle for "optional", locked badge for "always") instead of the - * static periodic badge; onSelect then receives a 3rd ({ asPeriodic }) arg. + * static loop badge; onSelect then receives a 3rd ({ asLoop }) arg. * Defaults to false (static badge, unchanged look) for config selectors. */ export function PromptsMenu({ @@ -93,14 +93,14 @@ export function PromptsMenu({ keyPrefix = "pm", filterTestId, listTestId, - periodicToggle = false, + loopToggle = false, }) { const { groups, flat } = flattenPrompts(prompts, { filterText, sortMode }); const clampedIndex = flat.length === 0 ? -1 : Math.min(selectedIndex, flat.length - 1); const curModelName = currentModelName(modelOption); - // Per-item periodic override (mode "optional" only), keyed by prompt.name. - const [periodicOverrides, setPeriodicOverrides] = useState({}); + // Per-item loop override (mode "optional" only), keyed by prompt.name. + const [loopOverrides, setLoopOverrides] = useState({}); const renderItem = (prompt) => { const fi = flat.indexOf(prompt); @@ -131,13 +131,13 @@ export function PromptsMenu({ <button type="button" onClick=${(e) => { - const asPeriodic = - promptPeriodicMode(prompt) === "optional" - ? periodicOverrides[prompt.name] !== undefined - ? periodicOverrides[prompt.name] - : promptPeriodicDefaultOn(prompt) + const asLoop = + promptLoopMode(prompt) === "optional" + ? loopOverrides[prompt.name] !== undefined + ? loopOverrides[prompt.name] + : promptLoopDefaultOn(prompt) : undefined; - onSelect && onSelect(prompt, e, { asPeriodic }); + onSelect && onSelect(prompt, e, { asLoop }); }} title=${prompt.description || prompt.name} class="prompt-item w-full text-left px-4 py-2.5 text-sm text-mitto-text hover:brightness-110 transition-all flex items-center gap-2 rounded-none" @@ -175,34 +175,34 @@ export function PromptsMenu({ /> </svg>`} <span class="truncate flex-1 min-w-0">${prompt.name}</span> - ${!periodicToggle && - prompt.periodic && + ${!loopToggle && + prompt.loop && html`<span class="shrink-0 text-success opacity-80" - title="Periodic prompt — sets the conversation to recurring mode" - ><${PeriodicIcon} className="w-3.5 h-3.5" + title="Loop prompt — sets the conversation to recurring mode" + ><${LoopIcon} className="w-3.5 h-3.5" /></span>`} - ${periodicToggle && + ${loopToggle && (() => { - const mode = promptPeriodicMode(prompt); + const mode = promptLoopMode(prompt); if (mode === "none") return null; if (mode === "optional") { const on = - periodicOverrides[prompt.name] !== undefined - ? periodicOverrides[prompt.name] - : promptPeriodicDefaultOn(prompt); + loopOverrides[prompt.name] !== undefined + ? loopOverrides[prompt.name] + : promptLoopDefaultOn(prompt); return html`<input type="checkbox" class="checkbox checkbox-sm shrink-0" style="background-color: transparent" checked=${on} title=${on - ? "Periodic: ON — click to disable recurring runs" - : "Periodic: OFF — click to run as recurring conversation"} + ? "Loop: ON — click to disable recurring runs" + : "Loop: OFF — click to run as recurring conversation"} onClick=${(e) => e.stopPropagation()} onChange=${(e) => { e.stopPropagation(); - setPeriodicOverrides((m) => ({ + setLoopOverrides((m) => ({ ...m, [prompt.name]: e.target.checked, })); @@ -218,7 +218,7 @@ export function PromptsMenu({ style="background-color: transparent" checked=${true} disabled - title="Always periodic — this prompt always runs as a recurring conversation (cannot be changed)" + title="Always loop — this prompt always runs as a recurring conversation (cannot be changed)" onClick=${(e) => e.stopPropagation()} />`; })()} diff --git a/web/static/components/RichSelect.js b/web/static/components/RichSelect.js new file mode 100644 index 00000000..9aa55762 --- /dev/null +++ b/web/static/components/RichSelect.js @@ -0,0 +1,123 @@ +// Mitto Web Interface - Generic rich daisyUI dropdown (select replacement) +// Controlled <details>-based dropdown that supports rich menu-item content +// (icons/badges) while behaving like a single-select. Mirrors the proven +// positioning/close pattern of ConfigOptionSelect (the `config-dropdown-block` +// scoped CSS in styles.css, because daisyUI's CSS-anchor placement is +// unreliable in WKWebView). + +const { html, useState, useEffect, useRef, useCallback } = window.preact; + +import { ChevronDownIcon, CheckIcon } from "./Icons.js"; + +/** + * RichSelect — controlled daisyUI dropdown allowing rich item rendering. + * + * Props: + * value {string} currently selected value + * options {Array<{value,label,render?}>} render()=>htmlNode for the menu row (falls back to label) + * onChange {function} (value) => void + * renderTrigger {function?} (selectedOption|null) => htmlNode; default shows label/placeholder + * placeholder {string?} trigger text when nothing selected. Default "Select…" + * ariaLabel {string?} + * className {string?} extra classes on the .dropdown container (the <details>). To + * weld into a daisyUI `join`, put "join-item" HERE (the <details> + * is a direct child of the join, so it receives the -1px weld + * margin natively) — not on triggerClass. + * triggerClass {string?} overrides the <summary> box classes. To match a join row, pass + * "input input-sm rounded-none …" (square middle-item box; corner + * rounding is handled by the join on the first/last items). + * Defaults to a standalone bordered box. + */ +export function RichSelect({ + value, + options = [], + onChange, + renderTrigger, + placeholder = "Select…", + ariaLabel, + className = "", + triggerClass, +}) { + const [open, setOpen] = useState(false); + const detailsRef = useRef(null); + + // Close on outside click / Escape while open (native <details> does not). + useEffect(() => { + if (!open) return undefined; + const onDocPointer = (e) => { + if (detailsRef.current && !detailsRef.current.contains(e.target)) { + setOpen(false); + } + }; + const onKey = (e) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocPointer); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocPointer); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const handleSelect = useCallback( + (v) => { + onChange?.(v); + setOpen(false); + }, + [onChange], + ); + + const selected = options.find((o) => o.value === value) || null; + const detailsClass = ["dropdown config-dropdown-block", className] + .filter(Boolean) + .join(" "); + const summaryClass = + triggerClass || + "flex w-full items-center justify-between gap-2 rounded-lg border border-mitto-border-2 bg-mitto-surface-3 px-3 py-2 text-sm list-none cursor-pointer transition-colors hover:bg-mitto-surface-hover"; + + return html` + <details + ref=${detailsRef} + class=${detailsClass} + open=${open} + onToggle=${(e) => { + const isOpen = e.currentTarget.open; + if (isOpen !== open) setOpen(isOpen); + }} + > + <summary class=${summaryClass} aria-label=${ariaLabel}> + <span class="min-w-0 flex-1 flex items-center gap-2 truncate"> + ${renderTrigger + ? renderTrigger(selected) + : selected + ? selected.label + : placeholder} + </span> + <${ChevronDownIcon} className="w-4 h-4 opacity-60 shrink-0" /> + </summary> + <ul + class="dropdown-content menu menu-sm bg-mitto-surface-2 rounded-box p-2 shadow border border-mitto-border-1 max-h-64 overflow-y-auto flex-nowrap w-full z-50" + > + ${options.map( + (opt) => html` + <li key=${opt.value}> + <button + type="button" + class=${opt.value === value ? "menu-active" : ""} + onClick=${() => handleSelect(opt.value)} + > + ${opt.value === value + ? html`<${CheckIcon} className="w-4 h-4 shrink-0" />` + : html`<span class="inline-block w-4 h-4 shrink-0"></span>`} + <span class="min-w-0 flex-1"> + ${opt.render ? opt.render() : opt.label} + </span> + </button> + </li> + `, + )} + </ul> + </details> + `; +} diff --git a/web/static/components/SessionItem.js b/web/static/components/SessionItem.js index c12768d2..521636da 100644 --- a/web/static/components/SessionItem.js +++ b/web/static/components/SessionItem.js @@ -6,9 +6,9 @@ import { FILTER_TAB } from "../utils/index.js"; import { useSwipeToAction, useConversationMenu } from "../hooks/index.js"; import { getArchiveReasonText, getGlobalWorkingDir } from "../lib.js"; import { - PERIODIC_PROGRESS_STYLE, - PERIODIC_PROGRESS_COLORS, - PERIODIC_PROGRESS_URGENT_THRESHOLD, + LOOP_PROGRESS_STYLE, + LOOP_PROGRESS_COLORS, + LOOP_PROGRESS_URGENT_THRESHOLD, } from "../constants.js"; import { WorkspacePill } from "./WorkspaceBadge.js"; import { ContextMenu, PortalTooltip } from "./ContextMenu.js"; @@ -38,7 +38,7 @@ const SUPPORTS_HOVER = const META_TOOLTIP_DELAY_MS = 450; /** - * Calculate periodic progress background style. + * Calculate loop progress background style. * Returns a CSS background style showing elapsed time as a progress indicator. * * @param {Object} params - Parameters @@ -47,17 +47,13 @@ const META_TOOLTIP_DELAY_MS = 450; * @param {boolean} params.isLight - Whether light theme is active * @returns {string|null} CSS background style or null if not applicable */ -export function getPeriodicProgressStyle({ - nextScheduledAt, - frequency, - isLight, -}) { +export function getLoopProgressStyle({ nextScheduledAt, frequency, isLight }) { // Skip if progress indicator is disabled - if (PERIODIC_PROGRESS_STYLE === "none" || !nextScheduledAt || !frequency) { + if (LOOP_PROGRESS_STYLE === "none" || !nextScheduledAt || !frequency) { return null; } - const colors = PERIODIC_PROGRESS_COLORS[PERIODIC_PROGRESS_STYLE]; + const colors = LOOP_PROGRESS_COLORS[LOOP_PROGRESS_STYLE]; if (!colors) return null; const themeColors = isLight ? colors.light : colors.dark; @@ -87,7 +83,7 @@ export function getPeriodicProgressStyle({ // Determine if we're in "urgent" state (close to next run) const remaining = 1 - progress; - const isUrgent = remaining < PERIODIC_PROGRESS_URGENT_THRESHOLD; + const isUrgent = remaining < LOOP_PROGRESS_URGENT_THRESHOLD; // Get the appropriate color const elapsedColor = isUrgent @@ -123,8 +119,8 @@ export function SessionItem({ groupingMode = "none", // Current grouping mode (to hide spawned indicator in hierarchical mode) onFetchConversationPrompts, // Async (session, workingDir) => menus:conversation prompts evaluated for THIS conversation onSendPromptToConversation, // Called with (session, prompt) when a context-menu prompt is clicked - onMakePeriodic, // Called with (session) to convert a regular session to periodic - onMakeNonPeriodic, // Called with (session) to revert a periodic session to regular + onMakeLoop, // Called with (session) to convert a regular session to loop + onMakeNonLoop, // Called with (session) to revert a loop session to regular // New props for parent-child hierarchy display isSpawned = false, // If true, shows "spawned" indicator (child session) extraLeftPadding = "", // Additional CSS class for left padding (e.g., "pl-6") @@ -140,18 +136,18 @@ export function SessionItem({ // Check if session is archived const isArchived = session.archived || false; - // Check if periodic is enabled for this session (runs active → clock icon + - // progress bar). Distinct from periodic_configured, which is true even when a - // periodic conversation is paused/draft. - const isPeriodicEnabled = session.periodic_enabled || false; - // Whether a periodic config exists at all (enabled OR paused/draft). Used to - // gate the "Make periodic" / "Make non-periodic" context-menu actions so a - // paused periodic conversation is not offered "Make periodic" again. - const isPeriodicConfigured = session.periodic_configured || false; + // Check if loop is enabled for this session (runs active → clock icon + + // progress bar). Distinct from loop_configured, which is true even when a + // loop conversation is paused/draft. + const isLoopEnabled = session.loop_enabled || false; + // Whether a loop config exists at all (enabled OR paused/draft). Used to + // gate the "Make loop" / "Make non-loop" context-menu actions so a + // paused loop conversation is not offered "Make loop" again. + const isLoopConfigured = session.loop_configured || false; // Leading category icon for the unified-tree row: // regular -> mitto bubble (muted) - // periodic -> clock (muted) + // loop -> clock (muted) // archived -> archive (muted) // Spawned/child rows keep their ↳ marker + child-origin glyph instead. let CategoryIcon = MittoIcon; @@ -159,24 +155,24 @@ export function SessionItem({ if (isArchived) { CategoryIcon = ArchiveIcon; categoryIconClass = "text-mitto-text-muted"; - } else if (isPeriodicEnabled) { + } else if (isLoopEnabled) { CategoryIcon = ClockIcon; categoryIconClass = "text-mitto-text-muted"; } - // Calculate periodic progress background style - const periodicProgressBg = useMemo(() => { - if (!isPeriodicEnabled || isArchived) return null; - return getPeriodicProgressStyle({ + // Calculate loop progress background style + const loopProgressBg = useMemo(() => { + if (!isLoopEnabled || isArchived) return null; + return getLoopProgressStyle({ nextScheduledAt: session.next_scheduled_at, - frequency: session.periodic_frequency, + frequency: session.loop_frequency, isLight: isLightTheme, }); }, [ - isPeriodicEnabled, + isLoopEnabled, isArchived, session.next_scheduled_at, - session.periodic_frequency, + session.loop_frequency, isLightTheme, ]); @@ -248,13 +244,13 @@ export function SessionItem({ parts.push(`Archived: ${archivedDate.toLocaleString()}`); } - // GC-suspended status (for periodic sessions paused to save resources) + // GC-suspended status (for loop sessions paused to save resources) if (session.gc_suspended) { parts.push("Status: Suspended (saving resources)"); } - // Next scheduled run (for periodic sessions) - if (isPeriodicEnabled && session.next_scheduled_at) { + // Next scheduled run (for loop sessions) + if (isLoopEnabled && session.next_scheduled_at) { const nextDate = new Date(session.next_scheduled_at); const now = Date.now(); const diff = nextDate.getTime() - now; @@ -283,7 +279,7 @@ export function SessionItem({ // Determine swipe action based on filter tab and session type: // - Archived tab: swipe to delete // - Child (spawned) sessions: swipe to delete (archive not applicable) - // - Regular/Periodic tabs: swipe to archive + // - Regular/Loop tabs: swipe to archive const isSwipeToDelete = filterTab === FILTER_TAB.ARCHIVED || isSpawned; // Swipe action handler - archive or delete based on current tab @@ -325,15 +321,15 @@ export function SessionItem({ session, workingDir, isArchived, - isPeriodicConfigured, + isLoopConfigured, isSpawned, canArchive, archiveBlockedReason, onRename, onDelete, onArchive, - onMakePeriodic, - onMakeNonPeriodic, + onMakeLoop, + onMakeNonLoop, onFetchConversationPrompts, onSendPromptToConversation, }); @@ -459,7 +455,7 @@ export function SessionItem({ ...${containerProps} > <!-- Swipe action background (revealed when swiping left) --> - <!-- Shows Archive (amber) for regular/periodic tabs, Delete (red) for archived tab --> + <!-- Shows Archive (amber) for regular/loop tabs, Delete (red) for archived tab --> <div class="absolute inset-0 ${isSwipeToDelete ? "bg-red-600" @@ -507,10 +503,10 @@ export function SessionItem({ data-session-id=${session.session_id} data-has-context-menu="true" > - ${periodicProgressBg + ${loopProgressBg ? html`<div class="absolute inset-0 z-0 pointer-events-none" - style="background: ${periodicProgressBg};" + style="background: ${loopProgressBg};" aria-hidden="true" ></div>` : ""} diff --git a/web/static/components/SessionList.js b/web/static/components/SessionList.js index de70c259..af4f8569 100644 --- a/web/static/components/SessionList.js +++ b/web/static/components/SessionList.js @@ -33,6 +33,7 @@ import { import { SessionItem } from "./SessionItem.js"; import { ContextMenu, PortalTooltip } from "./ContextMenu.js"; import { Modal } from "./Modal.js"; +import { Toolbar } from "./Toolbar.js"; import { FolderIcon, FolderOpenIcon, @@ -214,8 +215,8 @@ export function SessionList({ queueLength = 0, onFetchConversationPrompts, // Async (session, workingDir) => prompts[] for the context menu onSendPromptToConversation, - onMakePeriodic, // Called with (session) to convert a regular session to periodic - onMakeNonPeriodic, // Called with (session) to revert a periodic session to regular + onMakeLoop, // Called with (session) to convert a regular session to loop + onMakeNonLoop, // Called with (session) to revert a loop session to regular isCreatingSession = false, // True while ANY new-conversation request is in-flight or retrying creatingWorkingDirs = new Set(), // Set of workingDirs with an in-flight create request }) { @@ -597,13 +598,13 @@ export function SessionList({ }, [allSessions]); // Unified sidebar tree (mitto-1er.3): a single folder-grouped tree over ALL - // sessions (regular + periodic + archived), independent of the filter tab. + // sessions (regular + loop + archived), independent of the filter tab. const unifiedTree = useMemo( () => computeUnifiedTree(allSessions, workspaces), [allSessions, workspaces], ); - // Category visibility filter (mitto-1er.10): show/hide Regular/Periodic/ + // Category visibility filter (mitto-1er.10): show/hide Regular/Loop/ // Archived/Tasks. Browser-session scoped (sessionStorage); all visible by // default. Applied as a pure predicate over the unified tree before render. const [categoryFilter, setCategoryFilterState] = useState(() => @@ -623,7 +624,7 @@ export function SessionList({ }, []); const anyCategoryHidden = !categoryFilter.regular || - !categoryFilter.periodic || + !categoryFilter.loop || !categoryFilter.archived || !categoryFilter.tasks; const filteredTree = useMemo( @@ -898,8 +899,8 @@ export function SessionList({ groupingMode=${groupingMode} onFetchConversationPrompts=${onFetchConversationPrompts} onSendPromptToConversation=${onSendPromptToConversation} - onMakePeriodic=${onMakePeriodic} - onMakeNonPeriodic=${onMakeNonPeriodic} + onMakeLoop=${onMakeLoop} + onMakeNonLoop=${onMakeNonLoop} isSpawned=${isSpawned} extraLeftPadding=${extraLeftPadding} childCount=${childCount} @@ -1727,8 +1728,8 @@ export function SessionList({ <div class="p-4 flex items-center justify-between" > - <h2 class="font-semibold text-lg flex items-center gap-2"> - <${ChatBubbleIcon} className="w-5 h-5 shrink-0" /> + <h2 class="font-semibold text-2xl flex items-center gap-2"> + <${ChatBubbleIcon} className="w-6 h-6 shrink-0" /> <span>Mitto</span> </h2> ${ @@ -1755,165 +1756,149 @@ export function SessionList({ class="px-3 pb-8" data-testid="sidebar-toolbar" > - <!-- daisyUI join: welds the actions into one group spanning the full - panel width. Each direct child grows equally (flex-1); dropdown - triggers carry join-item on the <summary> (join styles apply even - when join-item is nested). --> - <div class="join w-full"> - <button - data-testid="new-conversation-btn" - onClick=${() => !isCreatingSession && onNewSession(null, null)} - aria-disabled=${isCreatingSession ? "true" : "false"} - class="btn btn-ghost btn-sm join-item flex-auto tooltip tooltip-bottom ${isCreatingSession ? "opacity-40 pointer-events-none" : ""}" - data-tip=${isCreatingSession ? "Creating conversation\u2026" : "New Conversation"} - aria-label=${isCreatingSession ? "Creating conversation\u2026" : "New Conversation"} - > - ${ - isCreatingSession + <!-- Actions rendered via the portable Toolbar component + (components/Toolbar.js) as a segmented "pill". Order: new + conversation, workspaces, category filter, density, search, + settings — evenly spaced, no separators. All six data-testids are + preserved so existing selectors/specs keep working. Filter/Density + keep their controlled open state (openToolbarMenu) and custom menu + content; Workspaces/Settings are disabled (greyed) when the + configuration is read-only. --> + <${Toolbar} + variant="block" + surface="bg-mitto-surface-3" + ariaLabel="Sidebar actions" + items=${[ + { + kind: "button", + testId: "new-conversation-btn", + icon: isCreatingSession ? html`<${SpinnerIcon} className="w-4 h-4 animate-spin" />` - : html`<${PlusIcon} className="w-4 h-4" />` - } - </button> - <!-- Workspaces: moved up from the footer. Disabled (greyed) instead - of hidden when the configuration is read-only. --> - <button - data-testid="workspaces-btn" - type="button" - onClick=${() => !configReadonly && onShowWorkspaces && onShowWorkspaces()} - aria-disabled=${configReadonly ? "true" : "false"} - class="btn btn-ghost btn-sm join-item flex-auto tooltip tooltip-bottom ${ - configReadonly - ? "opacity-40 pointer-events-none text-mitto-text-muted" - : "text-mitto-text-muted hover:text-mitto-text-strong" - }" - data-tip=${configReadonly ? "Workspaces (read-only configuration)" : "Workspaces"} - aria-label="Workspaces" - > - <${FolderIcon} className="w-4 h-4" /> - </button> - <!-- The dropdown trigger is the nested <summary>, so the join's - weld margin (applied to direct join-item children) never reaches - it. -ms-px reproduces that weld so the trigger sits flush with - the adjacent buttons, exactly like the plain <button> items. --> - <details - class="dropdown flex-auto -ms-px" - open=${openToolbarMenu === "filter"} - onToggle=${(e) => { - const open = e.currentTarget.open; - if (open !== (openToolbarMenu === "filter")) - handleToolbarMenuToggle("filter", open); - }} - > - <summary - data-testid="category-filter-btn" - class="btn btn-ghost btn-sm join-item w-full list-none tooltip tooltip-bottom ${ - anyCategoryHidden - ? "text-mitto-accent-400" - : "text-mitto-text-muted" - }" - data-tip="Filter categories" - aria-label="Filter categories" - > - <${FilterIcon} className="w-4 h-4" /> - </summary> - <ul - class="dropdown-content menu menu-sm bg-mitto-surface-2 rounded-box z-10 mt-1 w-44 p-2 shadow border border-mitto-border-1" - > - <li class="menu-title text-xs">Show categories</li> - ${[ - { key: "regular", label: "Regular" }, - { key: "periodic", label: "Periodic" }, - { key: "archived", label: "Archived" }, - { key: "tasks", label: "Tasks" }, - ].map( - (opt) => html` - <li key=${opt.key}> - <label class="flex items-center gap-2 cursor-pointer"> - <input - type="checkbox" - class="checkbox checkbox-sm" - checked=${categoryFilter[opt.key]} - onInput=${() => handleCategoryToggle(opt.key)} - data-testid=${`category-filter-${opt.key}`} - /> - <span class="text-sm">${opt.label}</span> - </label> + : html`<${PlusIcon} className="w-4 h-4" />`, + tip: isCreatingSession + ? "Creating conversation\u2026" + : "New Conversation", + ariaLabel: isCreatingSession + ? "Creating conversation\u2026" + : "New Conversation", + disabled: isCreatingSession, + onClick: () => !isCreatingSession && onNewSession(null, null), + }, + { + kind: "button", + testId: "workspaces-btn", + icon: html`<${FolderIcon} className="w-4 h-4" />`, + tip: configReadonly + ? "Workspaces (read-only configuration)" + : "Workspaces", + ariaLabel: "Workspaces", + disabled: configReadonly, + onClick: () => + !configReadonly && onShowWorkspaces && onShowWorkspaces(), + }, + { + kind: "dropdown", + testId: "category-filter-btn", + icon: html`<${FilterIcon} className="w-4 h-4" />`, + tip: "Filter categories", + ariaLabel: "Filter categories", + active: anyCategoryHidden, + caret: true, + open: openToolbarMenu === "filter", + onToggle: (open) => handleToolbarMenuToggle("filter", open), + menu: html` + <ul + class="dropdown-content menu menu-sm bg-mitto-surface-2 rounded-box z-10 mt-1 w-44 p-2 shadow border border-mitto-border-1" + > + <li class="menu-title text-xs">Show categories</li> + ${[ + { key: "regular", label: "Regular" }, + { key: "loop", label: "Loop" }, + { key: "archived", label: "Archived" }, + { key: "tasks", label: "Tasks" }, + ].map( + (opt) => html` + <li key=${opt.key}> + <label class="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + class="checkbox checkbox-sm" + checked=${categoryFilter[opt.key]} + onInput=${() => handleCategoryToggle(opt.key)} + data-testid=${`category-filter-${opt.key}`} + /> + <span class="text-sm">${opt.label}</span> + </label> + </li> + `, + )} + </ul> + `, + }, + { + kind: "dropdown", + testId: "density-btn", + icon: html`<${SlidersIcon} className="w-4 h-4" />`, + tip: "Density", + ariaLabel: "Density", + caret: true, + open: openToolbarMenu === "density", + onToggle: (open) => handleToolbarMenuToggle("density", open), + menu: html` + <ul + class="dropdown-content menu menu-sm bg-mitto-surface-2 rounded-box z-10 mt-1 w-44 p-2 shadow border border-mitto-border-1" + > + <li class="menu-title text-xs">Density</li> + <li> + <button + type="button" + data-testid="density-comfortable" + onClick=${() => handleDensityChange("comfortable")} + > + ${density === "comfortable" + ? html`<${CheckIcon} className="w-4 h-4" />` + : html`<span class="inline-block w-4 h-4"></span>`} + <span class="text-sm">Comfortable</span> + </button> </li> - `, - )} - </ul> - </details> - <!-- Density control: opens a menu with "Comfortable" / "Condensed". - The active mode is checked; the choice persists in localStorage. --> - <details - class="dropdown flex-auto -ms-px" - open=${openToolbarMenu === "density"} - onToggle=${(e) => { - const open = e.currentTarget.open; - if (open !== (openToolbarMenu === "density")) - handleToolbarMenuToggle("density", open); - }} - > - <summary - data-testid="density-btn" - class="btn btn-ghost btn-sm join-item w-full list-none text-mitto-text-muted tooltip tooltip-bottom" - data-tip="Density" - aria-label="Density" - > - <${SlidersIcon} className="w-4 h-4" /> - </summary> - <ul - class="dropdown-content menu menu-sm bg-mitto-surface-2 rounded-box z-10 mt-1 w-44 p-2 shadow border border-mitto-border-1" - > - <li class="menu-title text-xs">Density</li> - <li> - <button type="button" data-testid="density-comfortable" onClick=${() => handleDensityChange("comfortable")}> - ${density === "comfortable" ? html`<${CheckIcon} className="w-4 h-4" />` : html`<span class="inline-block w-4 h-4"></span>`} - <span class="text-sm">Comfortable</span> - </button> - </li> - <li> - <button type="button" data-testid="density-condensed" onClick=${() => handleDensityChange("condensed")}> - ${density === "condensed" ? html`<${CheckIcon} className="w-4 h-4" />` : html`<span class="inline-block w-4 h-4"></span>`} - <span class="text-sm">Condensed</span> - </button> - </li> - </ul> - </details> - <!-- Search (placeholder — search is not yet implemented). --> - <button - type="button" - data-testid="search-btn" - class="btn btn-ghost btn-sm join-item flex-auto text-mitto-text-muted tooltip tooltip-bottom" - aria-label="Search" - data-tip="Search" - > - <${SearchIcon} className="w-4 h-4" /> - </button> - <!-- Settings: moved up from the footer. Disabled (greyed) instead of - hidden when the configuration is read-only. --> - <button - data-testid="settings-btn" - type="button" - onClick=${() => !configReadonly && onShowSettings && onShowSettings()} - aria-disabled=${configReadonly ? "true" : "false"} - class="btn btn-ghost btn-sm join-item flex-auto tooltip tooltip-bottom ${ - configReadonly - ? "opacity-40 pointer-events-none text-mitto-text-muted" - : "text-mitto-text-muted hover:text-mitto-text-strong" - }" - data-tip=${ - configReadonly + <li> + <button + type="button" + data-testid="density-condensed" + onClick=${() => handleDensityChange("condensed")} + > + ${density === "condensed" + ? html`<${CheckIcon} className="w-4 h-4" />` + : html`<span class="inline-block w-4 h-4"></span>`} + <span class="text-sm">Condensed</span> + </button> + </li> + </ul> + `, + }, + { + kind: "button", + testId: "search-btn", + icon: html`<${SearchIcon} className="w-4 h-4" />`, + tip: "Search", + ariaLabel: "Search", + }, + { + kind: "button", + testId: "settings-btn", + icon: html`<${SettingsIcon} className="w-4 h-4" />`, + tip: configReadonly ? rcFilePath ? `Using ${rcFilePath}` : "Settings (read-only configuration)" - : "Settings" - } - aria-label="Settings" - > - <${SettingsIcon} className="w-4 h-4" /> - </button> - </div> + : "Settings", + ariaLabel: "Settings", + disabled: configReadonly, + onClick: () => + !configReadonly && onShowSettings && onShowSettings(), + }, + ]} + /> </div> <div class="flex-1 overflow-y-auto scrollbar-hide"> ${ @@ -1978,8 +1963,13 @@ export function SessionList({ <span class="text-base font-semibold">A</span> </button> </div> - <!-- Keyboard shortcuts button --> - <button + <!-- Keyboard shortcuts button. Hidden on touch/no-hover devices + (iPhone, tablets): there is no physical keyboard, so the + shortcuts dialog is meaningless and the button (with its + hover-only tooltip) would just add clutter. Gated on the same + (hover: hover) probe used elsewhere in this file. --> + ${SIDEBAR_SUPPORTS_HOVER && + html`<button onClick=${onShowKeyboardShortcuts} class="btn btn-ghost btn-square btn-sm group tooltip tooltip-top" data-tip="Keyboard Shortcuts" @@ -1988,7 +1978,7 @@ export function SessionList({ <${KeyboardIcon} className="w-4 h-4 text-mitto-text-muted group-hover:text-mitto-text-strong" /> - </button> + </button>`} </div> </div> </div> diff --git a/web/static/components/SessionPanel.js b/web/static/components/SessionPanel.js index ff5e005d..ca669990 100644 --- a/web/static/components/SessionPanel.js +++ b/web/static/components/SessionPanel.js @@ -9,7 +9,7 @@ import { EditIcon, CheckIcon, FolderIcon, - PeriodicFilledIcon, + LoopFilledIcon, SettingsIcon, SlidersIcon, } from "./Icons.js"; @@ -247,7 +247,7 @@ export function SessionPanel({ const [editedTitle, setEditedTitle] = useState(""); const [isSavingTitle, setIsSavingTitle] = useState(false); const titleInputRef = useRef(null); - const [periodicConfig, setPeriodicConfig] = useState(null); + const [loopConfig, setLoopConfig] = useState(null); const [callbackConfig, setCallbackConfig] = useState(null); const [callbackCopied, setCallbackCopied] = useState(false); const [confirmDialog, setConfirmDialog] = useState(null); @@ -284,7 +284,7 @@ export function SessionPanel({ // --- Effects: reset on session change --- useEffect(() => { setIsEditingTitle(false); - setPeriodicConfig(null); + setLoopConfig(null); setCallbackConfig(null); setCallbackCopied(false); setFlagsError(null); @@ -301,26 +301,26 @@ export function SessionPanel({ setIsLoadingFlags(true); setFlagsError(null); - // Periodic + callback endpoints only exist for periodic conversations. - // Gating on periodic_configured avoids 404 noise on regular sessions. - const periodicConfigured = sessionInfo?.periodic_configured === true; + // Loop + callback endpoints only exist for loop conversations. + // Gating on loop_configured avoids 404 noise on regular sessions. + const loopConfigured = sessionInfo?.loop_configured === true; try { - const [periodicRes, callbackRes, flagsRes, settingsRes] = + const [loopRes, callbackRes, flagsRes, settingsRes] = await Promise.all([ - periodicConfigured - ? authFetch(endpoints.sessions.periodic(sessionId)) + loopConfigured + ? authFetch(endpoints.sessions.loop(sessionId)) : Promise.resolve(null), - periodicConfigured + loopConfigured ? authFetch(endpoints.sessions.callback(sessionId)) : Promise.resolve(null), authFetch(endpoints.misc.advancedFlags()), authFetch(endpoints.sessions.settings(sessionId)), ]); - if (periodicRes && periodicRes.ok) - setPeriodicConfig(await periodicRes.json()); - else setPeriodicConfig(null); + if (loopRes && loopRes.ok) + setLoopConfig(await loopRes.json()); + else setLoopConfig(null); if (callbackRes && callbackRes.ok) setCallbackConfig(await callbackRes.json()); @@ -344,7 +344,7 @@ export function SessionPanel({ }; fetchData(); - }, [isOpen, sessionId, sessionInfo?.periodic_configured]); + }, [isOpen, sessionId, sessionInfo?.loop_configured]); // --- Effects: fetch linked beads issue status when open --- // The status badge mirrors the style used in the Beads view. The status @@ -734,7 +734,7 @@ export function SessionPanel({ class="tabs tabs-lift shrink-0 pt-2" style="--tab-border-color: var(--mitto-border-1);" > - <label class="tab flex-1 tooltip tooltip-bottom" data-tip="Properties" aria-label="Properties"> + <label class="tab flex-1" title="Properties" aria-label="Properties"> <input type="radio" name="session-panel-tabs" @@ -743,7 +743,7 @@ export function SessionPanel({ /> <${SettingsIcon} className="w-4 h-4" /> </label> - <label class="tab flex-1 tooltip tooltip-bottom" data-tip="Changes" aria-label="Changes"> + <label class="tab flex-1" title="Changes" aria-label="Changes"> <input type="radio" name="session-panel-tabs" @@ -764,7 +764,7 @@ export function SessionPanel({ /> </svg> </label> - <label class="tab flex-1 tooltip tooltip-bottom" data-tip="Advanced" aria-label="Advanced"> + <label class="tab flex-1" title="Advanced" aria-label="Advanced"> <input type="radio" name="session-panel-tabs" @@ -1344,44 +1344,44 @@ export function SessionPanel({ </div> `} - <!-- Periodic Prompts Section --> - ${periodicConfig?.enabled && + <!-- Loop Prompts Section --> + ${loopConfig?.enabled && html` <div> <label class="block text-sm font-medium text-mitto-text-secondary mb-2" - >Periodic Prompts</label + >Loop Prompts</label > <div class="flex items-center gap-2 text-sm text-mitto-text-300"> - <${PeriodicFilledIcon} + <${LoopFilledIcon} className="w-4 h-4 shrink-0 text-mitto-accent" /> - <span>${formatFrequency(periodicConfig.frequency)}</span> + <span>${formatFrequency(loopConfig.frequency)}</span> </div> - ${periodicConfig.last_sent_at && + ${loopConfig.last_sent_at && html`<p class="mt-1 flex items-baseline gap-2 text-xs text-mitto-text-500" > <strong>Last run:</strong> <span - >${new Date(periodicConfig.last_sent_at).toLocaleString()}</span + >${new Date(loopConfig.last_sent_at).toLocaleString()}</span > </p>`} - ${periodicConfig.next_scheduled_at && + ${loopConfig.next_scheduled_at && html`<p class="mt-1 flex items-baseline gap-2 text-xs text-mitto-text-500" > <strong>Next run:</strong> <span >${new Date( - periodicConfig.next_scheduled_at, + loopConfig.next_scheduled_at, ).toLocaleString()}</span > </p>`} <p class="mt-1 text-xs text-mitto-text-500"> - ${(periodicConfig.max_iterations ?? 0) > 0 - ? `Run ${periodicConfig.iteration_count ?? 0} of ${periodicConfig.max_iterations}` - : `${periodicConfig.iteration_count ?? 0} run${(periodicConfig.iteration_count ?? 0) !== 1 ? "s" : ""} · unlimited`} + ${(loopConfig.max_iterations ?? 0) > 0 + ? `Run ${loopConfig.iteration_count ?? 0} of ${loopConfig.max_iterations}` + : `${loopConfig.iteration_count ?? 0} run${(loopConfig.iteration_count ?? 0) !== 1 ? "s" : ""} · unlimited`} </p> </div> `} @@ -1634,15 +1634,15 @@ export function SessionPanel({ `, )} - <!-- Callback URL Section (only for periodic conversations) --> - ${periodicConfig && + <!-- Callback URL Section (only for loop conversations) --> + ${loopConfig && html` <div> <label class="block text-sm font-medium text-mitto-text-secondary mb-2" >Callback URL</label > - ${periodicConfig.enabled + ${loopConfig.enabled ? html` ${callbackConfig?.callback_url ? html` @@ -1675,7 +1675,7 @@ export function SessionPanel({ </div> ` : html` - <${Tooltip} tip="Generate a callback URL for triggering this periodic conversation externally" placement="top"> + <${Tooltip} tip="Generate a callback URL for triggering this loop conversation externally" placement="top"> <button onClick=${handleEnableCallback} class="btn btn-xs btn-soft" @@ -1689,7 +1689,7 @@ export function SessionPanel({ ${callbackConfig?.callback_url ? html` <p class="text-xs text-mitto-text-muted mb-1.5 italic"> - Preserved but inactive while periodic is disabled + Preserved but inactive while loop is disabled </p> <div class="flex items-center gap-1.5"> <button diff --git a/web/static/components/SettingsDialog.js b/web/static/components/SettingsDialog.js index 7bc3d391..f08e0a6a 100644 --- a/web/static/components/SettingsDialog.js +++ b/web/static/components/SettingsDialog.js @@ -46,12 +46,40 @@ import { ShieldIcon, SearchIcon, LayersIcon, + KeyboardIcon, } from "./Icons.js"; import { AgentDiscoveryDialog } from "./AgentDiscoveryDialog.js"; import { Modal } from "./Modal.js"; import { ModelSelection } from "./ModelSelection.js"; import { ModelProfileSelect } from "./ModelProfileSelect.js"; +import { RichSelect } from "./RichSelect.js"; import { Tooltip } from "./Tooltip.js"; +import { ShortcutsEditor } from "./ShortcutsEditor.js"; +import { promptMenuIncludes } from "../utils/prompts.js"; + +// Section descriptors for the global Shortcuts tab. Section IDs match those used +// by the folder-level editor and the render-time toolbars; each maps to the +// prompt menu whose prompts are offered for that section. +const GLOBAL_SHORTCUT_SECTIONS = [ + { + id: "tasksList", + label: "Tasks list", + desc: "Buttons shown in every Tasks list toolbar.", + menu: "beadsList", + }, + { + id: "conversations", + label: "Conversation", + desc: "Buttons shown in every conversation toolbar; run in the current conversation.", + menu: "prompts", + }, + { + id: "beadsIssue", + label: "Beads issue", + desc: "Buttons shown in every beads issue detail toolbar; start a new conversation for the issue.", + menu: "beadsIssues", + }, +]; // Import constants import { CYCLING_MODE, CYCLING_MODE_OPTIONS } from "../constants.js"; @@ -215,9 +243,13 @@ export function AutoChildrenEditor({ currentWorkspaceUUID, onChange, getBasename, + modelProfiles, }) { const addChild = () => - onChange([...(children || []), { title: "", target_workspace_uuid: "" }]); + onChange([ + ...(children || []), + { title: "", target_workspace_uuid: "", model_profile: "" }, + ]); const removeChild = (idx) => onChange((children || []).filter((_, i) => i !== idx)); const updateChild = (idx, field, value) => { @@ -235,6 +267,47 @@ export function AutoChildrenEditor({ ws.working_dir === currentWs.working_dir, ); + // Small inline workspace badge + label used in the rich workspace dropdown + // (both menu rows and the trigger). Inline styles avoid depending on + // tailwind utility classes that may be absent from the precompiled snapshot. + const renderWorkspaceItem = (ws) => { + const info = getWorkspaceVisualInfo( + ws.working_dir, + ws.color, + ws.code, + ws.name, + ); + return html` + <span class="inline-flex items-center gap-2 min-w-0"> + <span + class="inline-flex items-center justify-center rounded font-bold shrink-0" + style=${{ + backgroundColor: info.color.background, + color: info.color.text, + width: "20px", + height: "20px", + fontSize: "10px", + }} + >${info.abbreviation}</span + > + <span class="truncate min-w-0" + >${ws.name || ws.acp_server} (${getBasename(ws.working_dir)})</span + > + </span> + `; + }; + + const workspaceOptions = targetOptions.map((ws) => ({ + value: ws.uuid, + label: `${ws.name || ws.acp_server} (${getBasename(ws.working_dir)})`, + render: () => renderWorkspaceItem(ws), + })); + + const profileOptions = [ + { value: "", label: "Default (ACP server criteria)" }, + ...(modelProfiles || []).map((p) => ({ value: p.name, label: p.name })), + ]; + const maxChildren = 5; const canAdd = (children || []).length < maxChildren; @@ -262,31 +335,34 @@ export function AutoChildrenEditor({ placeholder="Child title" onInput=${(e) => updateChild(idx, "title", e.target.value)} - class="input input-sm join-item flex-1" + class="input input-sm join-item flex-1 min-w-0" /> - <select + <${RichSelect} + className="flex-1 min-w-0 join-item" + triggerClass="input input-sm rounded-none w-full flex items-center justify-between gap-2 list-none cursor-pointer" + ariaLabel="Target workspace" value=${child.target_workspace_uuid || ""} - onChange=${(e) => - updateChild( - idx, - "target_workspace_uuid", - e.target.value, - )} - class="select select-sm join-item" - > - ${targetOptions.map( - (ws) => html` - <option value=${ws.uuid}> - ${ws.name || ws.acp_server} - (${getBasename(ws.working_dir)}) - </option> - `, - )} - </select> + options=${workspaceOptions} + placeholder="Select workspace" + renderTrigger=${(sel) => + sel + ? sel.render() + : html`<span>Select workspace</span>`} + onChange=${(v) => + updateChild(idx, "target_workspace_uuid", v)} + /> + <${RichSelect} + className="flex-1 min-w-0 join-item" + triggerClass="input input-sm rounded-none w-full flex items-center justify-between gap-2 list-none cursor-pointer" + ariaLabel="Model profile" + value=${child.model_profile || ""} + options=${profileOptions} + onChange=${(v) => updateChild(idx, "model_profile", v)} + /> <button type="button" onClick=${() => removeChild(idx)} - class="btn btn-ghost btn-square btn-sm join-item tooltip tooltip-bottom" + class="btn btn-ghost btn-square btn-sm join-item tooltip tooltip-bottom shrink-0" data-tip="Remove child" aria-label="Remove child" > @@ -1070,13 +1146,122 @@ export function SettingsDialog({ // Agent discovery dialog (triggered from Servers tab) const [showDiscoverAgents, setShowDiscoverAgents] = useState(false); + // ------ Global Shortcuts tab state ------------------------------------------ + // Global shortcut buttons stored in settings.json, keyed by section ID. These + // are merged with folder-level shortcuts at render time. + const [shortcutsSections, setShortcutsSections] = useState({}); + // Per-section available prompts, filtered by the section's menu tag. + const [shortcutsSectionPrompts, setShortcutsSectionPrompts] = useState({}); + const [shortcutsLoading, setShortcutsLoading] = useState(false); + const [shortcutsLoaded, setShortcutsLoaded] = useState(false); + const [shortcutsError, setShortcutsError] = useState(""); + + // Lazily load global shortcuts (and the global prompt list) when the Shortcuts + // tab is first opened. Guarded by shortcutsLoaded so we only persist on Save + // when we actually hold the authoritative state (never wipe an untouched tab). + useEffect(() => { + if (!isOpen || activeTab !== "shortcuts" || shortcutsLoaded) return; + setShortcutsLoading(true); + setShortcutsError(""); + authFetch(endpoints.global.shortcuts()) + .then((r) => r.json()) + .then((data) => { + setShortcutsSections(data.sections || {}); + const all = data.prompts || []; + const byMenu = (menu) => + all + .filter((p) => promptMenuIncludes(p, menu)) + .sort((a, b) => a.name.localeCompare(b.name)); + const perSection = {}; + for (const { id, menu } of GLOBAL_SHORTCUT_SECTIONS) { + perSection[id] = byMenu(menu); + } + setShortcutsSectionPrompts(perSection); + setShortcutsLoaded(true); + }) + .catch((err) => + setShortcutsError("Failed to load shortcuts: " + err.message), + ) + .finally(() => setShortcutsLoading(false)); + }, [isOpen, activeTab, shortcutsLoaded]); + + // Reset the loaded flag when the dialog closes so a reopen fetches fresh data. + useEffect(() => { + if (!isOpen) { + setShortcutsLoaded(false); + setShortcutsSections({}); + setShortcutsSectionPrompts({}); + } + }, [isOpen]); + + // ------ Global Shortcuts row helpers (mirror the folder-level editor) -------- + const addShortcutRow = (section) => { + const available = shortcutsSectionPrompts[section] || []; + const defaultPrompt = available.length > 0 ? available[0].name : ""; + setShortcutsSections((prev) => { + const list = [...(prev[section] || [])]; + if (list.length >= 10) return prev; + list.push({ icon: "", prompt: defaultPrompt }); + return { ...prev, [section]: list }; + }); + }; + const updateShortcutRow = (section, idx, patch) => { + setShortcutsSections((prev) => { + const list = [...(prev[section] || [])]; + list[idx] = { ...list[idx], ...patch }; + return { ...prev, [section]: list }; + }); + }; + const removeShortcutRow = (section, idx) => { + setShortcutsSections((prev) => { + const list = [...(prev[section] || [])]; + list.splice(idx, 1); + return { ...prev, [section]: list }; + }); + }; + const moveShortcutRow = (section, idx, dir) => { + setShortcutsSections((prev) => { + const list = [...(prev[section] || [])]; + const target = idx + dir; + if (target < 0 || target >= list.length) return prev; + [list[idx], list[target]] = [list[target], list[idx]]; + return { ...prev, [section]: list }; + }); + }; + + // Persist global shortcuts via PUT /api/global/shortcuts. Only invoked from + // handleSave when the tab was loaded (so an untouched tab never wipes them). + const persistGlobalShortcuts = async () => { + if (!shortcutsLoaded) return; + const sections = {}; + for (const { id } of GLOBAL_SHORTCUT_SECTIONS) { + sections[id] = (shortcutsSections[id] || []) + .filter((r) => r.prompt) + .slice(0, 10); + } + const res = await secureFetch(endpoints.global.shortcuts(), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sections }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) + throw new Error( + errorMessageFromData(data, "Failed to save global shortcuts"), + ); + setShortcutsSections(data.sections || {}); + // Notify open Tasks lists / conversation toolbars so their merged shortcut + // buttons refresh immediately, without a full page reload. + window.dispatchEvent(new CustomEvent("mitto:global_shortcuts_updated")); + }; + // Configuration state const [workspaces, setWorkspaces] = useState([]); const [acpServers, setAcpServers] = useState([]); // Model profiles (named profiles pairing criteria with capability tags) const [modelProfiles, setModelProfiles] = useState([]); - // Accordion: index of the single expanded model profile - const [expandedProfileIndex, setExpandedProfileIndex] = useState(0); + // Accordion: index of the single expanded model profile (-1 = all collapsed) + const [expandedProfileIndex, setExpandedProfileIndex] = useState(-1); // Raw text drafts for the tags input, keyed by profile index — lets the // user type commas without the controlled value swallowing them const [tagDrafts, setTagDrafts] = useState({}); @@ -1169,8 +1354,8 @@ export function SettingsDialog({ const [autoArchiveInactiveAfter, setAutoArchiveInactiveAfter] = useState(""); const [maxMessagesPerSession, setMaxMessagesPerSession] = useState(2000); - // Periodic suspend timeout setting (default "" = 30 minutes) - const [periodicSuspendTimeout, setPeriodicSuspendTimeout] = useState(""); + // Loop suspend timeout setting (default "" = 30 minutes) + const [loopSuspendTimeout, setLoopSuspendTimeout] = useState(""); // Memory recycle threshold setting (default "" = disabled, opt-in) const [memoryRecycleThreshold, setMemoryRecycleThreshold] = useState(""); @@ -1184,11 +1369,10 @@ export function SettingsDialog({ // Max child conversations setting - default 10 const [maxChildConversations, setMaxChildConversations] = useState(10); - // Max periodic iterations setting - default 100 - const [maxPeriodicIterations, setMaxPeriodicIterations] = useState(100); + // Max loop iterations setting - default 100 + const [maxLoopIterations, setMaxLoopIterations] = useState(100); - const [periodicBehaviorExpanded, setPeriodicBehaviorExpanded] = - useState(false); + const [loopBehaviorExpanded, setLoopBehaviorExpanded] = useState(false); // Default flags for new conversations const [availableFlags, setAvailableFlags] = useState([]); @@ -1460,7 +1644,7 @@ export function SettingsDialog({ servers.forEach(assignStableKey); setAcpServers(servers); setModelProfiles(Array.isArray(config.models) ? config.models : []); - setExpandedProfileIndex(0); + setExpandedProfileIndex(-1); setTagDrafts({}); // Reset server renames when config is loaded @@ -1598,8 +1782,8 @@ export function SettingsDialog({ setMaxMessagesPerSession(rawMaxMessages || 2000); } - // Load periodic suspend timeout (default "" = 30 minutes) - setPeriodicSuspendTimeout(config.session?.periodic_suspend_timeout || ""); + // Load loop suspend timeout (default "" = 30 minutes) + setLoopSuspendTimeout(config.session?.loop_suspend_timeout || ""); // Load memory recycle threshold (default "" = disabled) setMemoryRecycleThreshold(config.session?.memory_recycle_threshold || ""); @@ -1619,10 +1803,8 @@ export function SettingsDialog({ config.conversations?.max_child_conversations ?? 10, ); - // Load max periodic iterations setting - default to 100 - setMaxPeriodicIterations( - config.conversations?.max_periodic_iterations ?? 100, - ); + // Load max loop iterations setting - default to 100 + setMaxLoopIterations(config.conversations?.max_loop_iterations ?? 100); // Load input font family setting (web UI) - default to "system" setInputFontFamily(config.ui?.web?.input_font_family || "system"); @@ -1883,7 +2065,7 @@ export function SettingsDialog({ enabled: externalImagesEnabled, }, max_child_conversations: maxChildConversations, - max_periodic_iterations: maxPeriodicIterations, + max_loop_iterations: maxLoopIterations, // Only include default_flags if any are set ...(Object.keys(defaultFlags).length > 0 && { default_flags: defaultFlags, @@ -1899,7 +2081,7 @@ export function SettingsDialog({ auto_archive_inactive_after: autoArchiveInactiveAfter, max_messages_per_session: maxMessagesPerSession === 0 ? -1 : maxMessagesPerSession, - periodic_suspend_timeout: periodicSuspendTimeout, + loop_suspend_timeout: loopSuspendTimeout, memory_recycle_threshold: memoryRecycleThreshold, }; @@ -2013,6 +2195,10 @@ export function SettingsDialog({ // Config changed on disk — invalidate cache so next read is fresh. invalidateConfigCache(); + // Persist global shortcuts (dedicated endpoint; no-op if the Shortcuts tab + // was never opened). Runs after the main config save so its write wins. + await persistGlobalShortcuts(); + // Update the global sound and notification setting flags if (isMacApp) { window.mittoAgentCompletedSoundEnabled = agentCompletedSound; @@ -2359,6 +2545,7 @@ export function SettingsDialog({ { id: "web", label: "Web", icon: GlobeIcon }, { id: "mcp", label: "MCP", icon: LightningIcon }, { id: "ui", label: "UI", icon: SlidersIcon }, + { id: "shortcuts", label: "Shortcuts", icon: KeyboardIcon }, ]; return html` @@ -3532,26 +3719,22 @@ export function SettingsDialog({ </div> </div> - <!-- Periodic Behavior (collapse) --> + <!-- Loop Behavior (collapse) --> <div - data-testid="periodic-behavior-collapse" - class="collapse collapse-arrow ${periodicBehaviorExpanded + data-testid="loop-behavior-collapse" + class="collapse collapse-arrow ${loopBehaviorExpanded ? "collapse-open" : "collapse-close"} border border-mitto-border-2/50 rounded-md bg-mitto-surface-3/20 mt-2" > <div class="collapse-title flex items-center justify-between p-3 pr-12 min-h-0 cursor-pointer bg-mitto-surface-3/30 hover:bg-mitto-surface-3/50 transition-colors" onClick=${() => - setPeriodicBehaviorExpanded( - !periodicBehaviorExpanded, - )} + setLoopBehaviorExpanded(!loopBehaviorExpanded)} > - <span class="text-sm font-medium" - >Periodic Behavior</span - > + <span class="text-sm font-medium">Loop Behavior</span> </div> <div class="collapse-content px-0"> - ${periodicBehaviorExpanded && + ${loopBehaviorExpanded && html` <div class="p-4 space-y-4 border-t border-mitto-border-2/50" @@ -3559,20 +3742,20 @@ export function SettingsDialog({ <div class="flex items-center justify-between"> <div> <div class="font-medium text-sm"> - Suspend periodic conversations + Suspend loop conversations </div> <div class="text-xs text-mitto-text-muted"> - Automatically suspend idle periodic - conversations when their next run is farther - away than this timeout. Saves memory by - stopping ACP and MCP processes. Conversations - resume transparently when focused. + Automatically suspend idle loop conversations + when their next run is farther away than this + timeout. Saves memory by stopping ACP and MCP + processes. Conversations resume transparently + when focused. </div> </div> <select - value=${periodicSuspendTimeout} + value=${loopSuspendTimeout} onInput=${(e) => - setPeriodicSuspendTimeout(e.target.value)} + setLoopSuspendTimeout(e.target.value)} class="select select-sm" > <option value="">After 30 minutes</option> @@ -3586,10 +3769,10 @@ export function SettingsDialog({ <div class="flex items-center justify-between"> <div> <div class="font-medium text-sm"> - Max Periodic Iterations + Max Loop Iterations </div> <div class="text-xs text-mitto-text-muted"> - Maximum number of scheduled runs a periodic + Maximum number of scheduled runs a loop conversation performs before it auto-stops. Set to 0 for unlimited (still bounded by a built-in safety ceiling of 1000). @@ -3599,9 +3782,9 @@ export function SettingsDialog({ type="number" min="0" max="1000" - value=${maxPeriodicIterations} + value=${maxLoopIterations} onInput=${(e) => - setMaxPeriodicIterations( + setMaxLoopIterations( parseInt(e.target.value, 10) || 0, )} class="input input-sm w-20 text-center" @@ -4652,6 +4835,29 @@ export function SettingsDialog({ </div> `} + <!-- Shortcuts Tab --> + ${activeTab === "shortcuts" && + html` + <div class="space-y-4"> + <p class="text-mitto-text-muted text-sm"> + Global shortcut buttons appear across every workspace. + They are merged with any per-folder shortcuts (configured + in the Workspaces dialog); global buttons appear first. + </p> + <${ShortcutsEditor} + sections=${GLOBAL_SHORTCUT_SECTIONS} + shortcutsSections=${shortcutsSections} + sectionPrompts=${shortcutsSectionPrompts} + loading=${shortcutsLoading} + error=${shortcutsError} + onAdd=${addShortcutRow} + onUpdate=${updateShortcutRow} + onRemove=${removeShortcutRow} + onMove=${moveShortcutRow} + /> + </div> + `} + <!-- Models Tab --> ${activeTab === "models" && html` @@ -4667,8 +4873,7 @@ export function SettingsDialog({ const trimmedName = (p.name || "").trim(); const tags = p.tags || []; const isPartialBlank = - trimmedName === "" && - (!!p.criteria || tags.length > 0); + trimmedName === "" && (!!p.criteria || tags.length > 0); return html` <div key=${i} @@ -4829,9 +5034,7 @@ export function SettingsDialog({ ), })} > - <${CloseIcon} - className="w-3 h-3" - /> + <${CloseIcon} className="w-3 h-3" /> </button> </span> `, diff --git a/web/static/components/ShortcutsEditor.js b/web/static/components/ShortcutsEditor.js new file mode 100644 index 00000000..8f2080a4 --- /dev/null +++ b/web/static/components/ShortcutsEditor.js @@ -0,0 +1,161 @@ +// Mitto Web Interface — ShortcutsEditor component +// A reusable per-section shortcut-button editor shared by the folder-level +// (Workspaces dialog) and global-level (Settings dialog) shortcut panels. + +const { html } = window.preact; + +import { IconPicker } from "./IconPicker.js"; +import { SpinnerIcon, TrashIcon } from "./Icons.js"; + +/** + * ShortcutsEditor renders one fieldset per section, each with an ordered list of + * editable shortcut rows (icon picker + prompt select + move/remove) and an + * "+ Add shortcut" button. + * + * Props: + * sections {Array} - [{ id, label, desc }] section descriptors. + * shortcutsSections {Object} - { [sectionId]: [{ icon, prompt }] } current rows. + * sectionPrompts {Object} - { [sectionId]: [promptObj] } available prompts. + * loading {boolean} - Show a spinner instead of the sections. + * error {string} - Error message shown below the sections. + * maxPerSection {number} - Cap on rows per section (default 10). + * onAdd {function}(sectionId) + * onUpdate {function}(sectionId, idx, patch) + * onRemove {function}(sectionId, idx) + * onMove {function}(sectionId, idx, dir) // dir = -1 up, +1 down + * redundantPromptNames {Object} - { [sectionId]: Set<string> } prompt names + * configured at a higher (global) level. Rows referencing them render + * greyed-out, and those prompts are omitted from the add dropdown so they + * are not configured twice. + */ +export function ShortcutsEditor({ + sections = [], + shortcutsSections = {}, + sectionPrompts = {}, + loading = false, + error = "", + maxPerSection = 10, + onAdd, + onUpdate, + onRemove, + onMove, + redundantPromptNames = {}, +}) { + if (loading) { + return html`<div class="flex items-center justify-center p-4"> + <${SpinnerIcon} className="w-5 h-5 animate-spin" /> + </div>`; + } + + return html` + <div class="space-y-4"> + ${sections.map(({ id: section, label, desc }) => { + const rows = shortcutsSections[section] || []; + const prompts = sectionPrompts[section] || []; + const redundant = redundantPromptNames[section] || new Set(); + return html` + <fieldset key=${section} class="fieldset pt-2"> + <legend class="fieldset-legend">${label}</legend> + <p class="text-sm text-mitto-text-muted mb-3">${desc}</p> + + <div class="space-y-2" data-testid="shortcut-rows-${section}"> + ${rows.map((row, idx) => { + const linkedPrompt = prompts.find((p) => p.name === row.prompt); + // A row whose prompt is also configured globally is redundant at + // this (folder) level: grey it out to signal the global entry wins. + const isRedundant = redundant.has(row.prompt); + // Build the prompt options: exclude prompts configured at the + // higher level, but always keep this row's current value so it + // still renders (even if now redundant). + const options = prompts.filter( + (p) => !redundant.has(p.name) || p.name === row.prompt, + ); + return html` + <div + key=${idx} + class="join w-full ${isRedundant ? "opacity-50" : ""}" + data-testid="shortcut-row-${section}-${idx}" + title=${isRedundant + ? "Also configured globally — the global shortcut is used" + : ""} + > + <${IconPicker} + value=${row.icon} + defaultIconName=${linkedPrompt?.icon || ""} + className="join-item border-mitto-border" + onChange=${(name) => + onUpdate(section, idx, { icon: name })} + /> + <select + class="select select-sm join-item flex-1" + value=${row.prompt} + onChange=${(e) => + onUpdate(section, idx, { prompt: e.target.value })} + > + <option value="">Select a prompt…</option> + ${options.map( + (p) => html` + <option key=${p.name} value=${p.name}> + ${p.name} + </option> + `, + )} + </select> + <button + type="button" + class="btn btn-ghost btn-square btn-sm join-item" + disabled=${idx === 0} + onClick=${() => onMove(section, idx, -1)} + aria-label="Move up" + title="Move up" + > + ↑ + </button> + <button + type="button" + class="btn btn-ghost btn-square btn-sm join-item" + disabled=${idx === rows.length - 1} + onClick=${() => onMove(section, idx, 1)} + aria-label="Move down" + title="Move down" + > + ↓ + </button> + <button + type="button" + class="btn btn-ghost btn-square btn-sm join-item text-mitto-danger" + onClick=${() => onRemove(section, idx)} + aria-label="Remove" + title="Remove" + > + <${TrashIcon} className="w-4 h-4" /> + </button> + </div> + `; + })} + </div> + + <div class="mt-3 flex items-center gap-2"> + <button + type="button" + class="btn btn-sm btn-ghost" + disabled=${rows.length >= maxPerSection} + onClick=${() => onAdd(section)} + data-testid="shortcut-add-${section}" + > + + Add shortcut + </button> + ${rows.length >= maxPerSection && + html`<span class="text-xs text-mitto-text-muted" + >Maximum ${maxPerSection}</span + >`} + </div> + </fieldset> + `; + })} + ${error && html`<p class="text-sm text-mitto-danger">${error}</p>`} + </div> + `; +} + +export default ShortcutsEditor; diff --git a/web/static/components/Toolbar.js b/web/static/components/Toolbar.js new file mode 100644 index 00000000..9ce6fe57 --- /dev/null +++ b/web/static/components/Toolbar.js @@ -0,0 +1,209 @@ +// Mitto Web Interface - Portable Toolbar Component +// A config-driven, reusable toolbar rendered as a segmented "pill": grouped +// icon buttons (with optional active state), caret dropdowns, thin vertical +// separators between groups, an optional flex spacer, and a trailing overflow +// ("...") menu. Visual language mirrors modern floating tool palettes. +// +// Item kinds (each entry in `items`): +// { kind: "button", testId, icon, tip, ariaLabel, active, disabled, danger, onClick, className } +// { kind: "dropdown", testId, icon, tip, ariaLabel, active, caret, align, open, onToggle, menu } +// { kind: "overflow", testId, tip, ariaLabel, align, open, onToggle, items: [{ testId, icon, label, active, disabled, onClick }] } +// { kind: "separator" } +// { kind: "spacer" } +// { kind: "custom", content } — arbitrary node rendered inline in the pill +// (e.g. a count/status label). It carries no button +// styling, so it is unaffected by the borderless-item +// rules and simply sits in the flex row. +// +// Props: +// items - array of item descriptors (see above) +// variant - "floating" (auto-width pill) | "block" (fills width). Default "floating". +// size - daisyUI btn size: "xs" | "sm" | "md". Default "sm". +// surface - background utility for the pill container. Default +// "bg-mitto-surface-2". Pass an elevated tone (e.g. +// "bg-mitto-surface-3") to make the pill "float" above a tinted +// panel. Kept as a single class so it never collides with a +// second bg-* utility (equal-specificity utilities are resolved +// by stylesheet order, not class-attribute order). +// ariaLabel - accessible label for the container (role="toolbar"). +// testId - data-testid for the container. +// className - extra classes on the container. + +const { html, Fragment } = window.preact; + +import { ChevronDownIcon, EllipsisIcon } from "./Icons.js"; + +const SIZE = { xs: "btn-xs", sm: "btn-sm", md: "btn-md" }; + +// Shared class list for a toolbar trigger (button or <summary>). The pill +// container carries the border; items are borderless ghosts (see styles-v2.css +// .mitto-toolbar rules) so groups read as one continuous surface. +function triggerClasses( + size, + { active, danger, square = true, tip, extra = "" }, +) { + return [ + "btn btn-ghost", + SIZE[size] || "btn-sm", + square ? "btn-square" : "", + active + ? "btn-active text-mitto-accent-400" + : danger + ? "text-error hover:text-error" + : "text-mitto-text-muted hover:text-mitto-text-strong", + tip ? "tooltip tooltip-bottom" : "", + extra, + ] + .filter(Boolean) + .join(" "); +} + +function ToolbarButton({ item, size }) { + const disabled = !!item.disabled; + return html` + <button + type="button" + data-testid=${item.testId || null} + onClick=${disabled ? null : item.onClick} + aria-disabled=${disabled ? "true" : "false"} + aria-pressed=${item.active ? "true" : "false"} + aria-label=${item.ariaLabel || item.tip || null} + class="${triggerClasses(size, { + active: item.active, + danger: item.danger, + tip: item.tip, + extra: item.className, + })} ${disabled ? "opacity-40 pointer-events-none" : ""}" + data-tip=${item.tip || null} + > + ${item.icon} + </button> + `; +} + +function ToolbarDropdown({ item, size }) { + return html` + <details + class="dropdown ${item.align === "end" ? "dropdown-end" : ""}" + open=${!!item.open} + onToggle=${(e) => { + const open = e.currentTarget.open; + if (open !== !!item.open) item.onToggle && item.onToggle(open); + }} + > + <summary + data-testid=${item.testId || null} + aria-label=${item.ariaLabel || item.tip || null} + class="${triggerClasses(size, { + active: item.active, + square: !item.caret, + tip: item.tip, + extra: `list-none ${item.caret ? "gap-1 px-2" : ""}`, + })}" + data-tip=${item.tip || null} + > + ${item.icon} + ${item.caret + ? html`<${ChevronDownIcon} className="w-3 h-3 opacity-70" />` + : null} + </summary> + ${item.menu} + </details> + `; +} + +function ToolbarOverflow({ item, size }) { + return html` + <details + class="dropdown ${item.align === "start" ? "" : "dropdown-end"}" + open=${!!item.open} + onToggle=${(e) => { + const open = e.currentTarget.open; + if (open !== !!item.open) item.onToggle && item.onToggle(open); + }} + > + <summary + data-testid=${item.testId || null} + aria-label=${item.ariaLabel || "More actions"} + class="${triggerClasses(size, { tip: item.tip, extra: "list-none" })}" + data-tip=${item.tip || null} + > + <${EllipsisIcon} className="w-4 h-4" /> + </summary> + <ul + class="dropdown-content menu menu-sm bg-mitto-surface-2 rounded-box z-10 mt-1 w-52 p-2 shadow border border-mitto-border-1" + > + ${(item.items || []).map( + (mi, i) => html` + <li + key=${mi.testId || mi.label || i} + class=${mi.disabled ? "menu-disabled" : ""} + > + <button + type="button" + data-testid=${mi.testId || null} + class=${mi.active ? "menu-active" : ""} + onClick=${mi.disabled ? null : mi.onClick} + > + ${mi.icon + ? html`<span + class="w-4 h-4 inline-flex items-center justify-center" + >${mi.icon}</span + >` + : null} + <span>${mi.label}</span> + </button> + </li> + `, + )} + </ul> + </details> + `; +} + +export function Toolbar({ + items = [], + variant = "floating", + size = "sm", + surface = "bg-mitto-surface-2", + ariaLabel = "Toolbar", + testId, + className = "", +}) { + const container = [ + "mitto-toolbar items-center gap-1 p-1", + `${surface} border border-mitto-border-1 rounded-box shadow`, + variant === "block" ? "flex w-full" : "inline-flex", + className, + ] + .filter(Boolean) + .join(" "); + + return html` + <div + class=${container} + role="toolbar" + aria-label=${ariaLabel} + data-testid=${testId || null} + > + ${items.map((item, i) => { + const key = item.testId || `${item.kind}-${i}`; + if (item.kind === "separator") + return html`<span + key=${key} + class="mitto-toolbar-sep" + aria-hidden="true" + ></span>`; + if (item.kind === "spacer") + return html`<span key=${key} class="flex-1" aria-hidden="true"></span>`; + if (item.kind === "custom") + return html`<${Fragment} key=${key}>${item.content}</${Fragment}>`; + if (item.kind === "dropdown") + return html`<${ToolbarDropdown} key=${key} item=${item} size=${size} />`; + if (item.kind === "overflow") + return html`<${ToolbarOverflow} key=${key} item=${item} size=${size} />`; + return html`<${ToolbarButton} key=${key} item=${item} size=${size} />`; + })} + </div> + `; +} diff --git a/web/static/components/WorkspacesDialog.js b/web/static/components/WorkspacesDialog.js index 3bb79bb6..aa1d5aba 100644 --- a/web/static/components/WorkspacesDialog.js +++ b/web/static/components/WorkspacesDialog.js @@ -40,8 +40,11 @@ import { MittoIcon, CopyIcon, ErrorIcon, + SlidersIcon, } from "./Icons.js"; +import { promptParameters } from "../utils/prompts.js"; + import { ConfirmDialog } from "./ConfirmDialog.js"; import { Modal } from "./Modal.js"; import { WorkspaceBadge } from "./WorkspaceBadge.js"; @@ -52,10 +55,31 @@ import { } from "./SettingsDialog.js"; import { ModelProfileSelect } from "./ModelProfileSelect.js"; +import { ModelTagSelect } from "./ModelTagSelect.js"; import { Tooltip } from "./Tooltip.js"; -import { IconPicker } from "./IconPicker.js"; +import { ShortcutsEditor } from "./ShortcutsEditor.js"; import { promptMenuIncludes } from "../utils/prompts.js"; +// Section descriptors for the folder Shortcuts tab. Section IDs match those +// persisted on the server (folders.json) and used by the render-time toolbars. +const SHORTCUT_SECTIONS = [ + { + id: "tasksList", + label: "Tasks list", + desc: "Buttons shown in the Tasks list toolbar.", + }, + { + id: "conversations", + label: "Conversation", + desc: "Buttons shown in the conversation toolbar; run in the current conversation.", + }, + { + id: "beadsIssue", + label: "Beads issue", + desc: "Buttons shown in the beads issue detail toolbar; start a new conversation for the issue.", + }, +]; + // Flatten the canonical nested error envelope {error:{code,message,details}} to a // flat message string. Returns "" when there is no error. Also accepts the legacy // flat {error:"..."} shape (the HTTP-200 bd-failure path) unchanged. @@ -176,6 +200,7 @@ export function WorkspacesDialog({ initialWorkingDir, initialTab, showToast, + onOpenPromptParamDialog, }) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -195,6 +220,12 @@ export function WorkspacesDialog({ // so we hand the desired tab off here and consume it there. const pendingInitialTabRef = useRef(null); + // Tracks the workspace whose transient edit fields are currently loaded, so we + // can flush those edits back into the `workspaces` array before switching to a + // different workspace. Without this, edits to multiple workspaces in a single + // dialog session would be lost (only the last-selected workspace would save). + const prevSelectedWorkspaceKeyRef = useRef(null); + // Key of a newly created workspace that doesn't have a valid working_dir yet const [newFolderKey, setNewFolderKey] = useState(null); @@ -207,6 +238,7 @@ export function WorkspacesDialog({ const [editGroup, setEditGroup] = useState(""); const [editAcpServer, setEditAcpServer] = useState(""); const [editAuxModelProfile, setEditAuxModelProfile] = useState(""); + const [editAuxModelTag, setEditAuxModelTag] = useState(""); // Whether the user has explicitly cleared a legacy raw auxiliary model // constraint by picking "-- None --" (vs. never having touched the control). const [editAuxModelConstraintCleared, setEditAuxModelConstraintCleared] = @@ -278,7 +310,17 @@ export function WorkspacesDialog({ const [shortcutsLoading, setShortcutsLoading] = useState(false); const [shortcutsLoaded, setShortcutsLoaded] = useState(false); const [shortcutsError, setShortcutsError] = useState(""); - const [tasksListPrompts, setTasksListPrompts] = useState([]); + // Per-section prompt lists, filtered by the section's prompt menu tag and + // sorted by name. Section ids match those persisted on the server side. + const [sectionPrompts, setSectionPrompts] = useState({ + tasksList: [], + conversations: [], + beadsIssue: [], + }); + // Global shortcut sections (from settings.json). Used only to derive which + // prompts are already configured globally so they can be excluded from the + // folder-level dropdowns and any duplicate folder rows greyed out. + const [globalShortcutsSections, setGlobalShortcutsSections] = useState({}); // Folder beads config state (for the Beads Config tab) — UI wrapper over `bd config`. // beadsConfig holds the raw {key: value} map last loaded from the server. @@ -297,7 +339,11 @@ export function WorkspacesDialog({ const [beadsPullPrompt, setBeadsPullPrompt] = useState(""); const [beadsPushPrompt, setBeadsPushPrompt] = useState(""); const [beadsSyncPrompt, setBeadsSyncPrompt] = useState(""); - // Available argument-free, enabled folder prompts (populated when upstream === "prompts"). + // Saved argument maps (name→string) for each prompt action. + const [beadsPullPromptArgs, setBeadsPullPromptArgs] = useState({}); + const [beadsPushPromptArgs, setBeadsPushPromptArgs] = useState({}); + const [beadsSyncPromptArgs, setBeadsSyncPromptArgs] = useState({}); + // Available enabled folder prompts (populated when upstream === "prompts"). const [beadsUpstreamPrompts, setBeadsUpstreamPrompts] = useState([]); const [beadsUpstreamPromptsLoading, setBeadsUpstreamPromptsLoading] = useState(false); @@ -505,9 +551,24 @@ export function WorkspacesDialog({ // When a workspace child is selected, populate workspace-level edit fields useEffect(() => { + // Flush the previously-selected workspace's transient edits into the + // workspaces array before repopulating the fields for the new selection. + // The scalar edit state (editAcpServer, editAuxModelProfile, etc.) still + // holds the previous workspace's values at this point, so applying them + // against prevKey commits those edits. This also runs when navigating to a + // folder (selectedWorkspaceKey becomes null) so edits are not lost there. + const prevKey = prevSelectedWorkspaceKeyRef.current; + if (prevKey && prevKey !== selectedWorkspaceKey) { + setWorkspaces((prev) => + prev.map((ws) => buildWorkspaceEditsFor(ws, prevKey)), + ); + } + prevSelectedWorkspaceKeyRef.current = selectedWorkspaceKey; + if (!selectedWorkspace) return; setEditAcpServer(selectedWorkspace.acp_server || ""); setEditAuxModelProfile(selectedWorkspace.auxiliary_model_profile || ""); + setEditAuxModelTag(selectedWorkspace.auxiliary_model_tag || ""); setEditAuxModelConstraintCleared(false); setEditAcpCommandOverride(selectedWorkspace.acp_command_override || ""); setEditRunner(selectedWorkspace.restricted_runner || "exec"); @@ -623,6 +684,9 @@ export function WorkspacesDialog({ setBeadsPullPrompt(""); setBeadsPushPrompt(""); setBeadsSyncPrompt(""); + setBeadsPullPromptArgs({}); + setBeadsPushPromptArgs({}); + setBeadsSyncPromptArgs({}); setBeadsUpstreamPrompts([]); }, [selectedFolder]); @@ -637,6 +701,12 @@ export function WorkspacesDialog({ authFetch(endpoints.folders.shortcuts({ working_dir: workingDir })) .then((r) => r.json()) .then((data) => setShortcutsSections(data.sections || {})), + // Global shortcuts: prompts already configured here are excluded from the + // folder dropdowns (and any duplicate folder rows are greyed out). + authFetch(endpoints.global.shortcuts()) + .then((r) => r.json()) + .then((data) => setGlobalShortcutsSections(data.sections || {})) + .catch(() => setGlobalShortcutsSections({})), authFetch( endpoints.workspacePrompts.list({ working_dir: workingDir, @@ -646,26 +716,36 @@ export function WorkspacesDialog({ .then((r) => r.json()) .then((data) => { const all = data.prompts || []; - const filtered = all - .filter((p) => promptMenuIncludes(p, "beadsList")) - .sort((a, b) => a.name.localeCompare(b.name)); - setTasksListPrompts(filtered); + const byMenu = (menu) => + all + .filter((p) => promptMenuIncludes(p, menu)) + .sort((a, b) => a.name.localeCompare(b.name)); + setSectionPrompts({ + tasksList: byMenu("beadsList"), + conversations: byMenu("prompts"), + beadsIssue: byMenu("beadsIssues"), + }); }), ]) .then(() => setShortcutsLoaded(true)) - .catch((err) => setShortcutsError("Failed to load shortcuts: " + err.message)) + .catch((err) => + setShortcutsError("Failed to load shortcuts: " + err.message), + ) .finally(() => setShortcutsLoading(false)); }, [activeTab, selectedFolder]); // Reset shortcuts state when switching folders. useEffect(() => { setShortcutsSections({}); - setTasksListPrompts([]); + setSectionPrompts({ tasksList: [], conversations: [], beadsIssue: [] }); setShortcutsError(""); setShortcutsLoaded(false); }, [selectedFolder]); const loadData = async () => { + // Reset the flush tracker so stale edit-field values from a previous dialog + // session are not flushed onto a workspace after a reload/reopen. + prevSelectedWorkspaceKeyRef.current = null; setLoading(true); try { const [config, runnersRes] = await Promise.all([ @@ -694,7 +774,29 @@ export function WorkspacesDialog({ setOrphanedWorkspaces(orphaned); setSelectedFolder(null); if (valid.length > 0) { - setSelectedWorkspaceKey(getWorkspaceKey(valid[0])); + // Preserve the previously-selected workspace across a reload/reopen when it + // still exists. Otherwise the selection resets to valid[0], whose order is + // not stable (it reflects the backend's map-iteration order, not the sorted + // tree). That made a just-saved edit appear "lost": the dialog reopened on a + // different workspace that legitimately still showed its own value. When no + // prior selection matches, fall back to a deterministic first entry (sorted + // by display name, then ACP server) so the initial selection is predictable. + const prevKey = selectedWorkspaceKey; + const preserved = + prevKey && valid.some((ws) => getWorkspaceKey(ws) === prevKey); + if (preserved) { + setSelectedWorkspaceKey(prevKey); + } else { + const firstByName = [...valid].sort((a, b) => { + const an = a.name || getBasename(a.working_dir) || ""; + const bn = b.name || getBasename(b.working_dir) || ""; + return ( + an.localeCompare(bn) || + (a.acp_server || "").localeCompare(b.acp_server || "") + ); + })[0]; + setSelectedWorkspaceKey(getWorkspaceKey(firstByName)); + } } else { setSelectedWorkspaceKey(null); } @@ -773,7 +875,9 @@ export function WorkspacesDialog({ const checkLiveAcpForWorkspace = useCallback(async (workspaceUUID) => { if (!workspaceUUID) return false; try { - const res = await authFetch(endpoints.workspaces.acpStatus(workspaceUUID)); + const res = await authFetch( + endpoints.workspaces.acpStatus(workspaceUUID), + ); if (!res.ok) return false; const data = await res.json(); return !!data.alive; @@ -906,11 +1010,9 @@ export function WorkspacesDialog({ setMcpInstallSuccess(`Successfully installed: ${names}`); // Check if a live ACP process needs restarting to pick up the new MCP server if (selectedWorkspace?.uuid) { - checkLiveAcpForWorkspace(selectedWorkspace.uuid).then( - (hasActive) => { - if (hasActive) setNeedsRestart(true); - }, - ); + checkLiveAcpForWorkspace(selectedWorkspace.uuid).then((hasActive) => { + if (hasActive) setNeedsRestart(true); + }); } // Reload MCP tools list after successful install setTimeout(() => { @@ -1036,11 +1138,9 @@ export function WorkspacesDialog({ } else { setMcpInstallSuccess("Installed Mitto MCP server."); if (selectedWorkspace?.uuid) { - checkLiveAcpForWorkspace(selectedWorkspace.uuid).then( - (hasActive) => { - if (hasActive) setNeedsRestart(true); - }, - ); + checkLiveAcpForWorkspace(selectedWorkspace.uuid).then((hasActive) => { + if (hasActive) setNeedsRestart(true); + }); } await loadMcpTools(acpServer, selectedWorkspace?.uuid); } @@ -1118,9 +1218,12 @@ export function WorkspacesDialog({ } }; - // Apply workspace-level edits (acp_server, runner, auto_approve) to the selected workspace - const applyWorkspaceEdits = (ws) => { - if (getWorkspaceKey(ws) !== selectedWorkspaceKey) return ws; + // Build a workspace object with the current transient edit fields applied, + // but only for the workspace matching targetKey; all others pass through + // unchanged. This is used both to flush edits on selection change and to + // commit the currently-selected workspace at save time. + const buildWorkspaceEditsFor = (ws, targetKey) => { + if (getWorkspaceKey(ws) !== targetKey) return ws; // A selected profile (or an explicit "-- None --") always wins over any // legacy raw matchMode/pattern constraint. Otherwise, an untouched // legacy raw constraint is preserved as-is. @@ -1135,6 +1238,7 @@ export function WorkspacesDialog({ ...ws, acp_server: editAcpServer, auxiliary_model_profile: editAuxModelProfile || undefined, + auxiliary_model_tag: editAuxModelTag || undefined, auxiliary_model_selection: auxModelSelection, restricted_runner: editRunner, restricted_runner_config: @@ -1145,6 +1249,10 @@ export function WorkspacesDialog({ }; }; + // Apply workspace-level edits (acp_server, runner, auto_approve) to the selected workspace + const applyWorkspaceEdits = (ws) => + buildWorkspaceEditsFor(ws, selectedWorkspaceKey); + const handleSave = async () => { // Block save if there's an incomplete new folder if (isNewFolderIncomplete) { @@ -1641,12 +1749,17 @@ export function WorkspacesDialog({ setBeadsPullPrompt((data && data.pull_prompt) || ""); setBeadsPushPrompt((data && data.push_prompt) || ""); setBeadsSyncPrompt((data && data.sync_prompt) || ""); + setBeadsPullPromptArgs((data && data.pull_prompt_args) || {}); + setBeadsPushPromptArgs((data && data.push_prompt_args) || {}); + setBeadsSyncPromptArgs((data && data.sync_prompt_args) || {}); } catch (_err) { setBeadsUpstream("none"); } }; - // Load available argument-free, enabled folder prompts for the "prompts" upstream pickers. + // Load available enabled folder prompts for the "prompts" upstream pickers. + // Parametrized prompts are included; per-prompt arguments are configured via + // the sliders button next to each row. const loadBeadsUpstreamPrompts = async (workingDir) => { if (!workingDir) return; setBeadsUpstreamPromptsLoading(true); @@ -1659,13 +1772,7 @@ export function WorkspacesDialog({ ); const data = await res.json().catch(() => ({})); const all = (data && data.prompts) || []; - // Only offer enabled prompts with no parameters (argument-free). - setBeadsUpstreamPrompts( - all.filter( - (p) => - p.enabled !== false && (!p.parameters || p.parameters.length === 0), - ), - ); + setBeadsUpstreamPrompts(all.filter((p) => p.enabled !== false)); } catch (_err) { setBeadsUpstreamPrompts([]); } finally { @@ -1686,6 +1793,9 @@ export function WorkspacesDialog({ body.pull_prompt = beadsPullPrompt; body.push_prompt = beadsPushPrompt; body.sync_prompt = beadsSyncPrompt; + body.pull_prompt_args = beadsPullPromptArgs; + body.push_prompt_args = beadsPushPromptArgs; + body.sync_prompt_args = beadsSyncPromptArgs; } const res = await secureFetch( endpoints.issues.upstream({ working_dir: workingDir }), @@ -1703,6 +1813,9 @@ export function WorkspacesDialog({ setBeadsPullPrompt((data && data.pull_prompt) || ""); setBeadsPushPrompt((data && data.push_prompt) || ""); setBeadsSyncPrompt((data && data.sync_prompt) || ""); + setBeadsPullPromptArgs((data && data.pull_prompt_args) || {}); + setBeadsPushPromptArgs((data && data.push_prompt_args) || {}); + setBeadsSyncPromptArgs((data && data.sync_prompt_args) || {}); } catch (err) { setBeadsUpstream(prev); // revert on failure setBeadsConfigError(err.message || "Failed to set upstream"); @@ -1741,6 +1854,9 @@ export function WorkspacesDialog({ pull_prompt: field === "pull_prompt" ? value : beadsPullPrompt, push_prompt: field === "push_prompt" ? value : beadsPushPrompt, sync_prompt: field === "sync_prompt" ? value : beadsSyncPrompt, + pull_prompt_args: beadsPullPromptArgs, + push_prompt_args: beadsPushPromptArgs, + sync_prompt_args: beadsSyncPromptArgs, }), }, ); @@ -1756,6 +1872,59 @@ export function WorkspacesDialog({ } }; + // Persist the saved argument map for a single pull/push/sync prompt. + // Sends the FULL upstream body (all three names + all three arg maps) so the + // backend can round-trip; reverts on failure. + const saveBeadsPromptArgs = async (field, args) => { + const workingDir = getSelectedFolderDir(); + if (!workingDir) return; + const setterMap = { + pull_prompt: setBeadsPullPromptArgs, + push_prompt: setBeadsPushPromptArgs, + sync_prompt: setBeadsSyncPromptArgs, + }; + const prevMap = { + pull_prompt: beadsPullPromptArgs, + push_prompt: beadsPushPromptArgs, + sync_prompt: beadsSyncPromptArgs, + }; + const setter = setterMap[field]; + const prev = prevMap[field]; + if (!setter) return; + setter(args); // optimistic + setBeadsUpstreamSaving(true); + try { + const res = await secureFetch( + endpoints.issues.upstream({ working_dir: workingDir }), + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + upstream: "prompts", + pull_prompt: beadsPullPrompt, + push_prompt: beadsPushPrompt, + sync_prompt: beadsSyncPrompt, + pull_prompt_args: + field === "pull_prompt" ? args : beadsPullPromptArgs, + push_prompt_args: + field === "push_prompt" ? args : beadsPushPromptArgs, + sync_prompt_args: + field === "sync_prompt" ? args : beadsSyncPromptArgs, + }), + }, + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) + throw new Error(beadsErrorMessage(data) || "Failed to save arguments"); + if (data && data.error) throw new Error(beadsErrorMessage(data)); + } catch (err) { + setter(prev); // revert on failure + setBeadsConfigError(err.message || "Failed to save arguments"); + } finally { + setBeadsUpstreamSaving(false); + } + }; + // Load (reload) prompts for the selected folder const reloadFolderPrompts = async (workingDir) => { const res = await authFetch( @@ -1823,43 +1992,48 @@ export function WorkspacesDialog({ // ------ Shortcuts tab helpers ----------------------------------------------- - // Immutably update a row in the tasksList section. - const updateShortcutRow = (idx, patch) => { + // Immutably update a row in the given section. + const updateShortcutRow = (section, idx, patch) => { setShortcutsSections((prev) => { - const list = [...(prev.tasksList || [])]; + const list = [...(prev[section] || [])]; list[idx] = { ...list[idx], ...patch }; - return { ...prev, tasksList: list }; + return { ...prev, [section]: list }; }); }; - // Remove a row from the tasksList section. - const removeShortcutRow = (idx) => { + // Remove a row from the given section. + const removeShortcutRow = (section, idx) => { setShortcutsSections((prev) => { - const list = [...(prev.tasksList || [])]; + const list = [...(prev[section] || [])]; list.splice(idx, 1); - return { ...prev, tasksList: list }; + return { ...prev, [section]: list }; }); }; - // Move a row up (dir=-1) or down (dir=1) in the tasksList section. - const moveShortcutRow = (idx, dir) => { + // Move a row up (dir=-1) or down (dir=1) within the given section. + const moveShortcutRow = (section, idx, dir) => { setShortcutsSections((prev) => { - const list = [...(prev.tasksList || [])]; + const list = [...(prev[section] || [])]; const target = idx + dir; if (target < 0 || target >= list.length) return prev; [list[idx], list[target]] = [list[target], list[idx]]; - return { ...prev, tasksList: list }; + return { ...prev, [section]: list }; }); }; - // Append a new empty row. - const addShortcutRow = () => { + // Append a new row to the given section, seeded with sensible defaults so it + // renders as a complete, usable shortcut right away. + const addShortcutRow = (section) => { + // Default the prompt to the first available prompt for this section (if any) + // so the row is immediately editable rather than showing an empty selector. + const available = sectionPrompts[section] || []; + const defaultPrompt = available.length > 0 ? available[0].name : ""; setShortcutsSections((prev) => { - const list = [...(prev.tasksList || [])]; + const list = [...(prev[section] || [])]; if (list.length >= 10) return prev; // Empty icon → fall back to the linked prompt's own icon at render time. - list.push({ icon: "", prompt: "" }); - return { ...prev, tasksList: list }; + list.push({ icon: "", prompt: defaultPrompt }); + return { ...prev, [section]: list }; }); }; @@ -1869,11 +2043,13 @@ export function WorkspacesDialog({ const persistShortcuts = async () => { const workingDir = getSelectedFolderDir(); if (!workingDir) return; - // Build sections: drop rows with empty prompt, cap to 10. - const tasksList = (shortcutsSections.tasksList || []) - .filter((r) => r.prompt) - .slice(0, 10); - const sections = { tasksList }; + // Build all sections: drop rows with empty prompt, cap to 10 per section. + const sections = {}; + for (const id of ["tasksList", "conversations", "beadsIssue"]) { + sections[id] = (shortcutsSections[id] || []) + .filter((r) => r.prompt) + .slice(0, 10); + } const res = await secureFetch( endpoints.folders.shortcuts({ working_dir: workingDir }), { @@ -1895,6 +2071,21 @@ export function WorkspacesDialog({ ); }; + // Per-section set of prompt names configured at the GLOBAL level. Folder rows + // referencing these are greyed out and the prompts are excluded from the + // folder-level dropdowns (they are already shown via the global shortcuts). + const shortcutRedundantPromptNames = useMemo(() => { + const out = {}; + for (const { id } of SHORTCUT_SECTIONS) { + out[id] = new Set( + (globalShortcutsSections[id] || []) + .map((r) => r.prompt) + .filter(Boolean), + ); + } + return out; + }, [globalShortcutsSections]); + // --------------------------------------------------------------------------- // Load processors when a folder is selected and the Processors tab is active @@ -2766,9 +2957,9 @@ export function WorkspacesDialog({ Prompt Actions </legend> <p class="label"> - Choose an argument-free prompt for each button. - Only enabled prompts with no parameters are listed - here. + Choose an enabled prompt for each button. Use the + sliders button to configure arguments for + parametrized prompts. </p> ${beadsUpstreamPromptsLoading ? html`<div @@ -2786,19 +2977,34 @@ export function WorkspacesDialog({ label: "Pull", field: "pull_prompt", value: beadsPullPrompt, + args: beadsPullPromptArgs, }, { label: "Push", field: "push_prompt", value: beadsPushPrompt, + args: beadsPushPromptArgs, }, { label: "Sync", field: "sync_prompt", value: beadsSyncPrompt, + args: beadsSyncPromptArgs, }, - ].map( - ({ label, field, value }) => html` + ].map(({ label, field, value, args }) => { + const selectedPrompt = value + ? beadsUpstreamPrompts.find( + (p) => p.name === value, + ) + : null; + const params = selectedPrompt + ? promptParameters(selectedPrompt) + : []; + const canEditArgs = + !!value && params.length > 0; + const argsDisabled = + !canEditArgs || beadsUpstreamSaving; + return html` <div key=${field} class="flex items-center gap-2 max-w-md" @@ -2834,9 +3040,41 @@ export function WorkspacesDialog({ `, )} </select> + <button + type="button" + onClick=${() => { + if ( + !canEditArgs || + !onOpenPromptParamDialog || + !selectedPrompt + ) + return; + onOpenPromptParamDialog( + selectedPrompt, + params, + async (userArgs) => { + await saveBeadsPromptArgs( + field, + userArgs, + ); + }, + { initialValues: args || {} }, + ); + }} + disabled=${argsDisabled} + class="shrink-0 p-1.5 rounded border border-mitto-border dark:border-mitto-border-2 bg-white dark:bg-mitto-surface-2 transition-colors ${argsDisabled + ? "opacity-50 cursor-not-allowed" + : "cursor-pointer hover:bg-mitto-surface-hover dark:hover:bg-mitto-surface-3"}" + aria-label=${`Set ${label.toLowerCase()} prompt arguments`} + data-testid=${`beads-${field}-args-btn`} + > + <${SlidersIcon} + className="w-4 h-4 text-mitto-text-secondary" + /> + </button> </div> - `, - )} + `; + })} </div> `} </fieldset> @@ -3847,127 +4085,18 @@ export function WorkspacesDialog({ ${activeTab === "shortcuts" && html` <div class="space-y-4"> - ${shortcutsLoading - ? html`<div - class="flex items-center justify-center p-4" - > - <${SpinnerIcon} className="w-5 h-5 animate-spin" /> - </div>` - : html` - <div class="space-y-4"> - <fieldset class="fieldset pt-2"> - <legend class="fieldset-legend"> - Tasks List - </legend> - <p class="text-sm text-mitto-text-muted mb-3"> - Manage shortcut buttons for sending prompts - in the Tasks list. - </p> - - <div class="space-y-2"> - ${(shortcutsSections.tasksList || []).map( - (row, idx) => { - const linkedPrompt = - tasksListPrompts.find( - (p) => p.name === row.prompt, - ); - return html` - <div key=${idx} class="join w-full"> - <${IconPicker} - value=${row.icon} - defaultIconName=${linkedPrompt?.icon || - ""} - className="join-item border-mitto-border" - onChange=${(name) => - updateShortcutRow(idx, { - icon: name, - })} - /> - <select - class="select select-sm join-item flex-1" - value=${row.prompt} - onChange=${(e) => - updateShortcutRow(idx, { - prompt: e.target.value, - })} - > - <option value=""> - Select a prompt… - </option> - ${tasksListPrompts.map( - (p) => html` - <option - key=${p.name} - value=${p.name} - > - ${p.name} - </option> - `, - )} - </select> - <button - type="button" - class="btn btn-ghost btn-square btn-sm join-item" - disabled=${idx === 0} - onClick=${() => - moveShortcutRow(idx, -1)} - aria-label="Move up" - title="Move up" - > - ↑ - </button> - <button - type="button" - class="btn btn-ghost btn-square btn-sm join-item" - disabled=${idx === - (shortcutsSections.tasksList || []) - .length - - 1} - onClick=${() => - moveShortcutRow(idx, 1)} - aria-label="Move down" - title="Move down" - > - ↓ - </button> - <button - type="button" - class="btn btn-ghost btn-square btn-sm join-item text-mitto-danger" - onClick=${() => - removeShortcutRow(idx)} - aria-label="Remove" - title="Remove" - > - <${TrashIcon} - className="w-4 h-4" - /> - </button> - </div> - `; - }, - )} - </div> - - <div class="mt-3 flex items-center gap-2"> - <button - type="button" - class="btn btn-sm btn-ghost" - disabled=${(shortcutsSections.tasksList || []).length >= 10} - onClick=${addShortcutRow} - > - + Add shortcut - </button> - ${(shortcutsSections.tasksList || []).length >= 10 && - html`<span class="text-xs text-mitto-text-muted">Maximum 10</span>`} - </div> - </fieldset> - - ${shortcutsError && - html`<p class="text-sm text-mitto-danger"> - ${shortcutsError} - </p>`} - </div> - `} + <${ShortcutsEditor} + sections=${SHORTCUT_SECTIONS} + shortcutsSections=${shortcutsSections} + sectionPrompts=${sectionPrompts} + loading=${shortcutsLoading} + error=${shortcutsError} + redundantPromptNames=${shortcutRedundantPromptNames} + onAdd=${addShortcutRow} + onUpdate=${updateShortcutRow} + onRemove=${removeShortcutRow} + onMove=${moveShortcutRow} + /> </div> `} @@ -3985,6 +4114,7 @@ export function WorkspacesDialog({ currentWorkspaceUUID=${firstWs?.uuid} onChange=${setEditAutoChildren} getBasename=${getBasename} + modelProfiles=${modelProfiles} /> </div> `} @@ -4106,11 +4236,31 @@ export function WorkspacesDialog({ legacyLabel=${auxLegacyModelLabel} onChange=${(name) => { setEditAuxModelProfile(name); + if (name) { + setEditAuxModelTag(""); + } if (!name && rawAuxModelConstraint) { setEditAuxModelConstraintCleared(true); } }} /> + <label + class="block text-xs text-mitto-text-muted mt-2 mb-1" + >Or by tag:</label + > + <${ModelTagSelect} + value=${editAuxModelTag} + profiles=${modelProfiles} + onChange=${(tag) => { + setEditAuxModelTag(tag); + if (tag) { + setEditAuxModelProfile(""); + } + if (!tag && rawAuxModelConstraint) { + setEditAuxModelConstraintCleared(true); + } + }} + /> </div> <label class="flex items-center gap-3 cursor-pointer"> <input diff --git a/web/static/components/WorkspacesDialog.test.js b/web/static/components/WorkspacesDialog.test.js index b27696dc..3067d7f0 100644 --- a/web/static/components/WorkspacesDialog.test.js +++ b/web/static/components/WorkspacesDialog.test.js @@ -7,6 +7,8 @@ * include only non-empty fields, with `env` included only when it has keys. */ +import { jest } from "@jest/globals"; + /** * Duplicated from WorkspacesDialog.js for testing (the component imports * window.preact globals, so it cannot be imported directly under jsdom). @@ -243,3 +245,298 @@ describe("buildSaveArgs (argument map for PUT endpoint)", () => { expect(buildSaveArgs({}, emptyProc)).toEqual({}); }); }); + +// --------------------------------------------------------------------------- +// Beads upstream "prompts" — args button gating and PUT body composition. +// Mirrors the inline logic in the Pull/Push/Sync row renderer and in +// saveBeadsPromptArgs. Duplicated for jsdom-friendly unit tests. +// --------------------------------------------------------------------------- + +/** + * Mirrors promptParameters(prompt) from web/static/utils/prompts.js — returns + * the structured parameters array for a prompt, or [] if absent/empty. + */ +function promptParameters(prompt) { + const params = prompt?.parameters; + if (Array.isArray(params) && params.length > 0) return params; + return []; +} + +/** + * Mirrors the canEditArgs / argsDisabled computation for a single row of the + * "Prompt Actions" fieldset. `selectedName` is the value of the row's <select>; + * `prompts` is the list of enabled folder prompts; `saving` reflects + * beadsUpstreamSaving. + */ +function computeArgsButtonState(selectedName, prompts, saving) { + const selectedPrompt = selectedName + ? (prompts || []).find((p) => p.name === selectedName) + : null; + const params = selectedPrompt ? promptParameters(selectedPrompt) : []; + const canEditArgs = !!selectedName && params.length > 0; + const disabled = !canEditArgs || !!saving; + return { selectedPrompt, params, canEditArgs, disabled }; +} + +/** + * Mirrors the body composition of saveBeadsPromptArgs — the full upstream body + * includes all three prompt names + all three arg maps, with the target + * field's map replaced by `args`. + */ +function buildSavePromptArgsBody(field, args, state) { + return { + upstream: "prompts", + pull_prompt: state.pullPrompt, + push_prompt: state.pushPrompt, + sync_prompt: state.syncPrompt, + pull_prompt_args: + field === "pull_prompt" ? args : state.pullPromptArgs, + push_prompt_args: + field === "push_prompt" ? args : state.pushPromptArgs, + sync_prompt_args: + field === "sync_prompt" ? args : state.syncPromptArgs, + }; +} + +describe("beads upstream args button — computeArgsButtonState", () => { + const paramFree = { name: "sync-plain", parameters: [] }; + const paramFul = { + name: "sync-with-args", + parameters: [{ name: "target", type: "string" }], + }; + const prompts = [paramFree, paramFul]; + + test("disabled when no prompt is selected (empty value)", () => { + const s = computeArgsButtonState("", prompts, false); + expect(s.canEditArgs).toBe(false); + expect(s.disabled).toBe(true); + expect(s.params).toEqual([]); + }); + + test("disabled when selected prompt has no parameters", () => { + const s = computeArgsButtonState("sync-plain", prompts, false); + expect(s.selectedPrompt).toBe(paramFree); + expect(s.params).toEqual([]); + expect(s.canEditArgs).toBe(false); + expect(s.disabled).toBe(true); + }); + + test("enabled when selected prompt declares parameters", () => { + const s = computeArgsButtonState("sync-with-args", prompts, false); + expect(s.selectedPrompt).toBe(paramFul); + expect(s.params).toEqual([{ name: "target", type: "string" }]); + expect(s.canEditArgs).toBe(true); + expect(s.disabled).toBe(false); + }); + + test("disabled while upstream is saving even when parametrized", () => { + const s = computeArgsButtonState("sync-with-args", prompts, true); + expect(s.canEditArgs).toBe(true); + expect(s.disabled).toBe(true); + }); + + test("disabled when selected name is not in the prompts list", () => { + const s = computeArgsButtonState("missing", prompts, false); + expect(s.selectedPrompt).toBeUndefined(); + expect(s.params).toEqual([]); + expect(s.canEditArgs).toBe(false); + expect(s.disabled).toBe(true); + }); +}); + +describe("saveBeadsPromptArgs PUT body (buildSavePromptArgsBody)", () => { + const baseState = { + pullPrompt: "pull-p", + pushPrompt: "push-p", + syncPrompt: "sync-p", + pullPromptArgs: { a: "1" }, + pushPromptArgs: { b: "2" }, + syncPromptArgs: { c: "3" }, + }; + + test("replaces pull_prompt_args, keeps the others intact", () => { + const body = buildSavePromptArgsBody( + "pull_prompt", + { a: "new" }, + baseState, + ); + expect(body).toEqual({ + upstream: "prompts", + pull_prompt: "pull-p", + push_prompt: "push-p", + sync_prompt: "sync-p", + pull_prompt_args: { a: "new" }, + push_prompt_args: { b: "2" }, + sync_prompt_args: { c: "3" }, + }); + }); + + test("replaces push_prompt_args only", () => { + const body = buildSavePromptArgsBody( + "push_prompt", + { b: "new" }, + baseState, + ); + expect(body.pull_prompt_args).toEqual({ a: "1" }); + expect(body.push_prompt_args).toEqual({ b: "new" }); + expect(body.sync_prompt_args).toEqual({ c: "3" }); + }); + + test("replaces sync_prompt_args only", () => { + const body = buildSavePromptArgsBody( + "sync_prompt", + { c: "new" }, + baseState, + ); + expect(body.pull_prompt_args).toEqual({ a: "1" }); + expect(body.push_prompt_args).toEqual({ b: "2" }); + expect(body.sync_prompt_args).toEqual({ c: "new" }); + }); + + test("always carries all three prompt names so switching a name does not wipe args", () => { + const body = buildSavePromptArgsBody("pull_prompt", {}, baseState); + expect(body.pull_prompt).toBe("pull-p"); + expect(body.push_prompt).toBe("push-p"); + expect(body.sync_prompt).toBe("sync-p"); + }); + + test("passes empty args map through (clears saved args)", () => { + const body = buildSavePromptArgsBody("sync_prompt", {}, baseState); + expect(body.sync_prompt_args).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// onOpenPromptParamDialog dispatch — mirrors the args-button onClick guard. +// --------------------------------------------------------------------------- + +/** + * Mirrors the args-button onClick: bails when disabled, when no dialog opener + * is available, or when no prompt is resolved. Otherwise forwards prompt, + * params, an onSubmit callback, and initialValues. + */ +function dispatchArgsButtonClick({ + canEditArgs, + onOpenPromptParamDialog, + selectedPrompt, + params, + savedArgs, + saveBeadsPromptArgs, + field, +}) { + if (!canEditArgs || !onOpenPromptParamDialog || !selectedPrompt) return false; + onOpenPromptParamDialog( + selectedPrompt, + params, + async (userArgs) => { + await saveBeadsPromptArgs(field, userArgs); + }, + { initialValues: savedArgs || {} }, + ); + return true; +} + +describe("args-button onClick (dispatchArgsButtonClick)", () => { + const prompt = { + name: "sync-with-args", + parameters: [{ name: "target", type: "string" }], + }; + const params = prompt.parameters; + + test("no-op when canEditArgs is false", () => { + const spy = jest.fn(); + const ok = dispatchArgsButtonClick({ + canEditArgs: false, + onOpenPromptParamDialog: spy, + selectedPrompt: prompt, + params, + savedArgs: {}, + saveBeadsPromptArgs: jest.fn(), + field: "sync_prompt", + }); + expect(ok).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + + test("no-op when onOpenPromptParamDialog is missing", () => { + const ok = dispatchArgsButtonClick({ + canEditArgs: true, + onOpenPromptParamDialog: null, + selectedPrompt: prompt, + params, + savedArgs: {}, + saveBeadsPromptArgs: jest.fn(), + field: "sync_prompt", + }); + expect(ok).toBe(false); + }); + + test("no-op when selectedPrompt is null", () => { + const spy = jest.fn(); + const ok = dispatchArgsButtonClick({ + canEditArgs: true, + onOpenPromptParamDialog: spy, + selectedPrompt: null, + params, + savedArgs: {}, + saveBeadsPromptArgs: jest.fn(), + field: "sync_prompt", + }); + expect(ok).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + + test("opens the dialog with prompt, params, onSubmit, and initialValues", () => { + const spy = jest.fn(); + const savedArgs = { target: "prod" }; + const ok = dispatchArgsButtonClick({ + canEditArgs: true, + onOpenPromptParamDialog: spy, + selectedPrompt: prompt, + params, + savedArgs, + saveBeadsPromptArgs: jest.fn(), + field: "pull_prompt", + }); + expect(ok).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); + const [passedPrompt, passedParams, onSubmit, opts] = spy.mock.calls[0]; + expect(passedPrompt).toBe(prompt); + expect(passedParams).toBe(params); + expect(typeof onSubmit).toBe("function"); + expect(opts).toEqual({ initialValues: savedArgs }); + }); + + test("onSubmit forwards user args to saveBeadsPromptArgs with the correct field", async () => { + const save = jest.fn().mockResolvedValue(undefined); + let captured = null; + dispatchArgsButtonClick({ + canEditArgs: true, + onOpenPromptParamDialog: (_p, _params, onSubmit) => { + captured = onSubmit; + }, + selectedPrompt: prompt, + params, + savedArgs: {}, + saveBeadsPromptArgs: save, + field: "push_prompt", + }); + await captured({ target: "staging" }); + expect(save).toHaveBeenCalledWith("push_prompt", { target: "staging" }); + }); + + test("defaults savedArgs to {} when absent", () => { + const spy = jest.fn(); + dispatchArgsButtonClick({ + canEditArgs: true, + onOpenPromptParamDialog: spy, + selectedPrompt: prompt, + params, + savedArgs: undefined, + saveBeadsPromptArgs: jest.fn(), + field: "pull_prompt", + }); + const [, , , opts] = spy.mock.calls[0]; + expect(opts).toEqual({ initialValues: {} }); + }); +}); diff --git a/web/static/components/index.js b/web/static/components/index.js index 1a1dd64e..b236221f 100644 --- a/web/static/components/index.js +++ b/web/static/components/index.js @@ -14,9 +14,10 @@ export { Modal } from "./Modal.js"; export { DeleteDialog } from "./DeleteDialog.js"; export { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog.js"; export { NewSessionWorkspaceDialog } from "./NewSessionWorkspaceDialog.js"; -export { PeriodicScheduleDialog } from "./PeriodicScheduleDialog.js"; +export { LoopScheduleDialog } from "./LoopScheduleDialog.js"; export { SessionItem } from "./SessionItem.js"; export { SessionList } from "./SessionList.js"; +export { Toolbar } from "./Toolbar.js"; // Icon components export { diff --git a/web/static/constants.js b/web/static/constants.js index 65bab11f..04fccb95 100644 --- a/web/static/constants.js +++ b/web/static/constants.js @@ -2,11 +2,11 @@ // Centralized configuration values and constants // ============================================================================= -// Periodic Progress Indicator Configuration +// Loop Progress Indicator Configuration // ============================================================================= /** - * Progress indicator style for periodic sessions in the sidebar. + * Progress indicator style for loop sessions in the sidebar. * The background of the session item shows elapsed/remaining time until next run. * * Available styles: @@ -16,13 +16,13 @@ * - "gradient_fade": Smooth gradient transition * - "none": Disable progress indicator */ -export const PERIODIC_PROGRESS_STYLE = "progress_bar"; +export const LOOP_PROGRESS_STYLE = "progress_bar"; /** * Color configurations for each progress style. * Each style defines colors for light and dark themes. */ -export const PERIODIC_PROGRESS_COLORS = { +export const LOOP_PROGRESS_COLORS = { // Progress bar: full-width fill as item background (uses slate-700 gray like ACP cards) progress_bar: { light: { @@ -82,7 +82,7 @@ export const PERIODIC_PROGRESS_COLORS = { * When remaining time is below this threshold, the urgent color is used. * At 0.05 (5%), urgent triggers when >= 95% of the interval has elapsed. */ -export const PERIODIC_PROGRESS_URGENT_THRESHOLD = 0.05; // 5% remaining = 95% elapsed +export const LOOP_PROGRESS_URGENT_THRESHOLD = 0.05; // 5% remaining = 95% elapsed // ============================================================================= // Conversation Cycling diff --git a/web/static/hooks/index.js b/web/static/hooks/index.js index 3119d137..85cc0278 100644 --- a/web/static/hooks/index.js +++ b/web/static/hooks/index.js @@ -19,8 +19,8 @@ export { useConversationMenu } from "./useConversationMenu.js"; export { buildSeedQueueBody, seedConversationWithPrompt, - decidePeriodicAction, - makePeriodicNow, + decideLoopAction, + makeLoopNow, useConversationSeeding, } from "./useConversationSeeding.js"; export { useBeadsKnownIds } from "./useBeadsKnownIds.js"; diff --git a/web/static/hooks/useBeadsIntegration.js b/web/static/hooks/useBeadsIntegration.js index 5a2e6fb7..86466474 100644 --- a/web/static/hooks/useBeadsIntegration.js +++ b/web/static/hooks/useBeadsIntegration.js @@ -12,7 +12,7 @@ import { menuSatisfies, collectPromptArguments, getMissingPromptParameters, - promptResolveAsPeriodic, + promptResolveAsLoop, } from "../utils/prompts.js"; import { useConversationSeeding } from "./useConversationSeeding.js"; @@ -29,9 +29,9 @@ import { useConversationSeeding } from "./useConversationSeeding.js"; * @param {Function} deps.setShowSidebar - Closes sidebar overlay (mobile). * @param {Function} deps.setShowSidePanel - Closes/opens the side panel (used in handleOpenBeadsIssue / return). * @param {Function} deps.setSidePanelTab - Selects the side panel tab (used when returning to a conversation). - * @param {Function} [deps.onOpenPeriodicDialog] - Opens the periodic schedule dialog. + * @param {Function} [deps.onOpenLoopDialog] - Opens the loop schedule dialog. * Signature: (prompt, onSchedule: ({ value, unit, at? }) => void) => void. - * When absent, periodic prompts fall back to the one-time named-prompt path. + * When absent, loop prompts fall back to the one-time named-prompt path. * @param {Function} [deps.onOpenPromptParamDialog] - Opens the prompt parameter dialog * to collect free-text parameters that the beadsIssues menu cannot auto-fill. * Signature: (prompt, parameters, onSubmit: (argsMap) => void) => void. @@ -47,7 +47,7 @@ export function useBeadsIntegration({ setShowSidebar, setShowSidePanel, setSidePanelTab, - onOpenPeriodicDialog, + onOpenLoopDialog, onOpenPromptParamDialog, activeSessionId, }) { @@ -236,9 +236,9 @@ export function useBeadsIntegration({ const convName = issue.title ? `${issue.id} · ${issue.title}` : issue.id; // Build the auto-filled args map and the list of parameters the menu - // cannot supply UP-FRONT, so BOTH the periodic and one-time paths receive - // the issue context (e.g. ${ISSUE_ID}). Previously the periodic branch - // returned before these were computed, so periodic conversations were + // cannot supply UP-FRONT, so BOTH the loop and one-time paths receive + // the issue context (e.g. ${ISSUE_ID}). Previously the loop branch + // returned before these were computed, so loop conversations were // created with no arguments and ${ISSUE_ID} was never substituted. const autoArgs = collectPromptArguments(prompt, { beadsId: issue.id, @@ -246,13 +246,13 @@ export function useBeadsIntegration({ }); const missing = getMissingPromptParameters(prompt, "beadsIssues"); - // Periodic prompts create a recurring conversation instead of a one-time seed. - const asPeriodic = promptResolveAsPeriodic(prompt, opts?.asPeriodic); - if (asPeriodic && onOpenPeriodicDialog) { - // Open the periodic dialog and start the conversation with the resolved + // Loop prompts create a recurring conversation instead of a one-time seed. + const asLoop = promptResolveAsLoop(prompt, opts?.asLoop); + if (asLoop && onOpenLoopDialog) { + // Open the loop dialog and start the conversation with the resolved // arguments merged in (so ${VAR} substitution sees the issue context). - const launchPeriodic = (args) => { - onOpenPeriodicDialog(prompt, async (schedule) => { + const launchLoop = (args) => { + onOpenLoopDialog(prompt, async (schedule) => { const result = await startConversationWithPrompt({ workingDir: beadsWorkingDir, acpServer: ws?.acp_server, @@ -260,13 +260,13 @@ export function useBeadsIntegration({ beadsIssue: issue.id, prompt, arguments: args, - periodic: schedule, + loop: schedule, }); if (!result?.sessionId) { showToast({ style: "error", title: - result?.error || "Failed to create periodic conversation", + result?.error || "Failed to create loop conversation", duration: 4000, }); return; @@ -274,22 +274,22 @@ export function useBeadsIntegration({ setMainView("conversation"); showToast({ style: "success", - title: `Started periodic "${prompt.name}" for ${issue.id}`, + title: `Started loop "${prompt.name}" for ${issue.id}`, duration: 3000, }); }); }; // When the menu can't auto-fill every parameter, collect the rest first, - // then open the periodic dialog with the merged arguments. + // then open the loop dialog with the merged arguments. if (missing.length > 0 && onOpenPromptParamDialog) { onOpenPromptParamDialog(prompt, missing, async (userArgs) => { - launchPeriodic({ ...autoArgs, ...userArgs }); + launchLoop({ ...autoArgs, ...userArgs }); }); return; } - launchPeriodic(autoArgs); + launchLoop(autoArgs); return; } @@ -359,7 +359,7 @@ export function useBeadsIntegration({ workspaces, startConversationWithPrompt, showToast, - onOpenPeriodicDialog, + onOpenLoopDialog, onOpenPromptParamDialog, ], ); @@ -381,21 +381,21 @@ export function useBeadsIntegration({ const beadsMatches = workspaces.filter((w) => w.working_dir === wd); const ws = beadsMatches.find((w) => w.is_default) || beadsMatches[0]; - // Periodic prompts create a recurring conversation instead of a one-time seed. - const asPeriodic = promptResolveAsPeriodic(prompt, opts?.asPeriodic); - if (asPeriodic && onOpenPeriodicDialog) { - onOpenPeriodicDialog(prompt, async (schedule) => { + // Loop prompts create a recurring conversation instead of a one-time seed. + const asLoop = promptResolveAsLoop(prompt, opts?.asLoop); + if (asLoop && onOpenLoopDialog) { + onOpenLoopDialog(prompt, async (schedule) => { const result = await startConversationWithPrompt({ workingDir: wd, acpServer: ws?.acp_server, name: prompt.name, prompt, - periodic: schedule, + loop: schedule, }); if (!result?.sessionId) { showToast({ style: "error", - title: result?.error || "Failed to create periodic conversation", + title: result?.error || "Failed to create loop conversation", duration: 4000, }); return; @@ -403,7 +403,7 @@ export function useBeadsIntegration({ setMainView("conversation"); showToast({ style: "success", - title: `Started periodic "${prompt.name}"`, + title: `Started loop "${prompt.name}"`, duration: 3000, }); }); @@ -441,7 +441,7 @@ export function useBeadsIntegration({ workspaces, startConversationWithPrompt, showToast, - onOpenPeriodicDialog, + onOpenLoopDialog, ], ); diff --git a/web/static/hooks/useConversationMenu.js b/web/static/hooks/useConversationMenu.js index 2f86dbac..944c50bb 100644 --- a/web/static/hooks/useConversationMenu.js +++ b/web/static/hooks/useConversationMenu.js @@ -4,7 +4,7 @@ // sidebar conversation rows (SessionItem) and the chat header three-dot button // so both surfaces expose an identical menu. Encapsulates the context-menu // open/close state, lazy-loaded menus:conversation prompts, and the assembled -// ContextMenu items array (prompt submenus, Properties, periodic toggle, +// ContextMenu items array (prompt submenus, Properties, loop toggle, // archive/unarchive, delete). const { html, useState, useMemo, useCallback } = window.preact; @@ -25,15 +25,15 @@ export function useConversationMenu({ session, workingDir = "", isArchived = false, - isPeriodicConfigured = false, + isLoopConfigured = false, isSpawned = false, canArchive = true, archiveBlockedReason = null, onRename, onDelete, onArchive, - onMakePeriodic, - onMakeNonPeriodic, + onMakeLoop, + onMakeNonLoop, onFetchConversationPrompts, // async (session, workingDir) => menus:conversation prompts onSendPromptToConversation, // (session, prompt) when a context-menu prompt is clicked onCopyConversation, // optional: (session) => void — shows "Copy as Markdown" item @@ -84,16 +84,23 @@ export function useConversationMenu({ const closeContextMenu = useCallback(() => setContextMenu(null), []); - const contextMenuItems = useMemo(() => { - const promptGroupItems = + // Prompt group submenus (menus:conversation prompts), e.g. "Workflow". + // Exposed separately so surfaces like the conversation Toolbar can render + // these hierarchical groups inside a dedicated dropdown while promoting the + // fixed actions (Copy, Flush, Loop, Archive, Delete) to top-level buttons. + const promptGroupItems = useMemo( + () => onSendPromptToConversation && menuPrompts && menuPrompts.length > 0 ? buildPromptGroupMenuItems( menuPrompts, (p, opts) => onSendPromptToConversation(session, p, opts), html`<${LightningIcon} />`, ) - : []; + : [], + [menuPrompts, onSendPromptToConversation, session], + ); + const contextMenuItems = useMemo(() => { return [ // Prompt group submenus (menus:conversation prompts), e.g. "Workflow" ...promptGroupItems, @@ -124,27 +131,27 @@ export function useConversationMenu({ }, ] : []), - // "Make periodic" — only for conversations without a periodic config yet, - // non-spawned, non-archived. Gated on periodic_configured (not - // periodic_enabled) so a paused/draft periodic conversation is still - // treated as already periodic and does not offer "Make periodic" again. - ...(!isPeriodicConfigured && !isSpawned && !isArchived + // "Make loop" — only for conversations without a loop config yet, + // non-spawned, non-archived. Gated on loop_configured (not + // loop_enabled) so a paused/draft loop conversation is still + // treated as already loop and does not offer "Make loop" again. + ...(!isLoopConfigured && !isSpawned && !isArchived ? [ { - label: "Make periodic", + label: "Make loop", icon: html`<${ClockIcon} />`, - onClick: () => onMakePeriodic && onMakePeriodic(session), + onClick: () => onMakeLoop && onMakeLoop(session), }, ] : []), - // "Make non-periodic" — inverse: any conversation that has a periodic + // "Make non-loop" — inverse: any conversation that has a loop // config (enabled OR paused/draft), non-spawned, can remove it. - ...(isPeriodicConfigured && !isSpawned + ...(isLoopConfigured && !isSpawned ? [ { - label: "Make non-periodic", + label: "Make non-loop", icon: html`<${MittoIcon} />`, - onClick: () => onMakeNonPeriodic && onMakeNonPeriodic(session), + onClick: () => onMakeNonLoop && onMakeNonLoop(session), }, ] : []), @@ -175,15 +182,14 @@ export function useConversationMenu({ }, ]; }, [ - menuPrompts, - onSendPromptToConversation, + promptGroupItems, session, onRename, - isPeriodicConfigured, + isLoopConfigured, isSpawned, isArchived, - onMakePeriodic, - onMakeNonPeriodic, + onMakeLoop, + onMakeNonLoop, canArchive, archiveBlockedReason, onArchive, @@ -196,6 +202,7 @@ export function useConversationMenu({ return { contextMenu, contextMenuItems, + promptGroupItems, openContextMenuAt, closeContextMenu, handleContextMenu, diff --git a/web/static/hooks/useConversationSeeding.js b/web/static/hooks/useConversationSeeding.js index 1a4e3437..4be320f7 100644 --- a/web/static/hooks/useConversationSeeding.js +++ b/web/static/hooks/useConversationSeeding.js @@ -1,6 +1,6 @@ // Mitto Web Interface - Conversation Seeding Hook // Shared helper to seed a conversation with a named prompt via prompt_name, -// or to create a new periodic conversation driven by a named prompt. +// or to create a new loop conversation driven by a named prompt. import { secureFetch } from "../utils/csrf.js"; import { apiUrl } from "../utils/api.js"; @@ -36,38 +36,38 @@ export function parseDurationToSeconds(input) { } /** - * Decide which periodic action to take based on the target session's state. + * Decide which loop action to take based on the target session's state. * * Returns one of: - * "new-periodic" — no session (or no session_id): create a NEW periodic conversation. - * "one-shot" — session is already periodic, or it is a child: send once, do NOT modify config. - * "make-periodic" — regular running conversation: configure it as periodic now. + * "new-loop" — no session (or no session_id): create a NEW loop conversation. + * "one-shot" — session is already a loop, or it is a child: send once, do NOT modify config. + * "make-loop" — regular running conversation: configure it as a loop now. * * @param {Object|null|undefined} session - The target session object (from session list / info). - * @returns {"new-periodic" | "one-shot" | "make-periodic"} + * @returns {"new-loop" | "one-shot" | "make-loop"} */ -export function decidePeriodicAction(session) { - if (!session || !session.session_id) return "new-periodic"; - if (session.periodic_enabled || session.periodic_configured) +export function decideLoopAction(session) { + if (!session || !session.session_id) return "new-loop"; + if (session.loop_enabled || session.loop_configured) return "one-shot"; if (session.parent_session_id) return "one-shot"; - return "make-periodic"; + return "make-loop"; } /** - * Make an existing regular conversation immediately periodic using a prompt's + * Make an existing regular conversation immediately a loop using a prompt's * declared defaults, then fire the first run. * * Steps: - * 1. PUT /api/sessions/{id}/periodic — configure prompt_name + frequency + max_iterations - * 2. POST /api/sessions/{id}/periodic/run-now — fire first run (reset_timer: true) + * 1. PUT /api/sessions/{id}/loop — configure prompt_name + frequency + max_iterations + * 2. POST /api/sessions/{id}/loop/run-now — fire first run (reset_timer: true) * * @param {string} sessionId - * @param {{ name: string, periodic?: { value?: number, unit?: string, at?: string, maxIterations?: number } }} prompt + * @param {{ name: string, loop?: { value?: number, unit?: string, at?: string, maxIterations?: number } }} prompt * @param {{ arguments?: Object, fetchImpl?: Function }} [opts] * @returns {Promise<{ success: boolean, error?: string }>} */ -export async function makePeriodicNow( +export async function makeLoopNow( sessionId, prompt, { arguments: args, fetchImpl } = {}, @@ -76,7 +76,7 @@ export async function makePeriodicNow( return { success: false, error: "invalid_request" }; } - const p = prompt?.periodic || {}; + const p = prompt?.loop || {}; const value = p.value || 1; const unit = p.unit || "hours"; const frequency = { value, unit }; @@ -89,16 +89,19 @@ export async function makePeriodicNow( ? p.maxIterations : 0; - // New trigger/delay/maxDuration fields from prompt periodic defaults. + // New trigger/delay/maxDuration fields from prompt loop defaults. const trigger = p.trigger || "schedule"; const delaySeconds = p.delay ?? 0; const maxDurationSeconds = parseDurationToSeconds(p.maxDuration); + // onTasks CEL condition, from the prompt's loop frontmatter default. + // conditionPreset is intentionally NOT threaded here (mitto-pei). + const condition = p.condition ?? ""; const fetch_ = fetchImpl || secureFetch; - // Step 1: configure periodic + // Step 1: configure loop try { - const putResp = await fetch_(endpoints.sessions.periodic(sessionId), { + const putResp = await fetch_(endpoints.sessions.loop(sessionId), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -109,6 +112,7 @@ export async function makePeriodicNow( trigger, delay_seconds: delaySeconds, max_duration_seconds: maxDurationSeconds, + ...(trigger === "onTasks" ? { condition } : {}), ...(args && typeof args === "object" && Object.keys(args).length > 0 ? { arguments: args } : {}), @@ -121,23 +125,23 @@ export async function makePeriodicNow( } catch (_) {} return { success: false, - error: errData.error || "periodic_setup_failed", + error: errData.error || "loop_setup_failed", }; } } catch (err) { - console.error("makePeriodicNow PUT error:", err); - return { success: false, error: "periodic_setup_failed" }; + console.error("makeLoopNow PUT error:", err); + return { success: false, error: "loop_setup_failed" }; } // Step 2: fire first run. - // NOTE: by this point the PUT above has already persisted the periodic config - // (the conversation IS periodic). The run-now POST is best-effort: a 409 + // NOTE: by this point the PUT above has already persisted the loop config + // (the conversation IS a loop). The run-now POST is best-effort: a 409 // (Conflict / session busy) means a run is already in flight — e.g. enabling a - // schedule-based config immediately fired its first run — so periodic is set + // schedule-based config immediately fired its first run — so the loop is set // and running. Treat 409 as success rather than surfacing a misleading - // "failed to configure periodic" error to the user. + // "failed to configure loop" error to the user. try { - const runResp = await fetch_(endpoints.sessions.periodicRunNow(sessionId), { + const runResp = await fetch_(endpoints.sessions.loopRunNow(sessionId), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reset_timer: true }), @@ -154,7 +158,7 @@ export async function makePeriodicNow( return { success: false, error: errData.error || "run_now_failed" }; } } catch (err) { - console.error("makePeriodicNow run-now error:", err); + console.error("makeLoopNow run-now error:", err); return { success: false, error: "run_now_failed" }; } @@ -221,22 +225,22 @@ export async function seedConversationWithPrompt( } /** - * Configure a periodic schedule on a newly-created session via PUT. - * Includes max_iterations when periodic.maxIterations is a positive number, - * or falls back to prompt?.periodic?.maxIterations. Sends 0 (unlimited) otherwise. + * Configure a loop schedule on a newly-created session via PUT. + * Includes max_iterations when loop.maxIterations is a positive number, + * or falls back to prompt?.loop?.maxIterations. Sends 0 (unlimited) otherwise. * @param {string} sessionId - * @param {{ name: string, periodic?: { maxIterations?: number } }} prompt - * @param {{ value: number, unit: string, at?: string, maxIterations?: number }} periodic + * @param {{ name: string, loop?: { maxIterations?: number } }} prompt + * @param {{ value: number, unit: string, at?: string, maxIterations?: number }} loop * @param {{ arguments?: Object, fetchImpl?: Function }} [opts] * @returns {Promise<{ success: boolean, error?: string }>} */ -export async function configurePeriodicSchedule( +export async function configureLoopSchedule( sessionId, prompt, - periodic, + loop, { arguments: args, fetchImpl } = {}, ) { - const { value, unit, at } = periodic; + const { value, unit, at } = loop; const frequency = { value, unit }; // Only include 'at' for daily schedules (matches backend Frequency.Validate() rules) if (unit === "days" && at) { @@ -246,28 +250,29 @@ export async function configurePeriodicSchedule( // Resolve max_iterations: from the dialog's returned value, then from prompt defaults. // A positive number is sent as-is; 0 means unlimited. let maxIterations = 0; - if ( - typeof periodic.maxIterations === "number" && - periodic.maxIterations > 0 - ) { - maxIterations = periodic.maxIterations; + if (typeof loop.maxIterations === "number" && loop.maxIterations > 0) { + maxIterations = loop.maxIterations; } else if ( - typeof prompt?.periodic?.maxIterations === "number" && - prompt.periodic.maxIterations > 0 + typeof prompt?.loop?.maxIterations === "number" && + prompt.loop.maxIterations > 0 ) { - maxIterations = prompt.periodic.maxIterations; + maxIterations = prompt.loop.maxIterations; } // New trigger/delay/maxDuration fields: from dialog result, then prompt defaults. - const trigger = periodic.trigger || prompt?.periodic?.trigger || "schedule"; - const delaySeconds = periodic.delaySeconds ?? prompt?.periodic?.delay ?? 0; + const trigger = loop.trigger || prompt?.loop?.trigger || "schedule"; + const delaySeconds = loop.delaySeconds ?? prompt?.loop?.delay ?? 0; const maxDurationSeconds = - periodic.maxDurationSeconds ?? - parseDurationToSeconds(prompt?.periodic?.maxDuration); + loop.maxDurationSeconds ?? + parseDurationToSeconds(prompt?.loop?.maxDuration); + // onTasks CEL condition: from the dialog result, then the prompt's loop + // frontmatter default. conditionPreset is intentionally NOT threaded + // here (mitto-pei). + const condition = loop.condition ?? prompt?.loop?.condition ?? ""; const fetch_ = fetchImpl || secureFetch; try { - const resp = await fetch_(endpoints.sessions.periodic(sessionId), { + const resp = await fetch_(endpoints.sessions.loop(sessionId), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -278,6 +283,7 @@ export async function configurePeriodicSchedule( trigger, delay_seconds: delaySeconds, max_duration_seconds: maxDurationSeconds, + ...(trigger === "onTasks" ? { condition } : {}), ...(args && typeof args === "object" && Object.keys(args).length > 0 ? { arguments: args } : {}), @@ -291,10 +297,10 @@ export async function configurePeriodicSchedule( try { errData = await resp.json(); } catch (_) {} - return { success: false, error: errData.error || "periodic_setup_failed" }; + return { success: false, error: errData.error || "loop_setup_failed" }; } catch (err) { - console.error("configurePeriodicSchedule error:", err); - return { success: false, error: "periodic_setup_failed" }; + console.error("configureLoopSchedule error:", err); + return { success: false, error: "loop_setup_failed" }; } } @@ -313,20 +319,20 @@ export function useConversationSeeding({ newSession }) { const startConversationWithPrompt = useCallback( /** * Create a new conversation seeded with a named prompt (one-time queue), - * or create a new periodic conversation driven by the named prompt. + * or create a new loop conversation driven by the named prompt. * - * When `periodic` is absent (or falsy): behave exactly as before — the + * When `loop` is absent (or falsy): behave exactly as before — the * session is created with `initialPromptName` so the queue delivers the * prompt as a one-time message. * - * When `periodic` is present: the session is created WITHOUT a queue seed, - * then `PUT /api/sessions/{id}/periodic` configures the named prompt on the - * periodic schedule. `at` (if provided) must already be in UTC HH:MM. + * When `loop` is present: the session is created WITHOUT a queue seed, + * then `PUT /api/sessions/{id}/loop` configures the named prompt on the + * loop schedule. `at` (if provided) must already be in UTC HH:MM. * * originPromptName is set on the session opts from prompt.name so the * backend can later detect duplicate singleton-prompt conversations. * - * @param {{ workingDir, acpServer, name, beadsIssue, prompt, arguments, periodic, fetchImpl }} opts + * @param {{ workingDir, acpServer, name, beadsIssue, prompt, arguments, loop, fetchImpl }} opts * @returns {Promise<{ sessionId: string, reused?: boolean } | { error: string }>} */ async ({ @@ -336,10 +342,10 @@ export function useConversationSeeding({ newSession }) { beadsIssue, prompt, arguments: args, - periodic, + loop, fetchImpl, }) => { - // Build the newSession call — skip the queue seed when periodic is present. + // Build the newSession call — skip the queue seed when loop is present. const sessionOpts = { workingDir, acpServer, @@ -347,7 +353,7 @@ export function useConversationSeeding({ newSession }) { beadsIssue, originPromptName: prompt?.name, }; - if (!periodic) { + if (!loop) { // One-time path: pass the named prompt so the queue delivers it once. sessionOpts.initialPromptName = prompt?.name; sessionOpts.arguments = args; @@ -358,16 +364,16 @@ export function useConversationSeeding({ newSession }) { return { error: result?.error || "session_creation_failed" }; } - if (periodic) { - // Periodic path: configure the schedule via PUT after creation. - const putResult = await configurePeriodicSchedule( + if (loop) { + // Loop path: configure the schedule via PUT after creation. + const putResult = await configureLoopSchedule( result.sessionId, prompt, - periodic, + loop, { arguments: args, fetchImpl }, ); if (!putResult.success) { - // Session was created but periodic config failed — surface the error. + // Session was created but loop config failed — surface the error. return { error: putResult.error }; } } diff --git a/web/static/hooks/useConversationSeeding.test.js b/web/static/hooks/useConversationSeeding.test.js index 6fc75509..7e63c5fe 100644 --- a/web/static/hooks/useConversationSeeding.test.js +++ b/web/static/hooks/useConversationSeeding.test.js @@ -6,13 +6,13 @@ import { jest } from "@jest/globals"; import { buildSeedQueueBody, seedConversationWithPrompt, - configurePeriodicSchedule, - decidePeriodicAction, - makePeriodicNow, + configureLoopSchedule, + decideLoopAction, + makeLoopNow, useConversationSeeding, parseDurationToSeconds, } from "./useConversationSeeding.js"; -import { promptResolveAsPeriodic } from "../utils/prompts.js"; +import { promptResolveAsLoop } from "../utils/prompts.js"; // Provide a minimal window.preact stub so the module-level destructure doesn't throw. global.window = global.window || {}; @@ -286,10 +286,10 @@ describe("useConversationSeeding — startConversationWithPrompt", () => { }); // ============================================================================= -// configurePeriodicSchedule +// configureLoopSchedule // ============================================================================= -describe("configurePeriodicSchedule", () => { +describe("configureLoopSchedule", () => { const prompt = { name: "daily-standup" }; function makeFetch(status, data = {}) { @@ -302,9 +302,9 @@ describe("configurePeriodicSchedule", () => { ); } - test("PUTs to /api/sessions/{id}/periodic with correct body for hours", async () => { + test("PUTs to /api/sessions/{id}/loop with correct body for hours", async () => { const fetchImpl = makeFetch(200, {}); - await configurePeriodicSchedule( + await configureLoopSchedule( "sess-1", prompt, { value: 2, unit: "hours" }, @@ -312,7 +312,7 @@ describe("configurePeriodicSchedule", () => { ); const [url, opts] = fetchImpl.mock.calls[0]; - expect(url).toContain("/api/sessions/sess-1/periodic"); + expect(url).toContain("/api/sessions/sess-1/loop"); expect(opts.method).toBe("PUT"); const body = JSON.parse(opts.body); @@ -325,7 +325,7 @@ describe("configurePeriodicSchedule", () => { test("includes 'at' in frequency only for days unit", async () => { const fetchImpl = makeFetch(200, {}); - await configurePeriodicSchedule( + await configureLoopSchedule( "sess-2", prompt, { value: 1, unit: "days", at: "09:00" }, @@ -339,7 +339,7 @@ describe("configurePeriodicSchedule", () => { test("omits 'at' for minutes unit even when provided", async () => { const fetchImpl = makeFetch(200, {}); - await configurePeriodicSchedule( + await configureLoopSchedule( "sess-3", prompt, { value: 30, unit: "minutes", at: "09:00" }, @@ -353,7 +353,7 @@ describe("configurePeriodicSchedule", () => { test("returns success:true on 200 response", async () => { const fetchImpl = makeFetch(200, {}); - const result = await configurePeriodicSchedule( + const result = await configureLoopSchedule( "sess-4", prompt, { value: 1, unit: "hours" }, @@ -362,9 +362,9 @@ describe("configurePeriodicSchedule", () => { expect(result).toEqual({ success: true }); }); - test("returns success:false with periodic_setup_failed on non-ok response", async () => { + test("returns success:false with loop_setup_failed on non-ok response", async () => { const fetchImpl = makeFetch(400, { error: "bad_request" }); - const result = await configurePeriodicSchedule( + const result = await configureLoopSchedule( "sess-5", prompt, { value: 1, unit: "hours" }, @@ -376,22 +376,22 @@ describe("configurePeriodicSchedule", () => { test("returns success:false on network error", async () => { const fetchImpl = jest.fn(() => Promise.reject(new Error("net fail"))); - const result = await configurePeriodicSchedule( + const result = await configureLoopSchedule( "sess-6", prompt, { value: 1, unit: "hours" }, { fetchImpl }, ); expect(result.success).toBe(false); - expect(result.error).toBe("periodic_setup_failed"); + expect(result.error).toBe("loop_setup_failed"); }); }); // ============================================================================= -// useConversationSeeding — startConversationWithPrompt periodic path +// useConversationSeeding — startConversationWithPrompt loop path // ============================================================================= -describe("useConversationSeeding — startConversationWithPrompt periodic path", () => { +describe("useConversationSeeding — startConversationWithPrompt loop path", () => { function makeFetch(status, data = {}) { return jest.fn(() => Promise.resolve({ @@ -402,10 +402,10 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path", ); } - test("periodic: does NOT pass initialPromptName to newSession", async () => { + test("loop: does NOT pass initialPromptName to newSession", async () => { const newSession = jest .fn() - .mockResolvedValue({ sessionId: "sess-periodic" }); + .mockResolvedValue({ sessionId: "sess-loop" }); const fetchImpl = makeFetch(200, {}); const { startConversationWithPrompt } = useConversationSeeding({ newSession, @@ -414,7 +414,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path", await startConversationWithPrompt({ prompt: { name: "daily-standup" }, workingDir: "/w", - periodic: { value: 1, unit: "hours" }, + loop: { value: 1, unit: "hours" }, fetchImpl, }); @@ -423,10 +423,10 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path", expect(callArg).not.toHaveProperty("arguments"); }); - test("periodic: PUTs periodic config after session creation", async () => { + test("loop: PUTs loop config after session creation", async () => { const newSession = jest .fn() - .mockResolvedValue({ sessionId: "sess-periodic" }); + .mockResolvedValue({ sessionId: "sess-loop" }); const fetchImpl = makeFetch(200, {}); const { startConversationWithPrompt } = useConversationSeeding({ newSession, @@ -435,7 +435,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path", const result = await startConversationWithPrompt({ prompt: { name: "daily-standup" }, workingDir: "/w", - periodic: { value: 1, unit: "days", at: "09:00" }, + loop: { value: 1, unit: "days", at: "09:00" }, fetchImpl, }); @@ -443,7 +443,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path", expect(fetchImpl).toHaveBeenCalledTimes(1); const [url, opts] = fetchImpl.mock.calls[0]; - expect(url).toContain("/api/sessions/sess-periodic/periodic"); + expect(url).toContain("/api/sessions/sess-loop/loop"); expect(opts.method).toBe("PUT"); const body = JSON.parse(opts.body); @@ -451,10 +451,10 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path", expect(body.enabled).toBe(true); expect(body.frequency.at).toBe("09:00"); - expect(result).toEqual({ sessionId: "sess-periodic", reused: false }); + expect(result).toEqual({ sessionId: "sess-loop", reused: false }); }); - test("periodic: returns error if periodic PUT fails", async () => { + test("loop: returns error if loop PUT fails", async () => { const newSession = jest.fn().mockResolvedValue({ sessionId: "sess-fail" }); const fetchImpl = makeFetch(500, { error: "server_error" }); const { startConversationWithPrompt } = useConversationSeeding({ @@ -464,7 +464,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path", const result = await startConversationWithPrompt({ prompt: { name: "p1" }, workingDir: "/w", - periodic: { value: 1, unit: "hours" }, + loop: { value: 1, unit: "hours" }, fetchImpl, }); @@ -472,7 +472,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path", expect(result).not.toHaveProperty("sessionId"); }); - test("non-periodic: still passes initialPromptName (unchanged behavior)", async () => { + test("non-loop: still passes initialPromptName (unchanged behavior)", async () => { const newSession = jest .fn() .mockResolvedValue({ sessionId: "sess-one-time" }); @@ -494,59 +494,59 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path", }); // ============================================================================= -// decidePeriodicAction +// decideLoopAction // ============================================================================= -describe("decidePeriodicAction", () => { - test("returns new-periodic when session is null", () => { - expect(decidePeriodicAction(null)).toBe("new-periodic"); +describe("decideLoopAction", () => { + test("returns new-loop when session is null", () => { + expect(decideLoopAction(null)).toBe("new-loop"); }); - test("returns new-periodic when session is undefined", () => { - expect(decidePeriodicAction(undefined)).toBe("new-periodic"); + test("returns new-loop when session is undefined", () => { + expect(decideLoopAction(undefined)).toBe("new-loop"); }); - test("returns new-periodic when session has no session_id", () => { - expect(decidePeriodicAction({ name: "foo" })).toBe("new-periodic"); + test("returns new-loop when session has no session_id", () => { + expect(decideLoopAction({ name: "foo" })).toBe("new-loop"); }); - test("returns one-shot when session is periodic_enabled", () => { + test("returns one-shot when session is loop_enabled", () => { expect( - decidePeriodicAction({ session_id: "s1", periodic_enabled: true }), + decideLoopAction({ session_id: "s1", loop_enabled: true }), ).toBe("one-shot"); }); - test("returns one-shot when session is periodic_configured (but not enabled)", () => { + test("returns one-shot when session is loop_configured (but not enabled)", () => { expect( - decidePeriodicAction({ session_id: "s1", periodic_configured: true }), + decideLoopAction({ session_id: "s1", loop_configured: true }), ).toBe("one-shot"); }); test("returns one-shot when session has parent_session_id (child conversation)", () => { expect( - decidePeriodicAction({ session_id: "s1", parent_session_id: "parent-1" }), + decideLoopAction({ session_id: "s1", parent_session_id: "parent-1" }), ).toBe("one-shot"); }); - test("returns make-periodic for a regular running conversation", () => { - expect(decidePeriodicAction({ session_id: "s1" })).toBe("make-periodic"); + test("returns make-loop for a regular running conversation", () => { + expect(decideLoopAction({ session_id: "s1" })).toBe("make-loop"); }); - test("returns make-periodic even when periodic_enabled is false/undefined", () => { + test("returns make-loop even when loop_enabled is false/undefined", () => { expect( - decidePeriodicAction({ session_id: "s1", periodic_enabled: false }), - ).toBe("make-periodic"); + decideLoopAction({ session_id: "s1", loop_enabled: false }), + ).toBe("make-loop"); }); }); // ============================================================================= -// makePeriodicNow +// makeLoopNow // ============================================================================= -describe("makePeriodicNow", () => { +describe("makeLoopNow", () => { const prompt = { name: "daily-standup", - periodic: { value: 1, unit: "hours", maxIterations: 5 }, + loop: { value: 1, unit: "hours", maxIterations: 5 }, }; function makeFetchSequence(...responses) { @@ -566,21 +566,21 @@ describe("makePeriodicNow", () => { } test("returns invalid_request when sessionId is missing", async () => { - const result = await makePeriodicNow(null, prompt); + const result = await makeLoopNow(null, prompt); expect(result).toEqual({ success: false, error: "invalid_request" }); }); test("returns invalid_request when prompt.name is missing", async () => { - const result = await makePeriodicNow("sess-1", { periodic: {} }); + const result = await makeLoopNow("sess-1", { loop: {} }); expect(result).toEqual({ success: false, error: "invalid_request" }); }); - test("PUTs periodic config with correct body including max_iterations", async () => { + test("PUTs loop config with correct body including max_iterations", async () => { const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); - await makePeriodicNow("sess-1", prompt, { fetchImpl }); + await makeLoopNow("sess-1", prompt, { fetchImpl }); const [putUrl, putOpts] = fetchImpl.mock.calls[0]; - expect(putUrl).toContain("/api/sessions/sess-1/periodic"); + expect(putUrl).toContain("/api/sessions/sess-1/loop"); expect(putOpts.method).toBe("PUT"); const body = JSON.parse(putOpts.body); @@ -593,11 +593,11 @@ describe("makePeriodicNow", () => { test("POSTs run-now with reset_timer:true after successful PUT", async () => { const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); - const result = await makePeriodicNow("sess-1", prompt, { fetchImpl }); + const result = await makeLoopNow("sess-1", prompt, { fetchImpl }); expect(fetchImpl).toHaveBeenCalledTimes(2); const [runUrl, runOpts] = fetchImpl.mock.calls[1]; - expect(runUrl).toContain("/api/sessions/sess-1/periodic/run-now"); + expect(runUrl).toContain("/api/sessions/sess-1/loop/run-now"); expect(runOpts.method).toBe("POST"); const runBody = JSON.parse(runOpts.body); @@ -610,7 +610,7 @@ describe("makePeriodicNow", () => { const fetchImpl = makeFetchSequence( makeResp(500, { error: "server_error" }), ); - const result = await makePeriodicNow("sess-1", prompt, { fetchImpl }); + const result = await makeLoopNow("sess-1", prompt, { fetchImpl }); expect(fetchImpl).toHaveBeenCalledTimes(1); expect(result.success).toBe(false); @@ -620,10 +620,10 @@ describe("makePeriodicNow", () => { test("includes 'at' in frequency only for days unit", async () => { const promptWithDays = { name: "daily-report", - periodic: { value: 1, unit: "days", at: "09:00", maxIterations: 0 }, + loop: { value: 1, unit: "days", at: "09:00", maxIterations: 0 }, }; const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); - await makePeriodicNow("sess-2", promptWithDays, { fetchImpl }); + await makeLoopNow("sess-2", promptWithDays, { fetchImpl }); const body = JSON.parse(fetchImpl.mock.calls[0][1].body); expect(body.frequency.at).toBe("09:00"); @@ -632,10 +632,10 @@ describe("makePeriodicNow", () => { test("sends max_iterations:0 when prompt has no maxIterations", async () => { const noMaxPrompt = { name: "simple", - periodic: { value: 2, unit: "hours" }, + loop: { value: 2, unit: "hours" }, }; const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); - await makePeriodicNow("sess-3", noMaxPrompt, { fetchImpl }); + await makeLoopNow("sess-3", noMaxPrompt, { fetchImpl }); const body = JSON.parse(fetchImpl.mock.calls[0][1].body); expect(body.max_iterations).toBe(0); @@ -646,29 +646,29 @@ describe("makePeriodicNow", () => { makeResp(200), makeResp(500, { error: "server_error" }), ); - const result = await makePeriodicNow("sess-4", prompt, { fetchImpl }); + const result = await makeLoopNow("sess-4", prompt, { fetchImpl }); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); test("treats run-now 409 (session busy) as success after PUT succeeds", async () => { - // The PUT already persisted the periodic config; a 409 means a run is already + // The PUT already persisted the loop config; a 409 means a run is already // in flight (e.g. enabling a schedule fired its first run). Not a failure. const fetchImpl = makeFetchSequence( makeResp(200), makeResp(409, { error: "busy" }), ); - const result = await makePeriodicNow("sess-5", prompt, { fetchImpl }); + const result = await makeLoopNow("sess-5", prompt, { fetchImpl }); expect(result).toEqual({ success: true }); }); }); // ============================================================================= -// configurePeriodicSchedule — max_iterations support +// configureLoopSchedule — max_iterations support // ============================================================================= -describe("configurePeriodicSchedule — max_iterations", () => { - const prompt = { name: "my-prompt", periodic: { maxIterations: 10 } }; +describe("configureLoopSchedule — max_iterations", () => { + const prompt = { name: "my-prompt", loop: { maxIterations: 10 } }; function makeFetch(status) { return jest.fn(() => @@ -680,9 +680,9 @@ describe("configurePeriodicSchedule — max_iterations", () => { ); } - test("includes max_iterations from periodic.maxIterations when positive", async () => { + test("includes max_iterations from loop.maxIterations when positive", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", { name: "p" }, { value: 1, unit: "hours", maxIterations: 7 }, @@ -692,9 +692,9 @@ describe("configurePeriodicSchedule — max_iterations", () => { expect(body.max_iterations).toBe(7); }); - test("falls back to prompt.periodic.maxIterations when periodic.maxIterations is absent", async () => { + test("falls back to prompt.loop.maxIterations when loop.maxIterations is absent", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", prompt, { value: 1, unit: "hours" }, @@ -706,7 +706,7 @@ describe("configurePeriodicSchedule — max_iterations", () => { test("sends max_iterations:0 when both are absent/zero (unlimited)", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", { name: "p" }, { value: 1, unit: "hours", maxIterations: 0 }, @@ -716,9 +716,9 @@ describe("configurePeriodicSchedule — max_iterations", () => { expect(body.max_iterations).toBe(0); }); - test("periodic.maxIterations takes priority over prompt.periodic.maxIterations", async () => { + test("loop.maxIterations takes priority over prompt.loop.maxIterations", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", prompt, { value: 1, unit: "hours", maxIterations: 3 }, @@ -730,25 +730,25 @@ describe("configurePeriodicSchedule — max_iterations", () => { }); // ============================================================================= -// ChatInput.handlePredefinedPrompt routing — periodic branch +// ChatInput.handlePredefinedPrompt routing — loop branch // -// Tests the pure routing decision: when a prompt has .periodic set and -// onPeriodicPrompt is provided, it must be called; otherwise the normal path runs. +// Tests the pure routing decision: when a prompt has .loop set and +// onLoopPrompt is provided, it must be called; otherwise the normal path runs. // This mirrors what ChatInput.handlePredefinedPrompt does after the shiftKey check. // ============================================================================= -describe("ChatInput periodic routing — onPeriodicPrompt delegation", () => { +describe("ChatInput loop routing — onLoopPrompt delegation", () => { /** * Minimal simulation of the ChatInput.handlePredefinedPrompt routing logic. * Extracted here so we can test without mounting the full ChatInput component. - * Mirrors the real code: const asPeriodic = prompt && promptResolveAsPeriodic(prompt); - * if (asPeriodic && onPeriodicPrompt) { onPeriodicPrompt(prompt); return; } (mitto-92x.3). + * Mirrors the real code: const asLoop = prompt && promptResolveAsLoop(prompt); + * if (asLoop && onLoopPrompt) { onLoopPrompt(prompt); return; } (mitto-92x.3). */ - function routePrompt(prompt, { onPeriodicPrompt, onSend } = {}) { - const asPeriodic = prompt && promptResolveAsPeriodic(prompt); - if (asPeriodic && onPeriodicPrompt) { - onPeriodicPrompt(prompt); - return "periodic"; + function routePrompt(prompt, { onLoopPrompt, onSend } = {}) { + const asLoop = prompt && promptResolveAsLoop(prompt); + if (asLoop && onLoopPrompt) { + onLoopPrompt(prompt); + return "loop"; } if (onSend && prompt?.name) { onSend(prompt.name); @@ -757,37 +757,37 @@ describe("ChatInput periodic routing — onPeriodicPrompt delegation", () => { return "noop"; } - test("calls onPeriodicPrompt for a periodic-flagged prompt", () => { - const onPeriodicPrompt = jest.fn(); + test("calls onLoopPrompt for a loop-flagged prompt", () => { + const onLoopPrompt = jest.fn(); const onSend = jest.fn(); const prompt = { name: "daily-standup", - periodic: { value: 1, unit: "hours" }, + loop: { value: 1, unit: "hours" }, }; - const result = routePrompt(prompt, { onPeriodicPrompt, onSend }); + const result = routePrompt(prompt, { onLoopPrompt, onSend }); - expect(onPeriodicPrompt).toHaveBeenCalledTimes(1); - expect(onPeriodicPrompt).toHaveBeenCalledWith(prompt); + expect(onLoopPrompt).toHaveBeenCalledTimes(1); + expect(onLoopPrompt).toHaveBeenCalledWith(prompt); expect(onSend).not.toHaveBeenCalled(); - expect(result).toBe("periodic"); + expect(result).toBe("loop"); }); - test("does NOT call onPeriodicPrompt for a non-periodic prompt — falls through to onSend", () => { - const onPeriodicPrompt = jest.fn(); + test("does NOT call onLoopPrompt for a non-loop prompt — falls through to onSend", () => { + const onLoopPrompt = jest.fn(); const onSend = jest.fn(); const prompt = { name: "regular-prompt", prompt: "do something" }; - const result = routePrompt(prompt, { onPeriodicPrompt, onSend }); + const result = routePrompt(prompt, { onLoopPrompt, onSend }); - expect(onPeriodicPrompt).not.toHaveBeenCalled(); + expect(onLoopPrompt).not.toHaveBeenCalled(); expect(onSend).toHaveBeenCalledWith("regular-prompt"); expect(result).toBe("send"); }); - test("falls through to onSend when onPeriodicPrompt is absent (even for periodic prompt)", () => { + test("falls through to onSend when onLoopPrompt is absent (even for loop prompt)", () => { const onSend = jest.fn(); - const prompt = { name: "daily", periodic: { value: 1, unit: "hours" } }; + const prompt = { name: "daily", loop: { value: 1, unit: "hours" } }; const result = routePrompt(prompt, { onSend }); @@ -795,43 +795,43 @@ describe("ChatInput periodic routing — onPeriodicPrompt delegation", () => { expect(result).toBe("send"); }); - test("does nothing when prompt has no name and no periodic", () => { - const onPeriodicPrompt = jest.fn(); + test("does nothing when prompt has no name and no loop", () => { + const onLoopPrompt = jest.fn(); const onSend = jest.fn(); - const result = routePrompt({}, { onPeriodicPrompt, onSend }); + const result = routePrompt({}, { onLoopPrompt, onSend }); - expect(onPeriodicPrompt).not.toHaveBeenCalled(); + expect(onLoopPrompt).not.toHaveBeenCalled(); expect(onSend).not.toHaveBeenCalled(); expect(result).toBe("noop"); }); - // mitto-92x.3: routing now flows through promptResolveAsPeriodic (mode-aware), - // not a bare `prompt.periodic` presence check. - test("mode: always (no explicit mode) routes to onPeriodicPrompt — unchanged behavior", () => { - const onPeriodicPrompt = jest.fn(); + // mitto-92x.3: routing now flows through promptResolveAsLoop (mode-aware), + // not a bare `prompt.loop` presence check. + test("mode: always (no explicit mode) routes to onLoopPrompt — unchanged behavior", () => { + const onLoopPrompt = jest.fn(); const onSend = jest.fn(); - const prompt = { name: "daily", periodic: { value: 1, unit: "hours" } }; + const prompt = { name: "daily", loop: { value: 1, unit: "hours" } }; - const result = routePrompt(prompt, { onPeriodicPrompt, onSend }); + const result = routePrompt(prompt, { onLoopPrompt, onSend }); - expect(onPeriodicPrompt).toHaveBeenCalledWith(prompt); + expect(onLoopPrompt).toHaveBeenCalledWith(prompt); expect(onSend).not.toHaveBeenCalled(); - expect(result).toBe("periodic"); + expect(result).toBe("loop"); }); test("mode: optional, default:false resolves to one-shot — falls through to onSend (no override in ChatInput yet)", () => { - const onPeriodicPrompt = jest.fn(); + const onLoopPrompt = jest.fn(); const onSend = jest.fn(); const prompt = { - name: "maybe-periodic", - periodic: { mode: "optional", default: false }, + name: "maybe-loop", + loop: { mode: "optional", default: false }, }; - const result = routePrompt(prompt, { onPeriodicPrompt, onSend }); + const result = routePrompt(prompt, { onLoopPrompt, onSend }); - expect(onPeriodicPrompt).not.toHaveBeenCalled(); - expect(onSend).toHaveBeenCalledWith("maybe-periodic"); + expect(onLoopPrompt).not.toHaveBeenCalled(); + expect(onSend).toHaveBeenCalledWith("maybe-loop"); expect(result).toBe("send"); }); }); @@ -887,10 +887,10 @@ describe("parseDurationToSeconds", () => { }); // ============================================================================= -// configurePeriodicSchedule — trigger/delay/maxDuration fields +// configureLoopSchedule — trigger/delay/maxDuration fields // ============================================================================= -describe("configurePeriodicSchedule — trigger/delay/maxDuration fields", () => { +describe("configureLoopSchedule — trigger/delay/maxDuration fields", () => { const prompt = { name: "my-prompt" }; function makeFetch(status) { @@ -905,7 +905,7 @@ describe("configurePeriodicSchedule — trigger/delay/maxDuration fields", () => test("includes trigger, delay_seconds, max_duration_seconds in PUT body", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", prompt, { @@ -925,7 +925,7 @@ describe("configurePeriodicSchedule — trigger/delay/maxDuration fields", () => test("defaults trigger to 'schedule' and delay/maxDuration to 0 when absent", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", prompt, { value: 1, unit: "hours" }, @@ -937,11 +937,11 @@ describe("configurePeriodicSchedule — trigger/delay/maxDuration fields", () => expect(body.max_duration_seconds).toBe(0); }); - test("falls back to prompt.periodic.trigger when periodic.trigger absent", async () => { + test("falls back to prompt.loop.trigger when loop.trigger absent", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", - { name: "p", periodic: { trigger: "onCompletion" } }, + { name: "p", loop: { trigger: "onCompletion" } }, { value: 1, unit: "hours" }, { fetchImpl }, ); @@ -949,11 +949,11 @@ describe("configurePeriodicSchedule — trigger/delay/maxDuration fields", () => expect(body.trigger).toBe("onCompletion"); }); - test("falls back to prompt.periodic.delay for delay_seconds when absent from periodic", async () => { + test("falls back to prompt.loop.delay for delay_seconds when absent from loop", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", - { name: "p", periodic: { delay: 15 } }, + { name: "p", loop: { delay: 15 } }, { value: 1, unit: "hours" }, { fetchImpl }, ); @@ -961,11 +961,11 @@ describe("configurePeriodicSchedule — trigger/delay/maxDuration fields", () => expect(body.delay_seconds).toBe(15); }); - test("parses prompt.periodic.maxDuration string ('2h') into max_duration_seconds", async () => { + test("parses prompt.loop.maxDuration string ('2h') into max_duration_seconds", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", - { name: "p", periodic: { maxDuration: "2h" } }, + { name: "p", loop: { maxDuration: "2h" } }, { value: 1, unit: "hours" }, { fetchImpl }, ); @@ -973,11 +973,11 @@ describe("configurePeriodicSchedule — trigger/delay/maxDuration fields", () => expect(body.max_duration_seconds).toBe(7200); }); - test("periodic.maxDurationSeconds takes priority over prompt.periodic.maxDuration", async () => { + test("loop.maxDurationSeconds takes priority over prompt.loop.maxDuration", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", - { name: "p", periodic: { maxDuration: "2h" } }, + { name: "p", loop: { maxDuration: "2h" } }, { value: 1, unit: "hours", maxDurationSeconds: 300 }, { fetchImpl }, ); @@ -987,10 +987,93 @@ describe("configurePeriodicSchedule — trigger/delay/maxDuration fields", () => }); // ============================================================================= -// makePeriodicNow — trigger/delay/maxDuration fields +// configureLoopSchedule — onTasks condition field (mitto-pei) // ============================================================================= -describe("makePeriodicNow — trigger/delay/maxDuration fields", () => { +describe("configureLoopSchedule — onTasks condition field", () => { + function makeFetch(status) { + return jest.fn(() => + Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve({}), + }), + ); + } + + test("trigger=onTasks: falls back to prompt.loop.condition when dialog result has none", async () => { + const fetchImpl = makeFetch(200); + await configureLoopSchedule( + "s1", + { name: "p", loop: { trigger: "onTasks", condition: "size(x) > 0" } }, + { value: 1, unit: "hours", trigger: "onTasks" }, + { fetchImpl }, + ); + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body.trigger).toBe("onTasks"); + expect(body.condition).toBe("size(x) > 0"); + }); + + test("trigger=onTasks: dialog loop.condition overrides the prompt default", async () => { + const fetchImpl = makeFetch(200); + await configureLoopSchedule( + "s1", + { name: "p", loop: { trigger: "onTasks", condition: "prompt default" } }, + { + value: 1, + unit: "hours", + trigger: "onTasks", + condition: "dialog override", + }, + { fetchImpl }, + ); + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body.condition).toBe("dialog override"); + }); + + test("non-onTasks trigger: condition is not sent even if prompt declares one", async () => { + const fetchImpl = makeFetch(200); + await configureLoopSchedule( + "s1", + { name: "p", loop: { trigger: "onTasks", condition: "size(x) > 0" } }, + { value: 1, unit: "hours", trigger: "schedule" }, + { fetchImpl }, + ); + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body).not.toHaveProperty("condition"); + }); + + test("trigger=onTasks with no condition anywhere: sends empty string", async () => { + const fetchImpl = makeFetch(200); + await configureLoopSchedule( + "s1", + { name: "p" }, + { value: 1, unit: "hours", trigger: "onTasks" }, + { fetchImpl }, + ); + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body.condition).toBe(""); + }); + + test("conditionPreset is never sent (out of scope for mitto-pei)", async () => { + const fetchImpl = makeFetch(200); + await configureLoopSchedule( + "s1", + { name: "p", loop: { trigger: "onTasks", condition: "size(x) > 0" } }, + { value: 1, unit: "hours", trigger: "onTasks" }, + { fetchImpl }, + ); + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body).not.toHaveProperty("condition_preset"); + expect(body).not.toHaveProperty("conditionPreset"); + }); +}); + +// ============================================================================= +// makeLoopNow — trigger/delay/maxDuration fields +// ============================================================================= + +describe("makeLoopNow — trigger/delay/maxDuration fields", () => { function makeFetchSequence(...responses) { let i = 0; return jest.fn(() => { @@ -1007,10 +1090,10 @@ describe("makePeriodicNow — trigger/delay/maxDuration fields", () => { }; } - test("includes trigger from prompt.periodic in PUT body", async () => { + test("includes trigger from prompt.loop in PUT body", async () => { const prompt = { name: "p", - periodic: { + loop: { value: 1, unit: "hours", trigger: "onCompletion", @@ -1019,7 +1102,7 @@ describe("makePeriodicNow — trigger/delay/maxDuration fields", () => { }, }; const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); - await makePeriodicNow("sess-1", prompt, { fetchImpl }); + await makeLoopNow("sess-1", prompt, { fetchImpl }); const body = JSON.parse(fetchImpl.mock.calls[0][1].body); expect(body.trigger).toBe("onCompletion"); @@ -1028,9 +1111,9 @@ describe("makePeriodicNow — trigger/delay/maxDuration fields", () => { }); test("defaults trigger to 'schedule' and delay/maxDuration to 0 when absent", async () => { - const prompt = { name: "p", periodic: { value: 1, unit: "hours" } }; + const prompt = { name: "p", loop: { value: 1, unit: "hours" } }; const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); - await makePeriodicNow("sess-1", prompt, { fetchImpl }); + await makeLoopNow("sess-1", prompt, { fetchImpl }); const body = JSON.parse(fetchImpl.mock.calls[0][1].body); expect(body.trigger).toBe("schedule"); @@ -1040,13 +1123,82 @@ describe("makePeriodicNow — trigger/delay/maxDuration fields", () => { }); // ============================================================================= -// makePeriodicNow — arguments forwarding +// makeLoopNow — onTasks condition field (mitto-pei) +// ============================================================================= + +describe("makeLoopNow — onTasks condition field", () => { + function makeFetchSequence(...responses) { + let i = 0; + return jest.fn(() => { + const r = responses[i++] || responses[responses.length - 1]; + return Promise.resolve(r); + }); + } + + function makeResp(status, data = {}) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + }; + } + + test("trigger=onTasks: includes condition from prompt.loop.condition", async () => { + const prompt = { + name: "p", + loop: { + value: 1, + unit: "hours", + trigger: "onTasks", + condition: "size(x) > 0", + }, + }; + const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); + await makeLoopNow("sess-1", prompt, { fetchImpl }); + + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body.trigger).toBe("onTasks"); + expect(body.condition).toBe("size(x) > 0"); + }); + + test("non-onTasks trigger: condition is not sent even if prompt declares one", async () => { + const prompt = { + name: "p", + loop: { + value: 1, + unit: "hours", + trigger: "schedule", + condition: "size(x) > 0", + }, + }; + const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); + await makeLoopNow("sess-1", prompt, { fetchImpl }); + + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body).not.toHaveProperty("condition"); + }); + + test("trigger=onTasks with no prompt condition: sends empty string", async () => { + const prompt = { + name: "p", + loop: { value: 1, unit: "hours", trigger: "onTasks" }, + }; + const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); + await makeLoopNow("sess-1", prompt, { fetchImpl }); + + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body.condition).toBe(""); + }); +}); + +// ============================================================================= +// makeLoopNow — arguments forwarding // ============================================================================= -describe("makePeriodicNow — arguments forwarding", () => { +describe("makeLoopNow — arguments forwarding", () => { const prompt = { name: "daily-standup", - periodic: { value: 1, unit: "hours" }, + loop: { value: 1, unit: "hours" }, }; function makeFetchSequence(...responses) { @@ -1067,7 +1219,7 @@ describe("makePeriodicNow — arguments forwarding", () => { test("includes arguments in PUT body when non-empty map is supplied", async () => { const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); - await makePeriodicNow("sess-1", prompt, { + await makeLoopNow("sess-1", prompt, { arguments: { ENV: "prod", REGION: "us-east" }, fetchImpl, }); @@ -1078,7 +1230,7 @@ describe("makePeriodicNow — arguments forwarding", () => { test("omits arguments from PUT body when empty object is supplied", async () => { const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); - await makePeriodicNow("sess-1", prompt, { arguments: {}, fetchImpl }); + await makeLoopNow("sess-1", prompt, { arguments: {}, fetchImpl }); const body = JSON.parse(fetchImpl.mock.calls[0][1].body); expect(body).not.toHaveProperty("arguments"); @@ -1086,7 +1238,7 @@ describe("makePeriodicNow — arguments forwarding", () => { test("omits arguments from PUT body when undefined (no opts supplied)", async () => { const fetchImpl = makeFetchSequence(makeResp(200), makeResp(200)); - await makePeriodicNow("sess-1", prompt, { fetchImpl }); + await makeLoopNow("sess-1", prompt, { fetchImpl }); const body = JSON.parse(fetchImpl.mock.calls[0][1].body); expect(body).not.toHaveProperty("arguments"); @@ -1094,10 +1246,10 @@ describe("makePeriodicNow — arguments forwarding", () => { }); // ============================================================================= -// configurePeriodicSchedule — arguments forwarding +// configureLoopSchedule — arguments forwarding // ============================================================================= -describe("configurePeriodicSchedule — arguments forwarding", () => { +describe("configureLoopSchedule — arguments forwarding", () => { const prompt = { name: "daily-standup" }; function makeFetch(status, data = {}) { @@ -1112,7 +1264,7 @@ describe("configurePeriodicSchedule — arguments forwarding", () => { test("includes arguments in PUT body when non-empty map is supplied", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", prompt, { value: 1, unit: "hours" }, @@ -1125,7 +1277,7 @@ describe("configurePeriodicSchedule — arguments forwarding", () => { test("omits arguments from PUT body when empty object is supplied", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", prompt, { value: 1, unit: "hours" }, @@ -1138,7 +1290,7 @@ describe("configurePeriodicSchedule — arguments forwarding", () => { test("omits arguments from PUT body when not supplied", async () => { const fetchImpl = makeFetch(200); - await configurePeriodicSchedule( + await configureLoopSchedule( "s1", prompt, { value: 1, unit: "hours" }, @@ -1151,10 +1303,10 @@ describe("configurePeriodicSchedule — arguments forwarding", () => { }); // ============================================================================= -// startConversationWithPrompt periodic path — arguments forwarding +// startConversationWithPrompt loop path — arguments forwarding // ============================================================================= -describe("useConversationSeeding — startConversationWithPrompt periodic path — arguments", () => { +describe("useConversationSeeding — startConversationWithPrompt loop path — arguments", () => { function makeFetch(status, data = {}) { return jest.fn(() => Promise.resolve({ @@ -1165,7 +1317,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path ); } - test("periodic: forwards arguments into the PUT body when supplied", async () => { + test("loop: forwards arguments into the PUT body when supplied", async () => { const newSession = jest.fn().mockResolvedValue({ sessionId: "sess-p" }); const fetchImpl = makeFetch(200); const { startConversationWithPrompt } = useConversationSeeding({ @@ -1175,7 +1327,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path await startConversationWithPrompt({ prompt: { name: "daily-standup" }, workingDir: "/w", - periodic: { value: 1, unit: "hours" }, + loop: { value: 1, unit: "hours" }, arguments: { TEAM: "backend" }, fetchImpl, }); @@ -1184,7 +1336,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path expect(body.arguments).toEqual({ TEAM: "backend" }); }); - test("periodic: omits arguments from PUT body when not supplied", async () => { + test("loop: omits arguments from PUT body when not supplied", async () => { const newSession = jest.fn().mockResolvedValue({ sessionId: "sess-p" }); const fetchImpl = makeFetch(200); const { startConversationWithPrompt } = useConversationSeeding({ @@ -1194,7 +1346,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path await startConversationWithPrompt({ prompt: { name: "daily-standup" }, workingDir: "/w", - periodic: { value: 1, unit: "hours" }, + loop: { value: 1, unit: "hours" }, fetchImpl, }); @@ -1202,7 +1354,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path expect(body).not.toHaveProperty("arguments"); }); - test("periodic: omits arguments from PUT body when empty object", async () => { + test("loop: omits arguments from PUT body when empty object", async () => { const newSession = jest.fn().mockResolvedValue({ sessionId: "sess-p" }); const fetchImpl = makeFetch(200); const { startConversationWithPrompt } = useConversationSeeding({ @@ -1212,7 +1364,7 @@ describe("useConversationSeeding — startConversationWithPrompt periodic path await startConversationWithPrompt({ prompt: { name: "daily-standup" }, workingDir: "/w", - periodic: { value: 1, unit: "hours" }, + loop: { value: 1, unit: "hours" }, arguments: {}, fetchImpl, }); diff --git a/web/static/hooks/useWebSocket.js b/web/static/hooks/useWebSocket.js index 95825cbe..6cb1adb0 100644 --- a/web/static/hooks/useWebSocket.js +++ b/web/static/hooks/useWebSocket.js @@ -351,9 +351,9 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { // { sessionId, sessionName, timestamp } const [backgroundCompletion, setBackgroundCompletion] = useState(null); - // Track periodic session starts for toast notifications + // Track loop session starts for toast notifications // { sessionId, sessionName, timestamp } - const [periodicStarted, setPeriodicStarted] = useState(null); + const [loopStarted, setLoopStarted] = useState(null); // Track background UI prompts for toast notifications // { sessionId, sessionName, question, timestamp } @@ -1299,32 +1299,32 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { msg.data.archived_at ?? session.info?.archived_at ?? null, // Preserve archive_pending flag from existing session info archive_pending: session.info?.archive_pending || false, - // Periodic state from server: - // periodic_configured: config exists → drives editor UI + reconnect long-lived check - // periodic_enabled: runs active → drives sidebar category + clock icon - periodic_configured: - msg.data.periodic_configured ?? - session.info?.periodic_configured ?? + // Loop state from server: + // loop_configured: config exists → drives editor UI + reconnect long-lived check + // loop_enabled: runs active → drives sidebar category + clock icon + loop_configured: + msg.data.loop_configured ?? + session.info?.loop_configured ?? false, - periodic_enabled: - msg.data.periodic_enabled ?? - session.info?.periodic_enabled ?? + loop_enabled: + msg.data.loop_enabled ?? + session.info?.loop_enabled ?? false, - periodic_stopped_reason: - msg.data.periodic_stopped_reason ?? - session.info?.periodic_stopped_reason ?? + loop_stopped_reason: + msg.data.loop_stopped_reason ?? + session.info?.loop_stopped_reason ?? null, - periodic_trigger: - msg.data.periodic_trigger ?? - session.info?.periodic_trigger ?? + loop_trigger: + msg.data.loop_trigger ?? + session.info?.loop_trigger ?? null, - periodic_delay_seconds: - msg.data.periodic_delay_seconds ?? - session.info?.periodic_delay_seconds ?? + loop_delay_seconds: + msg.data.loop_delay_seconds ?? + session.info?.loop_delay_seconds ?? null, - periodic_max_duration_seconds: - msg.data.periodic_max_duration_seconds ?? - session.info?.periodic_max_duration_seconds ?? + loop_max_duration_seconds: + msg.data.loop_max_duration_seconds ?? + session.info?.loop_max_duration_seconds ?? null, workspace_uuid: msg.data.workspace_uuid ?? null, // ACP readiness: false until acp_started event or explicit true in connected msg @@ -1357,6 +1357,53 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { // Context window usage (size/used from ACP) context_usage: msg.data.context_usage ?? session.info?.context_usage ?? null, + // MCP usage stats (event-derived, survive restart) + mcp_calls_total: + msg.data.mcp_calls_total ?? + session.info?.mcp_calls_total ?? + 0, + mcp_ui_calls: + msg.data.mcp_ui_calls ?? session.info?.mcp_ui_calls ?? 0, + mcp_children_wait_calls: + msg.data.mcp_children_wait_calls ?? + session.info?.mcp_children_wait_calls ?? + 0, + // Orchestration stats: children spawned (event-derived) + child + // wait times (in-memory, resets on restart) + children_spawned: + msg.data.children_spawned ?? + session.info?.children_spawned ?? + 0, + child_wait_count: + msg.data.child_wait_count ?? + session.info?.child_wait_count ?? + 0, + child_wait_total_ms: + msg.data.child_wait_total_ms ?? + session.info?.child_wait_total_ms ?? + 0, + // Activity stats (event-derived, survive restart) + turns: msg.data.turns ?? session.info?.turns ?? 0, + acp_tool_calls: + msg.data.acp_tool_calls ?? session.info?.acp_tool_calls ?? 0, + permissions_allowed: + msg.data.permissions_allowed ?? + session.info?.permissions_allowed ?? + 0, + permissions_denied: + msg.data.permissions_denied ?? + session.info?.permissions_denied ?? + 0, + errors: msg.data.errors ?? session.info?.errors ?? 0, + images_uploaded: + msg.data.images_uploaded ?? + session.info?.images_uploaded ?? + 0, + // Cumulative token usage across all turns (in-memory, resets on restart) + usage_cumulative: + msg.data.usage_cumulative ?? + session.info?.usage_cumulative ?? + null, // Config options (model, mode, etc.) - per-session // Use ?? to preserve existing options when server omits the field (e.g. pre-acp_started reconnect) config_options: @@ -1378,7 +1425,7 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { // Check for gaps using max_seq (immediate gap detection). // IMPORTANT: Must run BEFORE updateLastKnownSeq so that clientMaxSeq // reflects the state before this message, allowing gap detection to - // catch missing events (e.g., user_prompt from periodic runner that + // catch missing events (e.g., user_prompt from loop runner that // was broadcast before the WebSocket observer was attached). if (maxSeq) { checkAndFillGap(sessionId, maxSeq, msgSeq); @@ -2017,7 +2064,7 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { }); } - // Update processor stats from keepalive_ack (periodic refresh) + // Update processor stats from keepalive_ack (loop refresh) if (msg.data?.processor_count !== undefined) { setSessions((prev) => { const session = prev[sessionId]; @@ -2093,7 +2140,7 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { }); } - // Update processor stats from keepalive (provides periodic refresh of activation counts) + // Update processor stats from keepalive (provides loop refresh of activation counts) if (msg.data?.processor_count !== undefined) { setSessions((prev) => { const session = prev[sessionId]; @@ -2543,7 +2590,7 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { // The after_seq delta only returns events NEWER than the watermark, so when // the client has no in-memory messages the recent history is missing. This // happens both when the delta is empty (nothing changed while away) AND when - // it returns only a partial page (e.g. a periodic run produced a few events + // it returns only a partial page (e.g. a loop run produced a few events // since the watermark): the user would otherwise see just those few events // plus a "Load earlier messages…" button until a hard reload. Detect either // case (no prior messages + delta smaller than a full page) and fall back to @@ -2642,7 +2689,7 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { // alreadyExists check inside setSessions handles dedup by seq match). // Previously, the M1 isSeqDuplicate check here would race with // events_loaded marking seqs as seen, causing user_prompt messages - // to be silently dropped for periodic prompts. + // to be silently dropped for loop prompts. if (!is_mine && seq) { markSeqSeen(sessionId, seq); } @@ -2747,7 +2794,7 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { // Check if this message already exists (by seq number) // Only dedupe by seq - content deduplication was too aggressive and blocked - // legitimate periodic prompts (same text sent on each run). + // legitimate loop prompts (same text sent on each run). // The seq number is authoritative: if the server sends a new seq, it's a new message. const alreadyExists = session.messages.some((m) => { if (m.role !== ROLE_USER) return false; @@ -3562,7 +3609,7 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { // server (the server only keeps sessions alive while the binary is // running; after a restart old sessions are gone). Reconnecting them // only causes the readyState: 3 CLOSED log-spam that triggered this fix. - // NOTE: Periodic sessions are exempt from this age check — they are + // NOTE: Loop sessions are exempt from this age check — they are // long-lived by design and must always be allowed to reconnect. // // 2. Attempt cap (isReconnectLimitReached from utils/websocket.js): @@ -3572,18 +3619,18 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { // The counter resets on the next successful onopen, and is cleared // when the user explicitly switches to this session (switchSession). - // Check if this conversation has a periodic config — those are long-lived by design + // Check if this conversation has a loop config — those are long-lived by design // and should always be allowed to reconnect regardless of age. - // Use periodic_configured (config exists) not periodic_enabled (runs active), - // so paused/draft periodic conversations still count as long-lived. - const isPeriodic = - sessionsRef.current[sessionId]?.info?.periodic_configured || + // Use loop_configured (config exists) not loop_enabled (runs active), + // so paused/draft loop conversations still count as long-lived. + const isLoop = + sessionsRef.current[sessionId]?.info?.loop_configured || storedSessionsRef.current?.find((s) => s.session_id === sessionId) - ?.periodic_configured; + ?.loop_configured; const sessionAgeMs = getSessionAgeMs(sessionId); const isTooOld = - !isPeriodic && + !isLoop && sessionAgeMs !== null && sessionAgeMs > SESSION_MAX_RECONNECT_AGE_MS; @@ -4269,37 +4316,37 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { break; } - case "periodic_updated": - // Update session periodic state - // This is broadcast when any session's periodic state changes + case "loop_updated": + // Update session loop state + // This is broadcast when any session's loop state changes // // Two separate concepts: - // - periodic_configured: true if periodic config exists (determines UI mode - shows frequency panel) - // - periodic_enabled: true if periodic runs are active (determines lock state) + // - loop_configured: true if loop config exists (determines UI mode - shows frequency panel) + // - loop_enabled: true if loop runs are active (determines lock state) // // Also includes frequency and next_scheduled_at for cross-client sync console.log( - `[global] Session periodic state changed: ${msg.data.session_id} -> configured=${msg.data.periodic_configured}, enabled=${msg.data.periodic_enabled}`, + `[global] Session loop state changed: ${msg.data.session_id} -> configured=${msg.data.loop_configured}, enabled=${msg.data.loop_enabled}`, ); // Update in stored sessions: - // periodic_enabled: runs active → sidebar category + clock icon - // periodic_configured: config exists → editor UI mode + // loop_enabled: runs active → sidebar category + clock icon + // loop_configured: config exists → editor UI mode setStoredSessions((prev) => prev.map((s) => s.session_id === msg.data.session_id ? { ...s, - periodic_enabled: msg.data.periodic_enabled, - periodic_configured: msg.data.periodic_configured, + loop_enabled: msg.data.loop_enabled, + loop_configured: msg.data.loop_configured, next_scheduled_at: msg.data.next_scheduled_at || null, - periodic_frequency: msg.data.frequency || null, - periodic_iteration_count: msg.data.iteration_count ?? null, - periodic_max_iterations: msg.data.max_iterations ?? null, - periodic_stopped_reason: - msg.data.periodic_stopped_reason || null, - periodic_trigger: msg.data.trigger ?? null, - periodic_delay_seconds: msg.data.delay_seconds ?? null, - periodic_max_duration_seconds: + loop_frequency: msg.data.frequency || null, + loop_iteration_count: msg.data.iteration_count ?? null, + loop_max_iterations: msg.data.max_iterations ?? null, + loop_stopped_reason: + msg.data.loop_stopped_reason || null, + loop_trigger: msg.data.trigger ?? null, + loop_delay_seconds: msg.data.delay_seconds ?? null, + loop_max_duration_seconds: msg.data.max_duration_seconds ?? null, } : s, @@ -4315,19 +4362,19 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { ...session, info: { ...session.info, - // periodic_enabled: runs active → sidebar category + clock icon - periodic_enabled: msg.data.periodic_enabled, - // periodic_configured: config exists → editor UI mode - periodic_configured: msg.data.periodic_configured, + // loop_enabled: runs active → sidebar category + clock icon + loop_enabled: msg.data.loop_enabled, + // loop_configured: config exists → editor UI mode + loop_configured: msg.data.loop_configured, next_scheduled_at: msg.data.next_scheduled_at || null, - periodic_frequency: msg.data.frequency || null, - periodic_iteration_count: msg.data.iteration_count ?? null, - periodic_max_iterations: msg.data.max_iterations ?? null, - periodic_stopped_reason: - msg.data.periodic_stopped_reason || null, - periodic_trigger: msg.data.trigger ?? null, - periodic_delay_seconds: msg.data.delay_seconds ?? null, - periodic_max_duration_seconds: + loop_frequency: msg.data.frequency || null, + loop_iteration_count: msg.data.iteration_count ?? null, + loop_max_iterations: msg.data.max_iterations ?? null, + loop_stopped_reason: + msg.data.loop_stopped_reason || null, + loop_trigger: msg.data.trigger ?? null, + loop_delay_seconds: msg.data.delay_seconds ?? null, + loop_max_duration_seconds: msg.data.max_duration_seconds ?? null, }, }, @@ -4336,31 +4383,31 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { // Dispatch custom event for ChatInput to handle frequency and lock state updates // This allows the frequency panel to update in real-time when another client changes it window.dispatchEvent( - new CustomEvent("mitto:periodic_config_updated", { + new CustomEvent("mitto:loop_config_updated", { detail: { sessionId: msg.data.session_id, - // periodicConfigured controls UI mode - periodicConfigured: msg.data.periodic_configured, - // periodicEnabled controls lock state (whether runs are active) - periodicEnabled: msg.data.periodic_enabled, + // loopConfigured controls UI mode + loopConfigured: msg.data.loop_configured, + // loopEnabled controls lock state (whether runs are active) + loopEnabled: msg.data.loop_enabled, frequency: msg.data.frequency, nextScheduledAt: msg.data.next_scheduled_at, freshContext: msg.data.fresh_context, iterationCount: msg.data.iteration_count, maxIterations: msg.data.max_iterations, - stoppedReason: msg.data.periodic_stopped_reason || null, + stoppedReason: msg.data.loop_stopped_reason || null, }, }), ); break; - case "periodic_started": - // A periodic prompt was delivered to a session + case "loop_started": + // A loop prompt was delivered to a session // Show toast notification and trigger native notification if enabled console.log( - `[global] Periodic started: ${msg.data.session_id} (${msg.data.session_name})`, + `[global] Loop started: ${msg.data.session_id} (${msg.data.session_name})`, ); - setPeriodicStarted({ + setLoopStarted({ sessionId: msg.data.session_id, sessionName: msg.data.session_name, timestamp: Date.now(), @@ -6150,9 +6197,9 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { setBackgroundCompletion(null); }, []); - // Clear periodic started notification - const clearPeriodicStarted = useCallback(() => { - setPeriodicStarted(null); + // Clear loop started notification + const clearLoopStarted = useCallback(() => { + setLoopStarted(null); }, []); // Clear background UI prompt notification @@ -6254,8 +6301,8 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) { fetchStoredSessions, backgroundCompletion, clearBackgroundCompletion, - periodicStarted, - clearPeriodicStarted, + loopStarted, + clearLoopStarted, backgroundUIPrompt, clearBackgroundUIPrompt, backgroundUIPromptTimeout, diff --git a/web/static/hooks/useWorkspacePrompts.js b/web/static/hooks/useWorkspacePrompts.js index 9bcd597d..7956af77 100644 --- a/web/static/hooks/useWorkspacePrompts.js +++ b/web/static/hooks/useWorkspacePrompts.js @@ -1,9 +1,9 @@ // web/static/hooks/useWorkspacePrompts.js // Manages workspace-prompt fetching, caching, and derived prompt lists for the // App. Handles initial fetch on workspace change, re-fetch on session switch, -// periodic 30-second refresh, visibility-based refresh, and file-watcher events +// loop 30-second refresh, visibility-based refresh, and file-watcher events // (mitto:prompts_changed). Exposes the full prompt list, the "prompts" dropup -// subset, the periodic-selector subset, and per-session / per-beads-issue fetch +// subset, the loop-selector subset, and per-session / per-beads-issue fetch // helpers. const { useState, useEffect, useCallback, useMemo } = window.preact; @@ -24,7 +24,7 @@ import { * @param {string|null|undefined} deps.activeSessionId - Active session id. * Drives per-session re-fetches (CEL expressions vary per session). * @returns {{ workspacePrompts: Array, predefinedPrompts: Array, - * periodicPrompts: Array, fetchWorkspacePrompts: Function, + * loopPrompts: Array, fetchWorkspacePrompts: Function, * fetchConversationPromptsForSession: Function }} */ export function useWorkspacePrompts({ @@ -45,26 +45,25 @@ export function useWorkspacePrompts({ [workspacePrompts], ); - // Periodic prompts: prompts shown in the PeriodicPromptSelector dropdown. A + // Loop prompts: prompts shown in the LoopPromptSelector dropdown. A // prompt appears here if it opts into "prompts" (default dropup) OR - // "promptsPeriodic" (periodic-selector-specific). The union keeps existing + // "promptsLoop" (loop-selector-specific). The union keeps existing // prompts available in the selector while letting authors target a prompt - // ONLY at the periodic selector via `menus: promptsPeriodic`. + // ONLY at the loop selector via `menus: promptsLoop`. // - // Exclusion: `!promptsPeriodic` in a prompt's `menus` field suppresses it - // from the periodic selector even when it would otherwise be included via - // the union (e.g. a one-shot prompt with `menus: prompts, !promptsPeriodic`). + // Exclusion: `!promptsLoop` in a prompt's `menus` field suppresses it + // from the loop selector even when it would otherwise be included via + // the union (e.g. a one-shot prompt with `menus: prompts, !promptsLoop`). // The exclusion is applied BEFORE the satisfaction check so it always wins. - const periodicPrompts = useMemo( + const loopPrompts = useMemo( () => workspacePrompts.filter((p) => { // Explicit exclusion takes precedence over the union rule. - if (promptMenuExcludes(p).has("promptsPeriodic")) return false; + if (promptMenuExcludes(p).has("promptsLoop")) return false; const menus = promptMenus(p); return ( (menus.includes("prompts") && menuSatisfies(p, "prompts")) || - (menus.includes("promptsPeriodic") && - menuSatisfies(p, "promptsPeriodic")) + (menus.includes("promptsLoop") && menuSatisfies(p, "promptsLoop")) ); }), [workspacePrompts], @@ -80,7 +79,7 @@ export function useWorkspacePrompts({ // the backend evaluates `enabledWhen` for that conversation, then keep only the // prompts that opt into the conversation menu via `menus`. const fetchConversationPromptsForSession = useCallback( - async (session, workingDir) => { + async (session, workingDir, menus = ["conversation"]) => { const sessionId = session?.session_id; const dir = workingDir || session?.working_dir; if (!sessionId || !dir) return []; @@ -94,10 +93,13 @@ export function useWorkspacePrompts({ if (!res.ok) return []; const data = await res.json(); const all = data?.prompts || []; - // Parameters that the "conversation" menu cannot auto-fill are collected - // via the PromptParameterDialog when the user selects such a prompt - // (mitto-hcf.3). No menuSatisfies gate — all params can be user-filled. - return all.filter((p) => p && promptMenuIncludes(p, "conversation")); + // Keep prompts that opt into ANY of the requested menus. Parameters that + // a menu cannot auto-fill are collected via the PromptParameterDialog + // when the user selects such a prompt (mitto-hcf.3). No menuSatisfies + // gate — all params can be user-filled. + return all.filter( + (p) => p && menus.some((m) => promptMenuIncludes(p, m)), + ); } catch (err) { console.error("Failed to fetch conversation prompts for session:", err); return []; @@ -107,7 +109,7 @@ export function useWorkspacePrompts({ ); // Fetch workspace prompts with conditional request support (If-Modified-Since) - // This enables efficient periodic refresh without transferring data if unchanged + // This enables efficient loop refresh without transferring data if unchanged const fetchWorkspacePrompts = useCallback( async (workingDir, forceRefresh = false) => { if (!workingDir) return; @@ -201,7 +203,7 @@ export function useWorkspacePrompts({ } }, [activeSessionId]); // intentionally omit workingDir/workspacePromptsDir/fetchWorkspacePrompts from deps - // Periodic refresh of workspace prompts (every 30 seconds) + // Loop refresh of workspace prompts (every 30 seconds) // Uses conditional requests to avoid unnecessary data transfer useEffect(() => { if (!workingDir) return; @@ -250,7 +252,7 @@ export function useWorkspacePrompts({ return { workspacePrompts, predefinedPrompts, - periodicPrompts, + loopPrompts, fetchWorkspacePrompts, fetchConversationPromptsForSession, }; diff --git a/web/static/lib.js b/web/static/lib.js index 51a1d739..2764b8c4 100644 --- a/web/static/lib.js +++ b/web/static/lib.js @@ -360,10 +360,10 @@ function _parseUndelimited(text, segments) { * @param {Array} storedSessions - Sessions loaded from storage * @returns {Array} Combined and sorted sessions */ -// Labels shown in the conversation-header subtitle when a periodic loop has stopped or paused. -// Keyed by the `periodic_stopped_reason` string sent by the backend. +// Labels shown in the conversation-header subtitle when a loop has stopped or paused. +// Keyed by the `loop_stopped_reason` string sent by the backend. // Each entry has { label, kind } where kind is "stopped" (terminal/red) or "paused" (resumable/amber). -export const PERIODIC_STOPPED_LABELS = { +export const LOOP_STOPPED_LABELS = { maxDuration: { label: "Stopped: max time", kind: "stopped" }, maxIterations: { label: "Stopped: max iters", kind: "stopped" }, iterationSafeguard: { label: "Stopped: max iters", kind: "stopped" }, @@ -374,12 +374,12 @@ export const PERIODIC_STOPPED_LABELS = { }; /** - * Compact human-readable duration for a periodic max-duration cap. + * Compact human-readable duration for a loop max-duration cap. * Rounds down to the largest whole unit (days > hours > minutes > seconds). * @param {number} seconds * @returns {string} e.g. "2d", "3h", "30min", "45s" */ -export function formatPeriodicMaxDuration(seconds) { +export function formatLoopMaxDuration(seconds) { if (seconds >= 86400 && seconds % 86400 === 0) return `${seconds / 86400}d`; if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600}h`; if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60}min`; @@ -388,7 +388,7 @@ export function formatPeriodicMaxDuration(seconds) { /** * Compact trigger-type label shown in the conversation-header subtitle badge next - * to the periodic status pill. "schedule" (default) shows the frequency (e.g. + * to the loop status pill. "schedule" (default) shows the frequency (e.g. * "every 2h"); "onCompletion" shows the post-completion delay; "onTasks" shows a * fixed label (fires on beads/task changes, not on a cadence, so no "every N" or * countdown is meaningful). @@ -417,7 +417,7 @@ export function computeHeaderTriggerLabel(trigger, delaySeconds, frequency) { } // ============================================================================= -// onTasks Condition Presets (periodic trigger CEL condition editor) +// onTasks Condition Presets (loop trigger CEL condition editor) // ============================================================================= /** @@ -561,7 +561,7 @@ export function computeAllSessions(activeSessions, storedSessions) { const acpServer = s.acp_server || s.info?.acp_server || stored?.acp_server || ""; - // Always merge stored properties (archived, name, pinned, periodic_enabled, periodic_configured, next_scheduled_at, periodic_frequency) if stored session exists + // Always merge stored properties (archived, name, pinned, loop_enabled, loop_configured, next_scheduled_at, loop_frequency) if stored session exists if (stored) { return { ...s, @@ -577,29 +577,29 @@ export function computeAllSessions(activeSessions, storedSessions) { // session_streaming events out of order, causing the sidebar dot to stay lit // after the per-session WebSocket has already received prompt_complete. isStreaming: s.isStreaming || false, - // periodic_enabled: runs active → sidebar category + clock icon - periodic_enabled: stored.periodic_enabled || false, - // periodic_configured: config exists → editor UI mode + reconnect long-lived check - periodic_configured: stored.periodic_configured || false, - // Progress bar: next run time and frequency (from API list or WebSocket periodic_updated) + // loop_enabled: runs active → sidebar category + clock icon + loop_enabled: stored.loop_enabled || false, + // loop_configured: config exists → editor UI mode + reconnect long-lived check + loop_configured: stored.loop_configured || false, + // Progress bar: next run time and frequency (from API list or WebSocket loop_updated) next_scheduled_at: s.next_scheduled_at ?? stored.next_scheduled_at ?? null, - periodic_frequency: - s.periodic_frequency ?? stored.periodic_frequency ?? null, - // Reason the periodic loop stopped (maxDuration, maxIterations, etc.); null while running - periodic_stopped_reason: - s.periodic_stopped_reason ?? stored.periodic_stopped_reason ?? null, - // Periodic glance fields (shown in the conversation-header subtitle) - periodic_trigger: s.periodic_trigger ?? stored.periodic_trigger ?? null, - periodic_iteration_count: - s.periodic_iteration_count ?? stored.periodic_iteration_count ?? null, - periodic_max_iterations: - s.periodic_max_iterations ?? stored.periodic_max_iterations ?? null, - periodic_delay_seconds: - s.periodic_delay_seconds ?? stored.periodic_delay_seconds ?? null, - periodic_max_duration_seconds: - s.periodic_max_duration_seconds ?? - stored.periodic_max_duration_seconds ?? + loop_frequency: + s.loop_frequency ?? stored.loop_frequency ?? null, + // Reason the loop loop stopped (maxDuration, maxIterations, etc.); null while running + loop_stopped_reason: + s.loop_stopped_reason ?? stored.loop_stopped_reason ?? null, + // Loop glance fields (shown in the conversation-header subtitle) + loop_trigger: s.loop_trigger ?? stored.loop_trigger ?? null, + loop_iteration_count: + s.loop_iteration_count ?? stored.loop_iteration_count ?? null, + loop_max_iterations: + s.loop_max_iterations ?? stored.loop_max_iterations ?? null, + loop_delay_seconds: + s.loop_delay_seconds ?? stored.loop_delay_seconds ?? null, + loop_max_duration_seconds: + s.loop_max_duration_seconds ?? + stored.loop_max_duration_seconds ?? null, // CRITICAL: Preserve parent_session_id for hierarchical conversation tree parent_session_id: @@ -1053,7 +1053,7 @@ export function mergeMessagesWithSync(existingMessages, newMessages) { // with matching content. This handles the optimistic UI → server confirmation // case where the user's message was added before receiving a seq. // If no seq-less match exists, the message is a genuinely new event (e.g., - // periodic prompts with the same text across different runs) — skip the + // loop prompts with the same text across different runs) — skip the // content hash check which would incorrectly deduplicate it. if (m.seq) { const hash = getMessageHash(m); diff --git a/web/static/lib.test.js b/web/static/lib.test.js index 36cc30ce..29ff6df9 100644 --- a/web/static/lib.test.js +++ b/web/static/lib.test.js @@ -64,8 +64,8 @@ import { htmlToMarkdown, messageToMarkdown, conversationToMarkdown, - PERIODIC_STOPPED_LABELS, - formatPeriodicMaxDuration, + LOOP_STOPPED_LABELS, + formatLoopMaxDuration, buildRetryTargets, messageKey, computeHeaderTriggerLabel, @@ -215,47 +215,47 @@ describe("computeAllSessions", () => { }); // --------------------------------------------------------------------------- - // periodic_stopped_reason merge tests + // loop_stopped_reason merge tests // --------------------------------------------------------------------------- - test("merges periodic_stopped_reason from stored session when active lacks it", () => { + test("merges loop_stopped_reason from stored session when active lacks it", () => { const active = [{ session_id: "1", created_at: "2024-01-01T10:00:00Z" }]; const stored = [ { session_id: "1", - periodic_configured: true, - periodic_stopped_reason: "maxDuration", + loop_configured: true, + loop_stopped_reason: "maxDuration", created_at: "2024-01-01T10:00:00Z", }, ]; const result = computeAllSessions(active, stored); - expect(result[0].periodic_stopped_reason).toBe("maxDuration"); + expect(result[0].loop_stopped_reason).toBe("maxDuration"); }); - test("active periodic_stopped_reason takes precedence over stored", () => { + test("active loop_stopped_reason takes precedence over stored", () => { const active = [ { session_id: "1", - periodic_stopped_reason: "maxIterations", + loop_stopped_reason: "maxIterations", created_at: "2024-01-01T10:00:00Z", }, ]; const stored = [ { session_id: "1", - periodic_stopped_reason: "maxDuration", + loop_stopped_reason: "maxDuration", created_at: "2024-01-01T10:00:00Z", }, ]; const result = computeAllSessions(active, stored); - expect(result[0].periodic_stopped_reason).toBe("maxIterations"); + expect(result[0].loop_stopped_reason).toBe("maxIterations"); }); - test("periodic_stopped_reason is null when neither active nor stored has it", () => { + test("loop_stopped_reason is null when neither active nor stored has it", () => { const active = [{ session_id: "1", created_at: "2024-01-01T10:00:00Z" }]; const stored = [{ session_id: "1", created_at: "2024-01-01T10:00:00Z" }]; const result = computeAllSessions(active, stored); - expect(result[0].periodic_stopped_reason).toBeNull(); + expect(result[0].loop_stopped_reason).toBeNull(); }); // --------------------------------------------------------------------------- @@ -2324,7 +2324,7 @@ describe("mergeMessagesWithSync", () => { test("prompt retry creates new seq - distinct events", () => { // When both existing and new messages have seqs, they are treated as // distinct server-side events — even if the text is identical. - // This is critical for periodic prompts which reuse the same text + // This is critical for loop prompts which reuse the same text // across different runs. Content hash dedup only applies when the // existing message has NO seq (optimistic UI → server confirmation). // @@ -2372,7 +2372,7 @@ describe("mergeMessagesWithSync", () => { test("multiple prompt retries - distinct events when all have seqs", () => { // When all messages have seqs, they are treated as distinct events. - // This ensures periodic prompts (same text, different runs) all appear. + // This ensures loop prompts (same text, different runs) all appear. const existing = [ { role: ROLE_USER, text: "Fix the bug", seq: 10, timestamp: 1000 }, ]; @@ -5673,49 +5673,49 @@ describe("conversationToMarkdown", () => { }); // ============================================================================= -// PERIODIC_STOPPED_LABELS Tests +// LOOP_STOPPED_LABELS Tests // ============================================================================= -describe("PERIODIC_STOPPED_LABELS", () => { +describe("LOOP_STOPPED_LABELS", () => { test("maps all seven known reason codes to {label, kind} objects", () => { - expect(PERIODIC_STOPPED_LABELS.maxDuration).toEqual({ + expect(LOOP_STOPPED_LABELS.maxDuration).toEqual({ label: "Stopped: max time", kind: "stopped", }); - expect(PERIODIC_STOPPED_LABELS.maxIterations).toEqual({ + expect(LOOP_STOPPED_LABELS.maxIterations).toEqual({ label: "Stopped: max iters", kind: "stopped", }); - expect(PERIODIC_STOPPED_LABELS.iterationSafeguard).toEqual({ + expect(LOOP_STOPPED_LABELS.iterationSafeguard).toEqual({ label: "Stopped: max iters", kind: "stopped", }); - expect(PERIODIC_STOPPED_LABELS.promptUnresolved).toEqual({ + expect(LOOP_STOPPED_LABELS.promptUnresolved).toEqual({ label: "Stopped: prompt missing", kind: "stopped", }); - expect(PERIODIC_STOPPED_LABELS.resumeFailures).toEqual({ + expect(LOOP_STOPPED_LABELS.resumeFailures).toEqual({ label: "Stopped: resume errors", kind: "stopped", }); - expect(PERIODIC_STOPPED_LABELS.pausedByUser).toEqual({ + expect(LOOP_STOPPED_LABELS.pausedByUser).toEqual({ label: "Paused by you", kind: "paused", }); - expect(PERIODIC_STOPPED_LABELS.disabledByAgent).toEqual({ + expect(LOOP_STOPPED_LABELS.disabledByAgent).toEqual({ label: "Paused by the agent", kind: "paused", }); }); test("maxIterations and iterationSafeguard share the same label", () => { - expect(PERIODIC_STOPPED_LABELS.maxIterations.label).toBe( - PERIODIC_STOPPED_LABELS.iterationSafeguard.label, + expect(LOOP_STOPPED_LABELS.maxIterations.label).toBe( + LOOP_STOPPED_LABELS.iterationSafeguard.label, ); }); test("unknown reason is not in the map", () => { - expect(PERIODIC_STOPPED_LABELS["unknownReason"]).toBeUndefined(); + expect(LOOP_STOPPED_LABELS["unknownReason"]).toBeUndefined(); }); test("all stopped reasons have kind='stopped'", () => { @@ -5727,30 +5727,30 @@ describe("PERIODIC_STOPPED_LABELS", () => { "resumeFailures", ]; for (const reason of stoppedReasons) { - expect(PERIODIC_STOPPED_LABELS[reason].kind).toBe("stopped"); + expect(LOOP_STOPPED_LABELS[reason].kind).toBe("stopped"); } }); test("all paused reasons have kind='paused'", () => { const pausedReasons = ["pausedByUser", "disabledByAgent"]; for (const reason of pausedReasons) { - expect(PERIODIC_STOPPED_LABELS[reason].kind).toBe("paused"); + expect(LOOP_STOPPED_LABELS[reason].kind).toBe("paused"); } }); - // headerPeriodicState derivation logic - // Mirrors the IIFE in app.js that computes headerPeriodicState from an activeSession. + // headerLoopState derivation logic + // Mirrors the IIFE in app.js that computes headerLoopState from an activeSession. - function computeHeaderPeriodicState(session) { - if (!session?.periodic_configured) return null; - if (session?.periodic_enabled) { + function computeHeaderLoopState(session) { + if (!session?.loop_configured) return null; + if (session?.loop_enabled) { return { state: "running", label: "Auto", badgeClass: "badge-success badge-soft", }; } - const entry = PERIODIC_STOPPED_LABELS[session?.periodic_stopped_reason]; + const entry = LOOP_STOPPED_LABELS[session?.loop_stopped_reason]; if (entry && entry.kind === "stopped") { return { state: "stopped", @@ -5772,18 +5772,18 @@ describe("PERIODIC_STOPPED_LABELS", () => { }; } - test("non-periodic session yields null (no pill)", () => { - const session = { periodic_configured: false }; - expect(computeHeaderPeriodicState(session)).toBeNull(); + test("non-loop session yields null (no pill)", () => { + const session = { loop_configured: false }; + expect(computeHeaderLoopState(session)).toBeNull(); }); test("null session yields null (no pill)", () => { - expect(computeHeaderPeriodicState(null)).toBeNull(); + expect(computeHeaderLoopState(null)).toBeNull(); }); - test("enabled periodic session yields Auto/green", () => { - const session = { periodic_configured: true, periodic_enabled: true }; - const result = computeHeaderPeriodicState(session); + test("enabled loop session yields Auto/green", () => { + const session = { loop_configured: true, loop_enabled: true }; + const result = computeHeaderLoopState(session); expect(result.state).toBe("running"); expect(result.label).toBe("Auto"); expect(result.badgeClass).toContain("badge-success"); @@ -5791,11 +5791,11 @@ describe("PERIODIC_STOPPED_LABELS", () => { test("stopped reason yields Stopped/red", () => { const session = { - periodic_configured: true, - periodic_enabled: false, - periodic_stopped_reason: "maxDuration", + loop_configured: true, + loop_enabled: false, + loop_stopped_reason: "maxDuration", }; - const result = computeHeaderPeriodicState(session); + const result = computeHeaderLoopState(session); expect(result.state).toBe("stopped"); expect(result.label).toBe("Stopped: max time"); expect(result.badgeClass).toContain("badge-error"); @@ -5803,11 +5803,11 @@ describe("PERIODIC_STOPPED_LABELS", () => { test("pausedByUser reason yields Paused/amber", () => { const session = { - periodic_configured: true, - periodic_enabled: false, - periodic_stopped_reason: "pausedByUser", + loop_configured: true, + loop_enabled: false, + loop_stopped_reason: "pausedByUser", }; - const result = computeHeaderPeriodicState(session); + const result = computeHeaderLoopState(session); expect(result.state).toBe("paused"); expect(result.label).toBe("Paused by you"); expect(result.badgeClass).toContain("badge-warning"); @@ -5815,11 +5815,11 @@ describe("PERIODIC_STOPPED_LABELS", () => { test("disabledByAgent reason yields Paused/amber", () => { const session = { - periodic_configured: true, - periodic_enabled: false, - periodic_stopped_reason: "disabledByAgent", + loop_configured: true, + loop_enabled: false, + loop_stopped_reason: "disabledByAgent", }; - const result = computeHeaderPeriodicState(session); + const result = computeHeaderLoopState(session); expect(result.state).toBe("paused"); expect(result.label).toBe("Paused by the agent"); expect(result.badgeClass).toContain("badge-warning"); @@ -5827,11 +5827,11 @@ describe("PERIODIC_STOPPED_LABELS", () => { test("no reason (manual pause) yields generic Paused/amber", () => { const session = { - periodic_configured: true, - periodic_enabled: false, - periodic_stopped_reason: null, + loop_configured: true, + loop_enabled: false, + loop_stopped_reason: null, }; - const result = computeHeaderPeriodicState(session); + const result = computeHeaderLoopState(session); expect(result.state).toBe("paused"); expect(result.label).toBe("Paused"); expect(result.badgeClass).toContain("badge-warning"); @@ -5839,87 +5839,87 @@ describe("PERIODIC_STOPPED_LABELS", () => { test("unknown future reason falls back to generic Paused/amber", () => { const session = { - periodic_configured: true, - periodic_enabled: false, - periodic_stopped_reason: "someFutureReason", + loop_configured: true, + loop_enabled: false, + loop_stopped_reason: "someFutureReason", }; - const result = computeHeaderPeriodicState(session); + const result = computeHeaderLoopState(session); expect(result.state).toBe("paused"); expect(result.label).toBe("Paused"); expect(result.badgeClass).toContain("badge-warning"); }); test("all known reasons produce a non-empty label", () => { - const knownReasons = Object.keys(PERIODIC_STOPPED_LABELS); + const knownReasons = Object.keys(LOOP_STOPPED_LABELS); for (const reason of knownReasons) { - expect(PERIODIC_STOPPED_LABELS[reason].label).toBeTruthy(); + expect(LOOP_STOPPED_LABELS[reason].label).toBeTruthy(); } }); }); // ============================================================================= -// formatPeriodicMaxDuration Tests +// formatLoopMaxDuration Tests // ============================================================================= -describe("formatPeriodicMaxDuration", () => { +describe("formatLoopMaxDuration", () => { test("formats whole days", () => { - expect(formatPeriodicMaxDuration(86400)).toBe("1d"); - expect(formatPeriodicMaxDuration(172800)).toBe("2d"); - expect(formatPeriodicMaxDuration(604800)).toBe("7d"); + expect(formatLoopMaxDuration(86400)).toBe("1d"); + expect(formatLoopMaxDuration(172800)).toBe("2d"); + expect(formatLoopMaxDuration(604800)).toBe("7d"); }); test("formats whole hours", () => { - expect(formatPeriodicMaxDuration(3600)).toBe("1h"); - expect(formatPeriodicMaxDuration(7200)).toBe("2h"); - expect(formatPeriodicMaxDuration(18000)).toBe("5h"); + expect(formatLoopMaxDuration(3600)).toBe("1h"); + expect(formatLoopMaxDuration(7200)).toBe("2h"); + expect(formatLoopMaxDuration(18000)).toBe("5h"); }); test("formats whole minutes", () => { - expect(formatPeriodicMaxDuration(60)).toBe("1min"); - expect(formatPeriodicMaxDuration(1800)).toBe("30min"); - expect(formatPeriodicMaxDuration(3540)).toBe("59min"); + expect(formatLoopMaxDuration(60)).toBe("1min"); + expect(formatLoopMaxDuration(1800)).toBe("30min"); + expect(formatLoopMaxDuration(3540)).toBe("59min"); }); test("formats seconds for non-round values", () => { - expect(formatPeriodicMaxDuration(1)).toBe("1s"); - expect(formatPeriodicMaxDuration(45)).toBe("45s"); - expect(formatPeriodicMaxDuration(90)).toBe("90s"); - expect(formatPeriodicMaxDuration(3601)).toBe("3601s"); + expect(formatLoopMaxDuration(1)).toBe("1s"); + expect(formatLoopMaxDuration(45)).toBe("45s"); + expect(formatLoopMaxDuration(90)).toBe("90s"); + expect(formatLoopMaxDuration(3601)).toBe("3601s"); }); test("days takes priority over hours when divisible", () => { // 86400 is both a multiple of 3600 and 86400 — days wins - expect(formatPeriodicMaxDuration(86400)).toBe("1d"); - expect(formatPeriodicMaxDuration(172800)).toBe("2d"); + expect(formatLoopMaxDuration(86400)).toBe("1d"); + expect(formatLoopMaxDuration(172800)).toBe("2d"); }); test("hours takes priority over minutes when divisible by 3600", () => { // 7200 is divisible by both 60 and 3600 — hours wins - expect(formatPeriodicMaxDuration(7200)).toBe("2h"); + expect(formatLoopMaxDuration(7200)).toBe("2h"); }); test("non-divisible values fall through to seconds", () => { // 3661 = 1 hour + 1 minute + 1 second — not cleanly divisible by any unit - expect(formatPeriodicMaxDuration(3661)).toBe("3661s"); - expect(formatPeriodicMaxDuration(61)).toBe("61s"); + expect(formatLoopMaxDuration(3661)).toBe("3661s"); + expect(formatLoopMaxDuration(61)).toBe("61s"); }); }); // ============================================================================= -// Periodic header badge label logic tests +// Loop header badge label logic tests // ============================================================================= -describe("Periodic header badge label logic", () => { +describe("Loop header badge label logic", () => { // Mirrors the logic in app.js for deriving the trigger badge text: - // if (periodic_trigger === "onCompletion") → "after agent finishes[· +Ns]" + // if (loop_trigger === "onCompletion") → "after agent finishes[· +Ns]" // else if frequency set → "every <value><unit>" function computeTriggerLabel(session) { - if (!session?.periodic_configured) return null; - if (session.periodic_trigger === "onCompletion") { - const delay = session.periodic_delay_seconds ?? 0; + if (!session?.loop_configured) return null; + if (session.loop_trigger === "onCompletion") { + const delay = session.loop_delay_seconds ?? 0; return `after agent finishes${delay > 0 ? ` · +${delay}s` : ""}`; } - const freq = session.periodic_frequency; + const freq = session.loop_frequency; if (!freq) return null; const u = freq.unit === "minutes" ? "min" : freq.unit === "hours" ? "h" : "d"; @@ -5939,63 +5939,63 @@ describe("Periodic header badge label logic", () => { describe("trigger badge", () => { test("schedule trigger with hours frequency", () => { const session = { - periodic_configured: true, - periodic_trigger: "schedule", - periodic_frequency: { value: 2, unit: "hours" }, + loop_configured: true, + loop_trigger: "schedule", + loop_frequency: { value: 2, unit: "hours" }, }; expect(computeTriggerLabel(session)).toBe("every 2h"); }); test("schedule trigger with minutes frequency", () => { const session = { - periodic_configured: true, - periodic_trigger: "schedule", - periodic_frequency: { value: 30, unit: "minutes" }, + loop_configured: true, + loop_trigger: "schedule", + loop_frequency: { value: 30, unit: "minutes" }, }; expect(computeTriggerLabel(session)).toBe("every 30min"); }); test("schedule trigger with days frequency", () => { const session = { - periodic_configured: true, - periodic_trigger: "schedule", - periodic_frequency: { value: 1, unit: "days" }, + loop_configured: true, + loop_trigger: "schedule", + loop_frequency: { value: 1, unit: "days" }, }; expect(computeTriggerLabel(session)).toBe("every 1d"); }); test("onCompletion trigger without delay", () => { const session = { - periodic_configured: true, - periodic_trigger: "onCompletion", - periodic_delay_seconds: 0, + loop_configured: true, + loop_trigger: "onCompletion", + loop_delay_seconds: 0, }; expect(computeTriggerLabel(session)).toBe("after agent finishes"); }); test("onCompletion trigger with delay", () => { const session = { - periodic_configured: true, - periodic_trigger: "onCompletion", - periodic_delay_seconds: 30, + loop_configured: true, + loop_trigger: "onCompletion", + loop_delay_seconds: 30, }; expect(computeTriggerLabel(session)).toBe("after agent finishes · +30s"); }); - test("returns null when not periodic_configured", () => { + test("returns null when not loop_configured", () => { const session = { - periodic_configured: false, - periodic_trigger: "schedule", - periodic_frequency: { value: 1, unit: "hours" }, + loop_configured: false, + loop_trigger: "schedule", + loop_frequency: { value: 1, unit: "hours" }, }; expect(computeTriggerLabel(session)).toBeNull(); }); test("returns null when schedule has no frequency set", () => { const session = { - periodic_configured: true, - periodic_trigger: "schedule", - periodic_frequency: null, + loop_configured: true, + loop_trigger: "schedule", + loop_frequency: null, }; expect(computeTriggerLabel(session)).toBeNull(); }); diff --git a/web/static/styles-v2.css b/web/static/styles-v2.css index 590e5f96..23c3390d 100644 --- a/web/static/styles-v2.css +++ b/web/static/styles-v2.css @@ -631,6 +631,33 @@ a:hover { background: #1d4ed8 !important; /* Original blue-700 */ } +/* Draw attention to in-progress issues: the in_progress status badge pulses + with a bright blue flash, an expanding glow ring, and a slight scale so + active work clearly stands out in the list. Keyframe values already take + precedence during the animation, so !important is not needed here. */ +@keyframes beadsInProgressPulse { + 0%, + 100% { + background-color: #1d4ed8; /* blue-700 */ + box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.75); + transform: scale(1); + } + 50% { + background-color: #3b82f6; /* blue-500 (brighter) */ + box-shadow: 0 0 10px 3px rgba(59, 130, 246, 0.55); + transform: scale(1.08); + } +} +.beads-status-inprogress { + animation: beadsInProgressPulse 1.2s ease-in-out infinite; + will-change: background-color, box-shadow, transform; +} +@media (prefers-reduced-motion: reduce) { + .beads-status-inprogress { + animation: none; + } +} + .light .bg-yellow-400, .dark .bg-yellow-400 { background: #facc15 !important; @@ -686,27 +713,31 @@ a:hover { border-color: #475569; } -/* Sidebar toolbar dropdown triggers are <summary> elements (daisyUI - details/dropdown pattern), so the generic per-<button> border rule above - does not reach them — leaving the Filter/Density buttons borderless while the - plain <button> items in the same join group are bordered. Mirror the button - border + hover here, scoped to the toolbar, so all items in the row match. */ -.light [data-testid="sidebar-toolbar"] summary.btn { - border: 1px solid #e4e4e7; -} - -.light [data-testid="sidebar-toolbar"] summary.btn:hover { - background: #f0f0f0; - border-color: #d4d4d8; +/* Portable Toolbar component (components/Toolbar.js): a segmented "pill" of + grouped icon buttons. The single container carries the border + rounded + surface, so the resting border must be removed for the toolbar's own items — + otherwise each item would draw its own box and defeat the continuous-surface + look. Hover background and the active (btn-active) fill are left to the + global rules / daisyUI. This covers both plain <button> items and the + <summary> dropdown/overflow triggers: daisyUI's `.btn` applies a 1px solid + (transparent) border to summaries too, which the global `button` selector + never resets. */ +.mitto-toolbar > button, +.mitto-toolbar > details > summary { + border: none !important; } -.dark [data-testid="sidebar-toolbar"] summary.btn { - border: 1px solid #334155; +/* Thin vertical separator between toolbar groups. Stretches to the row height + and reuses the same divider tones as the global button borders. */ +.mitto-toolbar-sep { + width: 1px; + align-self: stretch; + margin: 0.25rem 0.125rem; + background: #e4e4e7; } -.dark [data-testid="sidebar-toolbar"] summary.btn:hover { +.dark .mitto-toolbar-sep { background: #334155; - border-color: #475569; } /* daisyUI menu items are <button>/<a> elements. They must NOT inherit the @@ -910,7 +941,7 @@ a:hover { } /* ============================================================================= - Named Prompt Pill (periodic / by-name prompts) + Named Prompt Pill (loop / by-name prompts) ============================================================================= */ /* ============================================================================= @@ -1233,3 +1264,12 @@ a:hover { padding-right: 2.5rem; height: 38px; /* Match input height: py-2 (8px * 2) + text-sm line-height (~20px) + border */ } + +/* ShortcutsEditor rows group a compact `select select-sm` with `btn-sm` + buttons inside a daisyUI `join`. The broad `.settings-dialog select` rule + above forces 38px, which is taller than the 32px btn-sm join items and + misaligns the row. Restore the daisyUI select-sm height (2rem) for selects + grouped in a join, leaving all other settings-dialog selects at 38px. */ +.settings-dialog .join select.select-sm { + height: var(--size); +} diff --git a/web/static/styles.css b/web/static/styles.css index a892578e..f1241c8a 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -60,6 +60,54 @@ z-index: 1000; } +/* daisyUI centers the tooltip bubble/arrow with translateX(-50%) (top/bottom) + and translateY(-50%) (left/right). On an odd-width/height bubble that lands + the box on a half-pixel, so WebKit rasterizes the text with sub-pixel + anti-aliasing → the "blurry tooltip font" symptom. Snap the centering + translate to a whole pixel with round() so the text sits on the pixel grid. + Guarded by @supports: browsers without round() fall back to daisyUI's + default (no regression). The dynamic translateY(var(--tt-pos)) that drives + the open/close animation is preserved verbatim (the open state only flips + --tt-pos to 0rem), so transitions still work. Selectors mirror daisyUI's + base placement rules; styles.css loads after tailwind.css, so equal + specificity resolves in our favor by source order. */ +@supports (transform: translateX(round(-50%, 1px))) { + .tooltip > .tooltip-content, + .tooltip[data-tip]::before, + .tooltip-top > .tooltip-content, + .tooltip-top[data-tip]::before, + .tooltip::after, + .tooltip-top::after { + transform: translateX(round(-50%, 1px)) translateY(var(--tt-pos, 0.25rem)); + } + .tooltip-bottom > .tooltip-content, + .tooltip-bottom[data-tip]::before { + transform: translateX(round(-50%, 1px)) translateY(var(--tt-pos, -0.25rem)); + } + .tooltip-bottom::after { + transform: translateX(round(-50%, 1px)) translateY(var(--tt-pos, -0.25rem)) + rotate(180deg); + } + .tooltip-left > .tooltip-content, + .tooltip-left[data-tip]::before { + transform: translateX(calc(var(--tt-pos, 0.25rem) - 0.25rem)) + translateY(round(-50%, 1px)); + } + .tooltip-left::after { + transform: translateX(var(--tt-pos, 0.25rem)) translateY(round(-50%, 1px)) + rotate(-90deg); + } + .tooltip-right > .tooltip-content, + .tooltip-right[data-tip]::before { + transform: translateX(calc(var(--tt-pos, -0.25rem) + 0.25rem)) + translateY(round(-50%, 1px)); + } + .tooltip-right::after { + transform: translateX(var(--tt-pos, -0.25rem)) translateY(round(-50%, 1px)) + rotate(90deg); + } +} + /* Messages container scrollbar - show scrollbar for better Edge compatibility. Edge (Chromium) can have scrolling issues when scrollbar is hidden with flex-col-reverse layouts. We show a thin, styled scrollbar instead. */ @@ -446,7 +494,12 @@ /* Light theme: slightly more subtle blue for child expand button */ .light .child-expand-streaming::before { - background-color: rgba(59, 130, 246, 0.3); /* blue-500, slightly lighter for light theme */ + background-color: rgba( + 59, + 130, + 246, + 0.3 + ); /* blue-500, slightly lighter for light theme */ } /* Thought bubble expand/collapse — subtle fade transition on content change */ @@ -916,7 +969,6 @@ a.mailto-link:hover { color: #1d4ed8; } - /* ============================================================================= Font Size Classes (applied to markdown-content) ============================================================================= */ @@ -963,8 +1015,15 @@ a.mailto-link:hover { the exact same font as the compose box, kept in sync from a single source. */ .input-font-system textarea.chat-input-textarea, .input-font-system .input-font-target .cm-content { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", Arial, sans-serif; + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif; } .input-font-sans-serif textarea.chat-input-textarea, @@ -979,14 +1038,15 @@ a.mailto-link:hover { .input-font-monospace textarea.chat-input-textarea, .input-font-monospace .input-font-target .cm-content { - font-family: "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; + font-family: + "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; } .input-font-menlo textarea.chat-input-textarea, .input-font-menlo .input-font-target .cm-content { - font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", - monospace; + font-family: + Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } .input-font-monaco textarea.chat-input-textarea, @@ -1006,7 +1066,8 @@ a.mailto-link:hover { .input-font-jetbrains-mono textarea.chat-input-textarea, .input-font-jetbrains-mono .input-font-target .cm-content { - font-family: "JetBrains Mono", "Fira Code", Menlo, Monaco, Consolas, monospace; + font-family: + "JetBrains Mono", "Fira Code", Menlo, Monaco, Consolas, monospace; } .input-font-sf-mono textarea.chat-input-textarea, @@ -1016,8 +1077,8 @@ a.mailto-link:hover { .input-font-cascadia-code textarea.chat-input-textarea, .input-font-cascadia-code .input-font-target .cm-content { - font-family: "Cascadia Code", "Cascadia Mono", Consolas, "Courier New", - monospace; + font-family: + "Cascadia Code", "Cascadia Mono", Consolas, "Courier New", monospace; } /* ============================================================================= @@ -1198,7 +1259,7 @@ a.mailto-link:hover { } } -/* On small screens, collapse the categorical header badges (the periodic +/* On small screens, collapse the categorical header badges (the loop status pill — Auto/Paused/Stopped — and the trigger badge) to icon-only by hiding their text labels. The leading icon stays visible and conveys the state. Numeric badges (max 2h, countdown) keep their full values; the @@ -1212,14 +1273,14 @@ a.mailto-link:hover { /* Run-count badge: show the full form ("Run N of M") on wider screens and a compact form ("N/M") on narrow screens. */ -[data-testid="periodic-run-count-badge"] .runcount-short { +[data-testid="loop-run-count-badge"] .runcount-short { display: none; } @media (max-width: 640px) { - [data-testid="periodic-run-count-badge"] .runcount-full { + [data-testid="loop-run-count-badge"] .runcount-full { display: none; } - [data-testid="periodic-run-count-badge"] .runcount-short { + [data-testid="loop-run-count-badge"] .runcount-short { display: inline; } } @@ -1531,11 +1592,31 @@ a.mailto-link:hover { } /* Queue item action buttons: always visible at reduced opacity, full on hover/focus, always full on touch */ -.queue-item-actions { opacity: 0.45; } +.queue-item-actions { + opacity: 0.45; +} .group:hover .queue-item-actions, -.group:focus-within .queue-item-actions { opacity: 1; } +.group:focus-within .queue-item-actions { + opacity: 1; +} @media (hover: none) { - .queue-item-actions { opacity: 1; } + .queue-item-actions { + opacity: 1; + } + + /* Touch devices have no real hover, so hover-only tooltips can never be seen + the intended way; worse, iOS "sticky hover" leaves a tapped element in + :hover and fires the CSS-only daisyUI tooltip, whose bubble then overlaps + adjacent buttons (most visibly the segmented toolbar pills). Disable the + tooltip popup entirely on touch — in every state (:hover and .tooltip-open) + — by suppressing the base pseudo-elements. The data-tip attribute stays for + accessibility; the JS PortalTooltip variant already self-gates on + (hover: hover) and never renders here. */ + .tooltip::before, + .tooltip::after, + .tooltip > .tooltip-content { + display: none !important; + } } /* Light theme adjustments */ @@ -1909,14 +1990,12 @@ mitto-action { margin-bottom: 0; } - .chat-input-context-pct { font-size: 11px; white-space: nowrap; padding: 1px 4px; } - /* Flat inline action button — matches p-1.5 rounded (28px, 4px radius) */ .chat-input-action { width: 28px; @@ -1929,7 +2008,9 @@ mitto-action { align-items: center; justify-content: center; cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; + transition: + background 0.15s ease, + color 0.15s ease; flex-shrink: 0; } @@ -1988,7 +2069,6 @@ mitto-action { color: #fb923c; } - /* UI Textbox textarea (mitto_ui_textbox) - base dark theme */ .ui-textbox-textarea { background: rgba(30, 41, 59, 0.8); @@ -2065,7 +2145,6 @@ mitto-action { color: #cbd5e1; } - /* Send/Stop buttons in light theme — same filled accent style */ .light .chat-input-action.send-active, .light .chat-input-action.stop-active { @@ -2100,7 +2179,11 @@ mitto-action { /* Workspace pill clickable hover effect - uses filter instead of opacity to maintain text legibility on colored backgrounds */ .workspace-pill-clickable { - transition: filter 0.15s ease, transform 0.15s ease, background-color 0.15s ease, color 0.15s ease; + transition: + filter 0.15s ease, + transform 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; } /* Dark theme: darken on hover */ .workspace-pill-clickable:hover { @@ -2290,7 +2373,6 @@ mitto-action { } } - /* Child session items. Apply the compact vertical padding to the inner padded block (the clickable row content), NOT the .session-item-container wrapper. The wrapper sits @@ -2318,14 +2400,14 @@ mitto-action { border: none !important; } - - /* Session children container animation */ .session-children { overflow: hidden; max-height: 0; opacity: 0; - transition: max-height 0.2s ease-out, opacity 0.15s ease-out; + transition: + max-height 0.2s ease-out, + opacity 0.15s ease-out; } .session-children--expanded { @@ -2355,3 +2437,62 @@ mitto-action { background-color: rgb(51 65 85 / 0.5); color: #9ca3af; } + +/* ============================================================================= + Global focus indicator — subtle accent glow + ----------------------------------------------------------------------------- + daisyUI 5 focuses form controls with `outline: 2px solid var(--input-color); + outline-offset: 2px` — a thick outline in the (dark) base-content color, + detached from the control's own 1px border, which reads as a harsh + "double line". Replace it everywhere with a soft, low-opacity accent halo + driven by --mitto-accent, so it adapts to the active theme. + + styles.css is UNLAYERED and loads after tailwind.css, so these rules beat + daisyUI's @layer rules regardless of specificity (layered author styles lose + to unlayered ones). A transparent (but solid-style) outline is retained so + the ring survives forced-colors / high-contrast mode, which ignores + box-shadow. On form controls, `position: relative; z-index: 1` lifts the glow + above welded neighbours in a daisyUI `join`. + ============================================================================= */ +:root { + --mitto-focus-ring: + 0 0 0 1px color-mix(in oklab, var(--mitto-accent, #dc2626) 45%, transparent), + 0 0 4px 1px + color-mix(in oklab, var(--mitto-accent, #dc2626) 30%, transparent); +} + +/* Form controls: daisyUI applies its outline on :focus AND :focus-within + (fires on click too — the reported case). */ +.input:focus, +.input:focus-within, +.select:focus, +.select:focus-within, +.textarea:focus, +.textarea:focus-within { + outline: 2px solid transparent; + outline-offset: 0; + box-shadow: var(--mitto-focus-ring); + position: relative; + z-index: 1; +} + +/* Everything keyboard-focused (buttons, checkboxes, radios, toggles, links, + summaries, menu items, tabs, …) — daisyUI uses :focus-visible here. The bare + selector is unlayered, so it overrides every layered `.x:focus-visible`. */ +:focus-visible { + outline: 2px solid transparent; + outline-offset: 0; + box-shadow: var(--mitto-focus-ring); +} + +/* Exclusion: the chat composition box owns its own focus treatment via + `.chat-input-box:focus-within` (a subtle border shift). Its inner textarea is + borderless and, being a text field, matches :focus-visible on plain click — + which would paint the accent glow as a red rectangle around the whole box. + Suppress the global glow there so only the box's border reacts. Higher + specificity (0,2,0) than the bare :focus-visible rule, both unlayered. */ +.chat-input-textarea:focus, +.chat-input-textarea:focus-visible { + outline: none; + box-shadow: none; +} diff --git a/web/static/tailwind.css b/web/static/tailwind.css index dc36daa2..e511f01d 100644 --- a/web/static/tailwind.css +++ b/web/static/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-100:oklch(96.2% .044 156.743);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-900:oklch(39.3% .095 152.535);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-800:oklch(45% .085 224.283);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-700:oklch(37.2% .044 257.287);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-mitto-bg:var(--mitto-bg);--color-mitto-sidebar:var(--mitto-sidebar);--color-mitto-input:var(--mitto-input);--color-mitto-input-box:var(--mitto-input-box);--color-mitto-user:var(--mitto-user);--color-mitto-user-text:var(--mitto-user-text);--color-mitto-user-border:var(--mitto-user-border);--color-mitto-agent:var(--mitto-agent);--color-mitto-border:var(--mitto-border);--color-mitto-text:var(--mitto-text);--color-mitto-text-secondary:var(--mitto-text-secondary);--color-mitto-accent:var(--mitto-accent);--color-mitto-accent-fg:var(--mitto-accent-fg);--color-mitto-accent-300:var(--mitto-accent-300);--color-mitto-accent-400:var(--mitto-accent-400);--color-mitto-accent-500:var(--mitto-accent-500);--color-mitto-accent-600:var(--mitto-accent-600);--color-mitto-accent-700:var(--mitto-accent-700);--color-mitto-accent-900:var(--mitto-accent-900);--color-mitto-danger:var(--mitto-danger);--color-mitto-danger-hover:var(--mitto-danger-hover);--color-mitto-danger-fg:var(--mitto-danger-fg);--color-mitto-surface-hover:var(--mitto-surface-hover);--color-mitto-surface-2:var(--mitto-surface-2);--color-mitto-surface-3:var(--mitto-surface-3);--color-mitto-surface-4:var(--mitto-surface-4);--color-mitto-text-strong:var(--mitto-text-strong);--color-mitto-text-muted:var(--mitto-text-muted);--color-mitto-text-200:var(--mitto-text-200);--color-mitto-text-300:var(--mitto-text-300);--color-mitto-text-500:var(--mitto-text-500);--color-mitto-border-1:var(--mitto-border-1);--color-mitto-border-2:var(--mitto-border-2);--color-mitto-border-3:var(--mitto-border-3);--color-mitto-success:var(--mitto-success);--color-mitto-warning:var(--mitto-warning)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:after,:before,::backdrop{border-color:var(--color-gray-200,currentColor)}::file-selector-button{border-color:var(--color-gray-200,currentColor)}.light{color-scheme:light}:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dark]:checked),[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=cupcake]:checked),[data-theme=cupcake]{color-scheme:light;--color-base-100:oklch(97.788% .004 56.375);--color-base-200:oklch(93.982% .007 61.449);--color-base-300:oklch(91.586% .006 53.44);--color-base-content:oklch(23.574% .066 313.189);--color-primary:oklch(85% .138 181.071);--color-primary-content:oklch(43% .078 188.216);--color-secondary:oklch(89% .061 343.231);--color-secondary-content:oklch(45% .187 3.815);--color-accent:oklch(90% .076 70.697);--color-accent-content:oklch(47% .157 37.304);--color-neutral:oklch(27% .006 286.033);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(68% .169 237.323);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(69% .17 162.48);--color-success-content:oklch(26% .051 172.552);--color-warning:oklch(79% .184 86.047);--color-warning-content:oklch(28% .066 53.813);--color-error:oklch(64% .246 16.439);--color-error-content:oklch(27% .105 12.094);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:0}:root:has(input.theme-controller[value=bumblebee]:checked),[data-theme=bumblebee]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(92% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(85% .199 91.936);--color-primary-content:oklch(42% .095 57.708);--color-secondary:oklch(75% .183 55.934);--color-secondary-content:oklch(40% .123 38.172);--color-accent:oklch(0% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(37% .01 67.558);--color-neutral-content:oklch(92% .003 48.717);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(39% .09 240.876);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(39% .141 25.723);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=emerald]:checked),[data-theme=emerald]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(35.519% .032 262.988);--color-primary:oklch(76.662% .135 153.45);--color-primary-content:oklch(33.387% .04 162.24);--color-secondary:oklch(61.302% .202 261.294);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(72.772% .149 33.2);--color-accent-content:oklch(0% 0 0);--color-neutral:oklch(35.519% .032 262.988);--color-neutral-content:oklch(98.462% .001 247.838);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=corporate]:checked),[data-theme=corporate]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(22.389% .031 278.072);--color-primary:oklch(58% .158 241.966);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(55% .046 257.417);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(60% .118 184.704);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(0% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(60% .126 221.723);--color-info-content:oklch(100% 0 0);--color-success:oklch(62% .194 149.214);--color-success-content:oklch(100% 0 0);--color-warning:oklch(85% .199 91.936);--color-warning-content:oklch(0% 0 0);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(0% 0 0);--radius-selector:.25rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=synthwave]:checked),[data-theme=synthwave]{color-scheme:dark;--color-base-100:oklch(15% .09 281.288);--color-base-200:oklch(20% .09 281.288);--color-base-300:oklch(25% .09 281.288);--color-base-content:oklch(78% .115 274.713);--color-primary:oklch(71% .202 349.761);--color-primary-content:oklch(28% .109 3.907);--color-secondary:oklch(82% .111 230.318);--color-secondary-content:oklch(29% .066 243.157);--color-accent:oklch(75% .183 55.934);--color-accent-content:oklch(26% .079 36.259);--color-neutral:oklch(45% .24 277.023);--color-neutral-content:oklch(87% .065 274.039);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(77% .152 181.912);--color-success-content:oklch(27% .046 192.524);--color-warning:oklch(90% .182 98.111);--color-warning-content:oklch(42% .095 57.708);--color-error:oklch(73.7% .121 32.639);--color-error-content:oklch(23.501% .096 290.329);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=retro]:checked),[data-theme=retro]{color-scheme:light;--color-base-100:oklch(91.637% .034 90.515);--color-base-200:oklch(88.272% .049 91.774);--color-base-300:oklch(84.133% .065 90.856);--color-base-content:oklch(41% .112 45.904);--color-primary:oklch(80% .114 19.571);--color-primary-content:oklch(39% .141 25.723);--color-secondary:oklch(92% .084 155.995);--color-secondary-content:oklch(44% .119 151.328);--color-accent:oklch(68% .162 75.834);--color-accent-content:oklch(41% .112 45.904);--color-neutral:oklch(44% .011 73.639);--color-neutral-content:oklch(86% .005 56.366);--color-info:oklch(58% .158 241.966);--color-info-content:oklch(96% .059 95.617);--color-success:oklch(51% .096 186.391);--color-success-content:oklch(96% .059 95.617);--color-warning:oklch(64% .222 41.116);--color-warning-content:oklch(96% .059 95.617);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(40% .123 38.172);--radius-selector:.25rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=cyberpunk]:checked),[data-theme=cyberpunk]{color-scheme:light;--color-base-100:oklch(94.51% .179 104.32);--color-base-200:oklch(91.51% .179 104.32);--color-base-300:oklch(85.51% .179 104.32);--color-base-content:oklch(0% 0 0);--color-primary:oklch(74.22% .209 6.35);--color-primary-content:oklch(14.844% .041 6.35);--color-secondary:oklch(83.33% .184 204.72);--color-secondary-content:oklch(16.666% .036 204.72);--color-accent:oklch(71.86% .217 310.43);--color-accent-content:oklch(14.372% .043 310.43);--color-neutral:oklch(23.04% .065 269.31);--color-neutral-content:oklch(94.51% .179 104.32);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:0rem;--radius-field:0rem;--radius-box:0rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=valentine]:checked),[data-theme=valentine]{color-scheme:light;--color-base-100:oklch(97% .014 343.198);--color-base-200:oklch(94% .028 342.258);--color-base-300:oklch(89% .061 343.231);--color-base-content:oklch(52% .223 3.958);--color-primary:oklch(65% .241 354.308);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(62% .265 303.9);--color-secondary-content:oklch(97% .014 308.299);--color-accent:oklch(82% .111 230.318);--color-accent-content:oklch(39% .09 240.876);--color-neutral:oklch(40% .153 2.432);--color-neutral-content:oklch(89% .061 343.231);--color-info:oklch(86% .127 207.078);--color-info-content:oklch(44% .11 240.79);--color-success:oklch(84% .143 164.978);--color-success-content:oklch(43% .095 166.913);--color-warning:oklch(75% .183 55.934);--color-warning-content:oklch(26% .079 36.259);--color-error:oklch(63% .237 25.331);--color-error-content:oklch(97% .013 17.38);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=halloween]:checked),[data-theme=halloween]{color-scheme:dark;--color-base-100:oklch(21% .006 56.043);--color-base-200:oklch(14% .004 49.25);--color-base-300:oklch(0% 0 0);--color-base-content:oklch(84.955% 0 0);--color-primary:oklch(77.48% .204 60.62);--color-primary-content:oklch(19.693% .004 196.779);--color-secondary:oklch(45.98% .248 305.03);--color-secondary-content:oklch(89.196% .049 305.03);--color-accent:oklch(64.8% .223 136.073);--color-accent-content:oklch(0% 0 0);--color-neutral:oklch(24.371% .046 65.681);--color-neutral-content:oklch(84.874% .009 65.681);--color-info:oklch(54.615% .215 262.88);--color-info-content:oklch(90.923% .043 262.88);--color-success:oklch(62.705% .169 149.213);--color-success-content:oklch(12.541% .033 149.213);--color-warning:oklch(66.584% .157 58.318);--color-warning-content:oklch(13.316% .031 58.318);--color-error:oklch(65.72% .199 27.33);--color-error-content:oklch(13.144% .039 27.33);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=garden]:checked),[data-theme=garden]{color-scheme:light;--color-base-100:oklch(92.951% .002 17.197);--color-base-200:oklch(86.445% .002 17.197);--color-base-300:oklch(79.938% .001 17.197);--color-base-content:oklch(16.961% .001 17.32);--color-primary:oklch(62.45% .278 3.836);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(48.495% .11 355.095);--color-secondary-content:oklch(89.699% .022 355.095);--color-accent:oklch(56.273% .054 154.39);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(24.155% .049 89.07);--color-neutral-content:oklch(92.951% .002 17.197);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=forest]:checked),[data-theme=forest]{color-scheme:dark;--color-base-100:oklch(20.84% .008 17.911);--color-base-200:oklch(18.522% .007 17.911);--color-base-300:oklch(16.203% .007 17.911);--color-base-content:oklch(83.768% .001 17.911);--color-primary:oklch(68.628% .185 148.958);--color-primary-content:oklch(0% 0 0);--color-secondary:oklch(69.776% .135 168.327);--color-secondary-content:oklch(13.955% .027 168.327);--color-accent:oklch(70.628% .119 185.713);--color-accent-content:oklch(14.125% .023 185.713);--color-neutral:oklch(30.698% .039 171.364);--color-neutral-content:oklch(86.139% .007 171.364);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=aqua]:checked),[data-theme=aqua]{color-scheme:dark;--color-base-100:oklch(37% .146 265.522);--color-base-200:oklch(28% .091 267.935);--color-base-300:oklch(22% .091 267.935);--color-base-content:oklch(90% .058 230.902);--color-primary:oklch(85.661% .144 198.645);--color-primary-content:oklch(40.124% .068 197.603);--color-secondary:oklch(60.682% .108 309.782);--color-secondary-content:oklch(96% .016 293.756);--color-accent:oklch(93.426% .102 94.555);--color-accent-content:oklch(18.685% .02 94.555);--color-neutral:oklch(27% .146 265.522);--color-neutral-content:oklch(80% .146 265.522);--color-info:oklch(54.615% .215 262.88);--color-info-content:oklch(90.923% .043 262.88);--color-success:oklch(62.705% .169 149.213);--color-success-content:oklch(12.541% .033 149.213);--color-warning:oklch(66.584% .157 58.318);--color-warning-content:oklch(27% .077 45.635);--color-error:oklch(73.95% .19 27.33);--color-error-content:oklch(14.79% .038 27.33);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=lofi]:checked),[data-theme=lofi]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(94% 0 0);--color-base-content:oklch(0% 0 0);--color-primary:oklch(15.906% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(21.455% .001 17.278);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(26.861% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(0% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(79.54% .103 205.9);--color-info-content:oklch(15.908% .02 205.9);--color-success:oklch(90.13% .153 164.14);--color-success-content:oklch(18.026% .03 164.14);--color-warning:oklch(88.37% .135 79.94);--color-warning-content:oklch(17.674% .027 79.94);--color-error:oklch(78.66% .15 28.47);--color-error-content:oklch(15.732% .03 28.47);--radius-selector:2rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=pastel]:checked),[data-theme=pastel]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98.462% .001 247.838);--color-base-300:oklch(92.462% .001 247.838);--color-base-content:oklch(20% 0 0);--color-primary:oklch(90% .063 306.703);--color-primary-content:oklch(49% .265 301.924);--color-secondary:oklch(89% .058 10.001);--color-secondary-content:oklch(51% .222 16.935);--color-accent:oklch(90% .093 164.15);--color-accent-content:oklch(50% .118 165.612);--color-neutral:oklch(55% .046 257.417);--color-neutral-content:oklch(92% .013 255.508);--color-info:oklch(86% .127 207.078);--color-info-content:oklch(52% .105 223.128);--color-success:oklch(87% .15 154.449);--color-success-content:oklch(52% .154 150.069);--color-warning:oklch(83% .128 66.29);--color-warning-content:oklch(55% .195 38.402);--color-error:oklch(80% .114 19.571);--color-error-content:oklch(50% .213 27.518);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:0;--noise:0}:root:has(input.theme-controller[value=fantasy]:checked),[data-theme=fantasy]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(27.807% .029 256.847);--color-primary:oklch(37.45% .189 325.02);--color-primary-content:oklch(87.49% .037 325.02);--color-secondary:oklch(53.92% .162 241.36);--color-secondary-content:oklch(90.784% .032 241.36);--color-accent:oklch(75.98% .204 56.72);--color-accent-content:oklch(15.196% .04 56.72);--color-neutral:oklch(27.807% .029 256.847);--color-neutral-content:oklch(85.561% .005 256.847);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=wireframe]:checked),[data-theme=wireframe]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(94% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(87% 0 0);--color-primary-content:oklch(26% 0 0);--color-secondary:oklch(87% 0 0);--color-secondary-content:oklch(26% 0 0);--color-accent:oklch(87% 0 0);--color-accent-content:oklch(26% 0 0);--color-neutral:oklch(87% 0 0);--color-neutral-content:oklch(26% 0 0);--color-info:oklch(44% .11 240.79);--color-info-content:oklch(90% .058 230.902);--color-success:oklch(43% .095 166.913);--color-success-content:oklch(90% .093 164.15);--color-warning:oklch(47% .137 46.201);--color-warning-content:oklch(92% .12 95.746);--color-error:oklch(44% .177 26.899);--color-error-content:oklch(88% .062 18.334);--radius-selector:0rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=black]:checked),[data-theme=black]{color-scheme:dark;--color-base-100:oklch(0% 0 0);--color-base-200:oklch(19% 0 0);--color-base-300:oklch(22% 0 0);--color-base-content:oklch(87.609% 0 0);--color-primary:oklch(35% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(35% 0 0);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(35% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(35% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(45.201% .313 264.052);--color-info-content:oklch(89.04% .062 264.052);--color-success:oklch(51.975% .176 142.495);--color-success-content:oklch(90.395% .035 142.495);--color-warning:oklch(96.798% .211 109.769);--color-warning-content:oklch(19.359% .042 109.769);--color-error:oklch(62.795% .257 29.233);--color-error-content:oklch(12.559% .051 29.233);--radius-selector:0rem;--radius-field:0rem;--radius-box:0rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=luxury]:checked),[data-theme=luxury]{color-scheme:dark;--color-base-100:oklch(14.076% .004 285.822);--color-base-200:oklch(20.219% .004 308.229);--color-base-300:oklch(23.219% .004 308.229);--color-base-content:oklch(75.687% .123 76.89);--color-primary:oklch(100% 0 0);--color-primary-content:oklch(20% 0 0);--color-secondary:oklch(27.581% .064 261.069);--color-secondary-content:oklch(85.516% .012 261.069);--color-accent:oklch(36.674% .051 338.825);--color-accent-content:oklch(87.334% .01 338.825);--color-neutral:oklch(24.27% .057 59.825);--color-neutral-content:oklch(93.203% .089 90.861);--color-info:oklch(79.061% .121 237.133);--color-info-content:oklch(15.812% .024 237.133);--color-success:oklch(78.119% .192 132.154);--color-success-content:oklch(15.623% .038 132.154);--color-warning:oklch(86.127% .136 102.891);--color-warning-content:oklch(17.225% .027 102.891);--color-error:oklch(71.753% .176 22.568);--color-error-content:oklch(14.35% .035 22.568);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dracula]:checked),[data-theme=dracula]{color-scheme:dark;--color-base-100:oklch(28.822% .022 277.508);--color-base-200:oklch(26.805% .02 277.508);--color-base-300:oklch(24.787% .019 277.508);--color-base-content:oklch(97.747% .007 106.545);--color-primary:oklch(75.461% .183 346.812);--color-primary-content:oklch(15.092% .036 346.812);--color-secondary:oklch(74.202% .148 301.883);--color-secondary-content:oklch(14.84% .029 301.883);--color-accent:oklch(83.392% .124 66.558);--color-accent-content:oklch(16.678% .024 66.558);--color-neutral:oklch(39.445% .032 275.524);--color-neutral-content:oklch(87.889% .006 275.524);--color-info:oklch(88.263% .093 212.846);--color-info-content:oklch(17.652% .018 212.846);--color-success:oklch(87.099% .219 148.024);--color-success-content:oklch(17.419% .043 148.024);--color-warning:oklch(95.533% .134 112.757);--color-warning-content:oklch(19.106% .026 112.757);--color-error:oklch(68.22% .206 24.43);--color-error-content:oklch(13.644% .041 24.43);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=cmyk]:checked),[data-theme=cmyk]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(95% 0 0);--color-base-300:oklch(90% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(71.772% .133 239.443);--color-primary-content:oklch(14.354% .026 239.443);--color-secondary:oklch(64.476% .202 359.339);--color-secondary-content:oklch(12.895% .04 359.339);--color-accent:oklch(94.228% .189 105.306);--color-accent-content:oklch(18.845% .037 105.306);--color-neutral:oklch(21.778% 0 0);--color-neutral-content:oklch(84.355% 0 0);--color-info:oklch(68.475% .094 217.284);--color-info-content:oklch(13.695% .018 217.284);--color-success:oklch(46.949% .162 321.406);--color-success-content:oklch(89.389% .032 321.406);--color-warning:oklch(71.236% .159 52.023);--color-warning-content:oklch(14.247% .031 52.023);--color-error:oklch(62.013% .208 28.717);--color-error-content:oklch(12.402% .041 28.717);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=autumn]:checked),[data-theme=autumn]{color-scheme:light;--color-base-100:oklch(95.814% 0 0);--color-base-200:oklch(89.107% 0 0);--color-base-300:oklch(82.4% 0 0);--color-base-content:oklch(19.162% 0 0);--color-primary:oklch(40.723% .161 17.53);--color-primary-content:oklch(88.144% .032 17.53);--color-secondary:oklch(61.676% .169 23.865);--color-secondary-content:oklch(12.335% .033 23.865);--color-accent:oklch(73.425% .094 60.729);--color-accent-content:oklch(14.685% .018 60.729);--color-neutral:oklch(54.367% .037 51.902);--color-neutral-content:oklch(90.873% .007 51.902);--color-info:oklch(69.224% .097 207.284);--color-info-content:oklch(13.844% .019 207.284);--color-success:oklch(60.995% .08 174.616);--color-success-content:oklch(12.199% .016 174.616);--color-warning:oklch(70.081% .164 56.844);--color-warning-content:oklch(14.016% .032 56.844);--color-error:oklch(53.07% .241 24.16);--color-error-content:oklch(90.614% .048 24.16);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=business]:checked),[data-theme=business]{color-scheme:dark;--color-base-100:oklch(24.353% 0 0);--color-base-200:oklch(22.648% 0 0);--color-base-300:oklch(20.944% 0 0);--color-base-content:oklch(84.87% 0 0);--color-primary:oklch(41.703% .099 251.473);--color-primary-content:oklch(88.34% .019 251.473);--color-secondary:oklch(64.092% .027 229.389);--color-secondary-content:oklch(12.818% .005 229.389);--color-accent:oklch(67.271% .167 35.791);--color-accent-content:oklch(13.454% .033 35.791);--color-neutral:oklch(27.441% .013 253.041);--color-neutral-content:oklch(85.488% .002 253.041);--color-info:oklch(62.616% .143 240.033);--color-info-content:oklch(12.523% .028 240.033);--color-success:oklch(70.226% .094 156.596);--color-success-content:oklch(14.045% .018 156.596);--color-warning:oklch(77.482% .115 81.519);--color-warning-content:oklch(15.496% .023 81.519);--color-error:oklch(51.61% .146 29.674);--color-error-content:oklch(90.322% .029 29.674);--radius-selector:0rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=acid]:checked),[data-theme=acid]{color-scheme:light;--color-base-100:oklch(98% 0 0);--color-base-200:oklch(95% 0 0);--color-base-300:oklch(91% 0 0);--color-base-content:oklch(0% 0 0);--color-primary:oklch(71.9% .357 330.759);--color-primary-content:oklch(14.38% .071 330.759);--color-secondary:oklch(73.37% .224 48.25);--color-secondary-content:oklch(14.674% .044 48.25);--color-accent:oklch(92.78% .264 122.962);--color-accent-content:oklch(18.556% .052 122.962);--color-neutral:oklch(21.31% .128 278.68);--color-neutral-content:oklch(84.262% .025 278.68);--color-info:oklch(60.72% .227 252.05);--color-info-content:oklch(12.144% .045 252.05);--color-success:oklch(85.72% .266 158.53);--color-success-content:oklch(17.144% .053 158.53);--color-warning:oklch(91.01% .212 100.5);--color-warning-content:oklch(18.202% .042 100.5);--color-error:oklch(64.84% .293 29.349);--color-error-content:oklch(12.968% .058 29.349);--radius-selector:1rem;--radius-field:1rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=lemonade]:checked),[data-theme=lemonade]{color-scheme:light;--color-base-100:oklch(98.71% .02 123.72);--color-base-200:oklch(91.8% .018 123.72);--color-base-300:oklch(84.89% .017 123.72);--color-base-content:oklch(19.742% .004 123.72);--color-primary:oklch(58.92% .199 134.6);--color-primary-content:oklch(11.784% .039 134.6);--color-secondary:oklch(77.75% .196 111.09);--color-secondary-content:oklch(15.55% .039 111.09);--color-accent:oklch(85.39% .201 100.73);--color-accent-content:oklch(17.078% .04 100.73);--color-neutral:oklch(30.98% .075 108.6);--color-neutral-content:oklch(86.196% .015 108.6);--color-info:oklch(86.19% .047 224.14);--color-info-content:oklch(17.238% .009 224.14);--color-success:oklch(86.19% .047 157.85);--color-success-content:oklch(17.238% .009 157.85);--color-warning:oklch(86.19% .047 102.15);--color-warning-content:oklch(17.238% .009 102.15);--color-error:oklch(86.19% .047 25.85);--color-error-content:oklch(17.238% .009 25.85);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=night]:checked),[data-theme=night]{color-scheme:dark;--color-base-100:oklch(20.768% .039 265.754);--color-base-200:oklch(19.314% .037 265.754);--color-base-300:oklch(17.86% .034 265.754);--color-base-content:oklch(84.153% .007 265.754);--color-primary:oklch(75.351% .138 232.661);--color-primary-content:oklch(15.07% .027 232.661);--color-secondary:oklch(68.011% .158 276.934);--color-secondary-content:oklch(13.602% .031 276.934);--color-accent:oklch(72.36% .176 350.048);--color-accent-content:oklch(14.472% .035 350.048);--color-neutral:oklch(27.949% .036 260.03);--color-neutral-content:oklch(85.589% .007 260.03);--color-info:oklch(68.455% .148 237.251);--color-info-content:oklch(0% 0 0);--color-success:oklch(78.452% .132 181.911);--color-success-content:oklch(15.69% .026 181.911);--color-warning:oklch(83.242% .139 82.95);--color-warning-content:oklch(16.648% .027 82.95);--color-error:oklch(71.785% .17 13.118);--color-error-content:oklch(14.357% .034 13.118);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=coffee]:checked),[data-theme=coffee]{color-scheme:dark;--color-base-100:oklch(24% .023 329.708);--color-base-200:oklch(21% .021 329.708);--color-base-300:oklch(16% .019 329.708);--color-base-content:oklch(72.354% .092 79.129);--color-primary:oklch(71.996% .123 62.756);--color-primary-content:oklch(14.399% .024 62.756);--color-secondary:oklch(34.465% .029 199.194);--color-secondary-content:oklch(86.893% .005 199.194);--color-accent:oklch(42.621% .074 224.389);--color-accent-content:oklch(88.524% .014 224.389);--color-neutral:oklch(16.51% .015 326.261);--color-neutral-content:oklch(83.302% .003 326.261);--color-info:oklch(79.49% .063 184.558);--color-info-content:oklch(15.898% .012 184.558);--color-success:oklch(74.722% .072 131.116);--color-success-content:oklch(14.944% .014 131.116);--color-warning:oklch(88.15% .14 87.722);--color-warning-content:oklch(17.63% .028 87.722);--color-error:oklch(77.318% .128 31.871);--color-error-content:oklch(15.463% .025 31.871);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=winter]:checked),[data-theme=winter]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97.466% .011 259.822);--color-base-300:oklch(93.268% .016 262.751);--color-base-content:oklch(41.886% .053 255.824);--color-primary:oklch(56.86% .255 257.57);--color-primary-content:oklch(91.372% .051 257.57);--color-secondary:oklch(42.551% .161 282.339);--color-secondary-content:oklch(88.51% .032 282.339);--color-accent:oklch(59.939% .191 335.171);--color-accent-content:oklch(11.988% .038 335.171);--color-neutral:oklch(19.616% .063 257.651);--color-neutral-content:oklch(83.923% .012 257.651);--color-info:oklch(88.127% .085 214.515);--color-info-content:oklch(17.625% .017 214.515);--color-success:oklch(80.494% .077 197.823);--color-success-content:oklch(16.098% .015 197.823);--color-warning:oklch(89.172% .045 71.47);--color-warning-content:oklch(17.834% .009 71.47);--color-error:oklch(73.092% .11 20.076);--color-error-content:oklch(14.618% .022 20.076);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=dim]:checked),[data-theme=dim]{color-scheme:dark;--color-base-100:oklch(30.857% .023 264.149);--color-base-200:oklch(28.036% .019 264.182);--color-base-300:oklch(26.346% .018 262.177);--color-base-content:oklch(82.901% .031 222.959);--color-primary:oklch(86.133% .141 139.549);--color-primary-content:oklch(17.226% .028 139.549);--color-secondary:oklch(73.375% .165 35.353);--color-secondary-content:oklch(14.675% .033 35.353);--color-accent:oklch(74.229% .133 311.379);--color-accent-content:oklch(14.845% .026 311.379);--color-neutral:oklch(24.731% .02 264.094);--color-neutral-content:oklch(82.901% .031 222.959);--color-info:oklch(86.078% .142 206.182);--color-info-content:oklch(17.215% .028 206.182);--color-success:oklch(86.171% .142 166.534);--color-success-content:oklch(17.234% .028 166.534);--color-warning:oklch(86.163% .142 94.818);--color-warning-content:oklch(17.232% .028 94.818);--color-error:oklch(82.418% .099 33.756);--color-error-content:oklch(16.483% .019 33.756);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=nord]:checked),[data-theme=nord]{color-scheme:light;--color-base-100:oklch(95.127% .007 260.731);--color-base-200:oklch(93.299% .01 261.788);--color-base-300:oklch(89.925% .016 262.749);--color-base-content:oklch(32.437% .022 264.182);--color-primary:oklch(59.435% .077 254.027);--color-primary-content:oklch(11.887% .015 254.027);--color-secondary:oklch(69.651% .059 248.687);--color-secondary-content:oklch(13.93% .011 248.687);--color-accent:oklch(77.464% .062 217.469);--color-accent-content:oklch(15.492% .012 217.469);--color-neutral:oklch(45.229% .035 264.131);--color-neutral-content:oklch(89.925% .016 262.749);--color-info:oklch(69.207% .062 332.664);--color-info-content:oklch(13.841% .012 332.664);--color-success:oklch(76.827% .074 131.063);--color-success-content:oklch(15.365% .014 131.063);--color-warning:oklch(85.486% .089 84.093);--color-warning-content:oklch(17.097% .017 84.093);--color-error:oklch(60.61% .12 15.341);--color-error-content:oklch(12.122% .024 15.341);--radius-selector:1rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=sunset]:checked),[data-theme=sunset]{color-scheme:dark;--color-base-100:oklch(22% .019 237.69);--color-base-200:oklch(20% .019 237.69);--color-base-300:oklch(18% .019 237.69);--color-base-content:oklch(77.383% .043 245.096);--color-primary:oklch(74.703% .158 39.947);--color-primary-content:oklch(14.94% .031 39.947);--color-secondary:oklch(72.537% .177 2.72);--color-secondary-content:oklch(14.507% .035 2.72);--color-accent:oklch(71.294% .166 299.844);--color-accent-content:oklch(14.258% .033 299.844);--color-neutral:oklch(26% .019 237.69);--color-neutral-content:oklch(70% .019 237.69);--color-info:oklch(85.559% .085 206.015);--color-info-content:oklch(17.111% .017 206.015);--color-success:oklch(85.56% .085 144.778);--color-success-content:oklch(17.112% .017 144.778);--color-warning:oklch(85.569% .084 74.427);--color-warning-content:oklch(17.113% .016 74.427);--color-error:oklch(85.511% .078 16.886);--color-error-content:oklch(17.102% .015 16.886);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=caramellatte]:checked),[data-theme=caramellatte]{color-scheme:light;--color-base-100:oklch(98% .016 73.684);--color-base-200:oklch(95% .038 75.164);--color-base-300:oklch(90% .076 70.697);--color-base-content:oklch(40% .123 38.172);--color-primary:oklch(0% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(22.45% .075 37.85);--color-secondary-content:oklch(90% .076 70.697);--color-accent:oklch(46.44% .111 37.85);--color-accent-content:oklch(90% .076 70.697);--color-neutral:oklch(55% .195 38.402);--color-neutral-content:oklch(98% .016 73.684);--color-info:oklch(42% .199 265.638);--color-info-content:oklch(90% .076 70.697);--color-success:oklch(43% .095 166.913);--color-success-content:oklch(90% .076 70.697);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(39% .141 25.723);--radius-selector:2rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:1}:root:has(input.theme-controller[value=abyss]:checked),[data-theme=abyss]{color-scheme:dark;--color-base-100:oklch(20% .08 209);--color-base-200:oklch(15% .08 209);--color-base-300:oklch(10% .08 209);--color-base-content:oklch(90% .076 70.697);--color-primary:oklch(92% .2653 125);--color-primary-content:oklch(50% .2653 125);--color-secondary:oklch(83.27% .0764 298.3);--color-secondary-content:oklch(43.27% .0764 298.3);--color-accent:oklch(43% 0 0);--color-accent-content:oklch(98% 0 0);--color-neutral:oklch(30% .08 209);--color-neutral-content:oklch(90% .076 70.697);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(79% .209 151.711);--color-success-content:oklch(26% .065 152.934);--color-warning:oklch(84.8% .1962 84.62);--color-warning-content:oklch(44.8% .1962 84.62);--color-error:oklch(65% .1985 24.22);--color-error-content:oklch(27% .1985 24.22);--radius-selector:2rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=silk]:checked),[data-theme=silk]{color-scheme:light;--color-base-100:oklch(97% .0035 67.78);--color-base-200:oklch(95% .0081 61.42);--color-base-300:oklch(90% .0081 61.42);--color-base-content:oklch(40% .0081 61.42);--color-primary:oklch(23.27% .0249 284.3);--color-primary-content:oklch(94.22% .2505 117.44);--color-secondary:oklch(23.27% .0249 284.3);--color-secondary-content:oklch(73.92% .2135 50.94);--color-accent:oklch(23.27% .0249 284.3);--color-accent-content:oklch(88.92% .2061 189.9);--color-neutral:oklch(20% 0 0);--color-neutral-content:oklch(80% .0081 61.42);--color-info:oklch(80.39% .1148 241.68);--color-info-content:oklch(30.39% .1148 241.68);--color-success:oklch(83.92% .0901 136.87);--color-success-content:oklch(23.92% .0901 136.87);--color-warning:oklch(83.92% .1085 80);--color-warning-content:oklch(43.92% .1085 80);--color-error:oklch(75.1% .1814 22.37);--color-error-content:oklch(35.1% .1814 22.37);--radius-selector:2rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:0}:root:not(span){overflow:var(--page-overflow)}:root{background:var(--page-scroll-bg,var(--root-bg));--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) var(--root-bg,#0000)}@supports (color:color-mix(in lab, red, red)){:root{--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) color-mix(in srgb, var(--root-bg,#0000), oklch(0% 0 0) calc(var(--page-has-backdrop,0) * 40%))}}:root{--page-scroll-transition-on:background-color .3s ease-out;transition:var(--page-scroll-transition);scrollbar-gutter:var(--page-scroll-gutter,unset);scrollbar-gutter:if(style(--page-has-scroll: 1): var(--page-scroll-gutter,unset) ; else: unset)}@keyframes set-page-has-scroll{0%,to{--page-has-scroll:1}}:root{--fx-noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E")}:root,[data-theme]{background:var(--page-scroll-bg,var(--root-bg));color:var(--color-base-content)}:where(:root,[data-theme]){--root-bg:var(--color-base-100)}:root{scrollbar-color:currentColor #0000}@supports (color:color-mix(in lab, red, red)){:root{scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) #0000}}@property --radialprogress{syntax:"<percentage>";inherits:true;initial-value:0%}:where(:root),:root:has(input.theme-controller[value=mitto]:checked),[data-theme=mitto]{color-scheme:dark;--color-base-100:var(--mitto-bg);--color-base-200:var(--mitto-surface-2);--color-base-300:var(--mitto-surface-3);--color-base-content:var(--mitto-text);--color-primary:var(--mitto-accent);--color-primary-content:var(--mitto-accent-fg);--color-secondary:var(--mitto-accent-hover);--color-secondary-content:var(--mitto-accent-fg);--color-accent:var(--mitto-accent);--color-accent-content:var(--mitto-accent-fg);--color-neutral:var(--mitto-surface-4);--color-neutral-content:var(--mitto-text);--color-info:var(--mitto-info);--color-info-content:#fff;--color-success:var(--mitto-success);--color-success-content:#fff;--color-warning:var(--mitto-warning);--color-warning-content:#000;--color-error:var(--mitto-danger);--color-error-content:var(--mitto-danger-fg);--radius-selector:.5rem;--radius-field:.5rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}}@layer components;@layer utilities{@layer daisyui.l1.l2.l3{.diff{webkit-user-select:none;-webkit-user-select:none;user-select:none;direction:ltr;grid-template-rows:1fr 1.8rem 1fr;grid-template-columns:auto 1fr;width:100%;display:grid;position:relative;overflow:hidden;container-type:inline-size}.diff:focus-visible,.diff:has(.diff-item-1:focus-visible),.diff:focus-visible{outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content)}.diff:focus-visible .diff-resizer{min-width:95cqi;max-width:95cqi}.diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px}.diff:has(.diff-item-1:focus-visible) .diff-resizer{min-width:5cqi;max-width:5cqi}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}.modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.modal::backdrop{display:none}:where(.drawer-side){overflow:hidden}.drawer-side{pointer-events:none;visibility:hidden;z-index:10;overscroll-behavior:contain;opacity:0;width:100%;transition:opacity .2s ease-out .1s allow-discrete, visibility .3s ease-out .1s allow-discrete;inset-inline-start:0;background-color:#0000;grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:repeat(1,minmax(0,1fr));grid-row-start:1;grid-column-start:1;place-items:flex-start start;height:100dvh;display:grid;position:fixed;top:0}.drawer-side>.drawer-overlay{cursor:pointer;background-color:oklch(0% 0 0/.4);place-self:stretch stretch;position:sticky;top:0}.drawer-side>*{grid-row-start:1;grid-column-start:1}.drawer-side>:not(.drawer-overlay){will-change:transform;transition:translate .3s ease-out,width .2s ease-out;translate:-100%}[dir=rtl] :is(.drawer-side>:not(.drawer-overlay)){translate:100%}.drawer-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:currentColor oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4))}@supports (color:color-mix(in lab, red, red)){:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4))}}:where(.drawer-toggle:checked~.drawer-side)>:not(.drawer-overlay){will-change:auto;transform:none}:where(:root:has(.drawer-toggle:checked)){--page-has-backdrop:1;--page-overflow:hidden;--page-scroll-bg:var(--page-scroll-bg-on);--page-scroll-gutter:stable;--page-scroll-transition:var(--page-scroll-transition-on);animation:forwards set-page-has-scroll;animation-timeline:scroll()}.tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-tail:calc(100% + 1px + .25rem);display:inline-block;position:relative}.tooltip>.tooltip-content,.tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;line-height:1.25;position:absolute}.tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;-webkit-mask-position:-1px 0;mask-position:-1px 0;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-image:var(--mask-tooltip);-webkit-mask-image:var(--mask-tooltip);mask-image:var(--mask-tooltip);display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.tooltip>.tooltip-content,.tooltip[data-tip]:before,.tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem}@media (prefers-reduced-motion:no-preference){:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}.tab{cursor:pointer;appearance:none;text-align:center;webkit-user-select:none;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative}@media (hover:hover){.tab:hover{color:var(--color-base-content)}}.tab{--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem}.tab:is(input[type=radio]){min-width:fit-content}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:is(label){position:relative}.tab:is(label) input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}:is(.tab:checked,.tab:is(label:has(:checked)),.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content{display:block}.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.tab:not(input):empty{cursor:default;flex-grow:1}.tab:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.tab:focus{outline-offset:2px;outline:2px solid #0000}}.tab:focus-visible,.tab:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}.tab[disabled]{pointer-events:none;opacity:.4}.menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.menu :where(li ul){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative}.menu :where(li ul):before{background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";inset-inline-start:0;position:absolute;top:.75rem;bottom:.75rem}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;text-wrap:balance;-webkit-user-select:none;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.menu :where(li>details>summary){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li>details>summary){outline-offset:2px;outline:2px solid #0000}}.menu :where(li>details>summary)::-webkit-details-marker{display:none}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.menu details{interpolate-size:allow-keywords;overflow:hidden}.menu details::details-content{block-size:0}@media (prefers-reduced-motion:no-preference){.menu details::details-content{transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.menu details[open]::details-content{block-size:auto}.menu :where(li>details[open]>summary):after,.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{color:var(--color-base-content);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative}.menu :where(li) .badge{justify-self:flex-end}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{outline-offset:2px;outline:2px solid #0000}}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):not(:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}.menu :where(li).menu-disabled{pointer-events:none;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li).menu-disabled{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.menu .dropdown-content{margin-top:.5rem;padding:.5rem}.menu .dropdown-content:before{display:none}.dock{z-index:1;background-color:var(--color-base-100);color:currentColor;border-top:.5px solid var(--color-base-content);flex-direction:row;justify-content:space-around;align-items:center;width:100%;padding:.5rem;display:flex;position:fixed;bottom:0;left:0;right:0}@supports (color:color-mix(in lab, red, red)){.dock{border-top:.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000)}}.dock{height:4rem;height:calc(4rem + env(safe-area-inset-bottom));padding-bottom:env(safe-area-inset-bottom)}.dock>*{cursor:pointer;border-radius:var(--radius-box);background-color:#0000;flex-direction:column;flex-shrink:1;flex-basis:100%;justify-content:center;align-items:center;gap:1px;max-width:8rem;height:100%;margin-bottom:.5rem;transition:opacity .2s ease-out;display:flex;position:relative}@media (hover:hover){.dock>:hover{opacity:.8}}:is(.dock>[aria-disabled=true],.dock>[disabled]),:is(.dock>[aria-disabled=true],.dock>[disabled]):hover{pointer-events:none;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.dock>[aria-disabled=true],.dock>[disabled]),:is(.dock>[aria-disabled=true],.dock>[disabled]):hover{color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}:is(.dock>[aria-disabled=true],.dock>[disabled]),:is(.dock>[aria-disabled=true],.dock>[disabled]):hover{opacity:1}.dock>* .dock-label{font-size:.6875rem}.dock>:after{content:"";background-color:#0000;border-top:3px solid #0000;border-radius:3.40282e38px;width:1.5rem;height:.25rem;transition:background-color .1s ease-out,text-color .1s ease-out,width .1s ease-out;position:absolute;bottom:.2rem}.dropdown{position-area:var(--anchor-v,bottom) var(--anchor-h,span-right);display:inline-block;position:relative}.dropdown>:not(:has(~[class*=dropdown-content])):focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.dropdown>:not(:has(~[class*=dropdown-content])):focus{outline-offset:2px;outline:2px solid #0000}}.dropdown .dropdown-content{position:absolute}.dropdown.dropdown-close .dropdown-content,.dropdown:not(details,.dropdown-open,.dropdown-hover:hover,:focus-within) .dropdown-content,.dropdown.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible)~.dropdown-content{transform-origin:top;opacity:0;display:none;scale:.95}.dropdown[popover],.dropdown .dropdown-content{z-index:999}@media (prefers-reduced-motion:no-preference){.dropdown[popover],.dropdown .dropdown-content{transition-behavior:allow-discrete;transition-property:opacity,scale,display,overlay;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s dropdown}}@starting-style{.dropdown[popover],.dropdown .dropdown-content{opacity:0;scale:.95}}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within)>[tabindex]:first-child{pointer-events:none}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within) .dropdown-content,.dropdown:not(.dropdown-close).dropdown-hover:hover .dropdown-content{opacity:1;scale:1}.dropdown:is(details) summary::-webkit-details-marker{display:none}.dropdown:where([popover]){background:0 0}.dropdown[popover]{color:inherit;position:fixed}@supports not (position-area:bottom){.dropdown[popover]{margin:auto}.dropdown[popover].dropdown-close{transform-origin:top;opacity:0;display:none;scale:.95}.dropdown[popover].dropdown-open:not(:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}.dropdown[popover]::backdrop{background-color:oklab(0% none none/.3)}}:is(.dropdown[popover].dropdown-close,.dropdown[popover]:not(.dropdown-open,:popover-open)){transform-origin:top;opacity:0;display:none;scale:.95}:where(.btn){width:unset}.btn{cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;webkit-user-select:none;-webkit-user-select:none;user-select:none;padding-inline:var(--btn-p);color:var(--btn-fg);--tw-prose-links:var(--btn-fg);height:var(--size);font-size:var(--fontsize,.875rem);outline-color:var(--btn-color,var(--color-base-content));background-color:var(--btn-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--btn-noise);border-width:var(--border);border-style:solid;border-color:var(--btn-border);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));touch-action:manipulation;box-shadow:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-p:1rem;--btn-border:var(--btn-bg);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;font-weight:600;transition-property:color,background-color,border-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}@supports (color:color-mix(in lab, red, red)){.btn{--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%))}}.btn{--btn-shadow:0 3px 2px -2px var(--btn-bg), 0 4px 3px -2px var(--btn-bg)}@supports (color:color-mix(in lab, red, red)){.btn{--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.btn{--btn-noise:var(--fx-noise)}@media (hover:hover){.btn:hover{--btn-bg:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}}.btn:focus-visible,.btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}.btn:active:not(.btn-active){--btn-bg:var(--btn-color,var(--color-base-200));translate:0 .5px}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%)}}.btn:active:not(.btn-active){--btn-border:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}.btn:active:not(.btn-active){--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0)}.btn:is(input[type=checkbox],input[type=radio]){appearance:none}.btn:is(input[type=checkbox],input[type=radio])[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}.btn:where(input:checked:not(.filter .btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);isolation:isolate}.loading{pointer-events:none;aspect-ratio:1;vertical-align:middle;width:calc(var(--size-selector,.25rem) * 6);background-color:currentColor;display:inline-block;-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:100%;mask-size:100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.countdown{display:inline-flex}.countdown>*{visibility:hidden;--value-v:calc(mod(max(0, var(--value)), 1000));--value-hundreds:calc(round(to-zero, var(--value-v) / 100, 1));--value-tens:calc(round(to-zero, mod(var(--value-v), 100) / 10, 1));--value-ones:calc(mod(var(--value-v), 100));--show-hundreds:clamp(clamp(0, var(--digits,1) - 2, 1), var(--value-hundreds), 1);--show-tens:clamp(clamp(0, var(--digits,1) - 1, 1), var(--value-tens) + var(--show-hundreds), 1);--first-digits:calc(round(to-zero, var(--value-v) / 10, 1));height:1em;width:calc(1ch + var(--show-tens) * 1ch + var(--show-hundreds) * 1ch);direction:ltr;transition:width .4s ease-out .2s;display:inline-block;position:relative;overflow-y:clip}.countdown>:before,.countdown>:after{visibility:visible;--tw-content:"00\a 01\a 02\a 03\a 04\a 05\a 06\a 07\a 08\a 09\a 10\a 11\a 12\a 13\a 14\a 15\a 16\a 17\a 18\a 19\a 20\a 21\a 22\a 23\a 24\a 25\a 26\a 27\a 28\a 29\a 30\a 31\a 32\a 33\a 34\a 35\a 36\a 37\a 38\a 39\a 40\a 41\a 42\a 43\a 44\a 45\a 46\a 47\a 48\a 49\a 50\a 51\a 52\a 53\a 54\a 55\a 56\a 57\a 58\a 59\a 60\a 61\a 62\a 63\a 64\a 65\a 66\a 67\a 68\a 69\a 70\a 71\a 72\a 73\a 74\a 75\a 76\a 77\a 78\a 79\a 80\a 81\a 82\a 83\a 84\a 85\a 86\a 87\a 88\a 89\a 90\a 91\a 92\a 93\a 94\a 95\a 96\a 97\a 98\a 99\a ";content:var(--tw-content);font-variant-numeric:tabular-nums;white-space:pre;text-align:end;direction:rtl;transition:all 1s cubic-bezier(1,0,0,1),width .2s ease-out .2s,opacity .2s ease-out .2s;position:absolute;overflow-x:clip}.countdown>:before{width:calc(1ch + var(--show-hundreds) * 1ch);top:calc(var(--first-digits) * -1em);opacity:var(--show-tens);inset-inline-end:0}.countdown>:after{width:1ch;top:calc(var(--value-ones) * -1em);inset-inline-start:0}.collapse{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;overflow:hidden}@media (prefers-reduced-motion:no-preference){.collapse{transition:grid-template-rows .2s}}.collapse>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;z-index:1;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),.collapse:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){content-visibility:visible;min-height:fit-content}@supports not (content-visibility:visible){.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){visibility:visible}}.collapse:focus-visible,.collapse:has(>input:is([type=checkbox],[type=radio]):focus-visible),.collapse:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}.collapse:not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-close)>input[type=radio]:not(:checked),.collapse:not(.collapse-close)>.collapse-title{cursor:pointer}:is(.collapse[tabindex]:focus:not(.collapse-close,.collapse[open]),.collapse[tabindex]:focus-within:not(.collapse-close,.collapse[open]))>.collapse-title{cursor:unset}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}.collapse:is(details){width:100%}@media (prefers-reduced-motion:no-preference){.collapse:is(details)::details-content{transition:content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords;height:0}.collapse:is(details):where([open])::details-content{height:auto}}.collapse:is(details) summary{display:block;position:relative}.collapse:is(details) summary::-webkit-details-marker{display:none}.collapse:is(details)>.collapse-content{content-visibility:visible}.collapse:is(details) summary{outline:none}.collapse-content{content-visibility:hidden;min-height:0;cursor:unset;grid-row-start:2;grid-column-start:1;padding-left:1rem;padding-right:1rem}@supports not (content-visibility:hidden){.collapse-content{visibility:hidden}}@media (prefers-reduced-motion:no-preference){.collapse-content{transition:content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out}}.list{flex-direction:column;font-size:.875rem;display:flex}.list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:var(--color-base-content);position:absolute;bottom:0}@supports (color:color-mix(in lab, red, red)){:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent)}}.toast{translate:var(--toast-x,0) var(--toast-y,0);inset-inline:auto 1rem;background-color:#0000;flex-direction:column;gap:.5rem;width:max-content;max-width:calc(100vw - 2rem);display:flex;position:fixed;top:auto;bottom:1rem}@media (prefers-reduced-motion:no-preference){.toast>*{animation:.25s ease-out toast}}.toggle{border:var(--border) solid currentColor;color:var(--input-color);cursor:pointer;appearance:none;vertical-align:middle;webkit-user-select:none;-webkit-user-select:none;user-select:none;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)));padding:var(--toggle-p);flex-shrink:0;grid-template-columns:0fr 1fr 1fr;place-content:center;display:inline-grid;position:relative;box-shadow:inset 0 1px}@supports (color:color-mix(in lab, red, red)){.toggle{box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset}}.toggle{--input-color:var(--color-base-content);transition:color .3s,grid-template-columns .2s}@supports (color:color-mix(in lab, red, red)){.toggle{--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000)}}.toggle{--toggle-p:calc(var(--size) * .125);--size:calc(var(--size-selector,.25rem) * 6);width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);height:var(--size)}.toggle>*{z-index:1;cursor:pointer;appearance:none;background-color:#0000;border:none;grid-column:2/span 1;grid-row-start:1;height:100%;padding:.125rem;transition:opacity .2s,rotate .4s}.toggle>:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.toggle>:focus{outline-offset:2px;outline:2px solid #0000}}.toggle>:nth-child(2){color:var(--color-base-100);rotate:0deg}.toggle>:nth-child(3){color:var(--color-base-100);opacity:0;rotate:-15deg}.toggle:has(:checked)>:nth-child(2){opacity:0;rotate:15deg}.toggle:has(:checked)>:nth-child(3){opacity:1;rotate:0deg}.toggle:before{aspect-ratio:1;border-radius:var(--radius-selector);--tw-content:"";content:var(--tw-content);width:100%;height:100%;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor;background-color:currentColor;grid-row-start:1;grid-column-start:2;transition:background-color .1s,translate .2s,inset-inline-start .2s;position:relative;inset-inline-start:0;translate:0}@supports (color:color-mix(in lab, red, red)){.toggle:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)}}.toggle:before{background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}@media (forced-colors:active){.toggle:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{.toggle:before{outline-offset:-1rem;outline:.25rem solid}}.toggle:focus-visible,.toggle:has(:focus-visible){outline-offset:2px;outline:2px solid}.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked){background-color:var(--color-base-100);--input-color:var(--color-base-content);grid-template-columns:1fr 1fr 0fr}:is(.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked)):before{background-color:currentColor}@starting-style{:is(.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked)):before{opacity:0}}.toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr}.toggle:disabled{cursor:not-allowed;opacity:.3}.toggle:disabled:before{border:var(--border) solid currentColor;background-color:#0000}.input{cursor:text;border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,.875rem), .875rem);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}@supports (color:color-mix(in lab, red, red)){.input{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.input{--size:calc(var(--size-field,.25rem) * 10);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.input:where(input){display:inline-flex}.input :where(input){appearance:none;background-color:#0000;border:none;width:100%;height:100%;display:inline-flex}.input :where(input):focus,.input :where(input):focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.input :where(input):focus,.input :where(input):focus-within{outline-offset:2px;outline:2px solid #0000}}.input :where(input[type=url]),.input :where(input[type=email]){direction:ltr}.input :where(input[type=date]){display:inline-flex}.input:focus,.input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.input:focus,.input:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.input:focus,.input:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.input:focus,.input:focus-within{--font-size:1rem}}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{box-shadow:none}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.input[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input::-webkit-calendar-picker-indicator{position:absolute;inset-inline-end:.75em}.input:has(>input[type=date]) :where(input[type=date]){webkit-appearance:none;appearance:none;display:inline-flex}.input:has(>input[type=date]) input[type=date]::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute;inset-inline-end:.75em}.indicator{width:max-content;display:inline-flex;position:relative}.indicator :where(.indicator-item){z-index:1;white-space:nowrap;top:var(--indicator-t,0);bottom:var(--indicator-b,auto);left:var(--indicator-s,auto);right:var(--indicator-e,0);translate:var(--indicator-x,50%) var(--indicator-y,-50%);position:absolute}.table{border-collapse:separate;--tw-border-spacing-x:calc(.25rem * 0);--tw-border-spacing-y:calc(.25rem * 0);width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(.table tr.row-hover,.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.table :where(thead,tfoot){white-space:nowrap;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead,tfoot){color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.table :where(thead,tfoot){font-size:.875rem;font-weight:600}.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}.table :where(.table-pin-rows thead tr){z-index:1;background-color:var(--color-base-100);position:sticky;top:0}.table :where(.table-pin-rows tfoot tr){z-index:1;background-color:var(--color-base-100);position:sticky;bottom:0}.table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}.steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid}.steps .step:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}.steps .step>.step-icon,.steps .step:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}.steps .step:first-child:before{--tw-content:none;content:var(--tw-content)}.steps .step[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}.range{appearance:none;webkit-appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:currentColor}@supports (color:color-mix(in lab, red, red)){.range{--range-bg:color-mix(in oklab, currentColor 10%, #0000)}}.range{cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden}[dir=rtl] .range{--range-dir:-1}.range:focus{outline:none}.range:focus-visible{outline-offset:2px;outline:2px solid}.range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.range::-webkit-slider-runnable-track{border:1px solid}.range::-moz-range-track{border:1px solid}}.range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;appearance:none;webkit-appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));position:relative;top:50%;transform:translateY(-50%)}@supports (color:color-mix(in lab, red, red)){.range::-webkit-slider-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}.range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));background-color:currentColor;position:relative;top:50%}@supports (color:color-mix(in lab, red, red)){.range::-moz-range-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range:disabled{cursor:not-allowed;opacity:.3}.chat-bubble{border-radius:var(--radius-field);background-color:var(--color-base-300);width:fit-content;color:var(--color-base-content);grid-row-end:3;min-width:2.5rem;max-width:90%;min-height:2rem;padding-block:.5rem;padding-inline:1rem;display:block;position:relative}.chat-bubble:before{background-color:inherit;content:"";width:.75rem;height:.75rem;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-image:var(--mask-chat);-webkit-mask-image:var(--mask-chat);mask-image:var(--mask-chat);position:absolute;bottom:0;-webkit-mask-position:0 -1px;mask-position:0 -1px;-webkit-mask-size:.8125rem;mask-size:.8125rem}.select{border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);height:var(--size);touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;font-size:.875rem;display:inline-flex;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.select{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.select{border-color:var(--input-color);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.select{--size:calc(var(--size-field,.25rem) * 10)}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}[dir=rtl] .select::picker(select){translate:.5rem}[dir=rtl] .select select::picker(select){translate:.5rem}.select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem}.select select:focus,.select select:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.select select:focus,.select select:focus-within{outline-offset:2px;outline:2px solid #0000}}.select select:not(:last-child){background-image:none;margin-inline-end:-1.375rem}.select:focus,.select:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.select:focus,.select:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.select:focus,.select:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.select:has(>select[disabled])>select[disabled]{cursor:not-allowed}@supports (appearance:base-select){.select,.select select{appearance:base-select}:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) optgroup{padding-top:.5em}:is(.select,.select select) optgroup option:first-child{margin-top:.5em}:is(.select,.select select) option{border-radius:var(--radius-field);white-space:normal;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{outline-offset:2px;outline:2px solid #0000}}:is(.select,.select select) option:not(:disabled):active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}.timeline{display:flex;position:relative}.timeline>li{grid-template-rows:var(--timeline-row-start,minmax(0, 1fr)) auto var(--timeline-row-end,minmax(0, 1fr));grid-template-columns:var(--timeline-col-start,minmax(0, 1fr)) auto var(--timeline-col-end,minmax(0, 1fr));flex-shrink:0;align-items:center;display:grid;position:relative}.timeline>li>hr{border:none;width:100%}.timeline>li>hr:first-child{grid-row-start:2;grid-column-start:1}.timeline>li>hr:last-child{grid-area:2/3/auto/none}@media print{.timeline>li>hr{border:.1px solid var(--color-base-300)}}.timeline :where(hr){background-color:var(--color-base-300);height:.25rem}.timeline:has(.timeline-middle hr):first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.timeline:has(.timeline-middle hr):last-child,.timeline:not(:has(.timeline-middle)) :first-child hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.timeline:not(:has(.timeline-middle)) :last-child hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.swap{cursor:pointer;vertical-align:middle;webkit-user-select:none;-webkit-user-select:none;user-select:none;place-content:center;display:inline-grid;position:relative}.swap input{appearance:none;border:none}.swap>*{grid-row-start:1;grid-column-start:1}@media (prefers-reduced-motion:no-preference){.swap>*{transition-property:transform,rotate,opacity;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.swap .swap-on,.swap .swap-indeterminate,.swap input:indeterminate~.swap-on,.swap input:is(:checked,:indeterminate)~.swap-off{opacity:0}.swap input:checked~.swap-on,.swap input:indeterminate~.swap-indeterminate{opacity:1;backface-visibility:visible}.collapse-title{grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out;position:relative}.checkbox{border:var(--border) solid var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox{cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1))}:is(.checkbox:checked,.checkbox[aria-checked=true]):before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1}@media (forced-colors:active){:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}@media print{:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}.checkbox:indeterminate{background-color:var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox:indeterminate{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox:indeterminate:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -35%;rotate:0deg}.radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}@supports (color:color-mix(in lab, red, red)){.radio{border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000))}}.radio{box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor)}.radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.radio:focus-visible{outline:2px solid}.radio:checked,.radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor}@media (prefers-reduced-motion:no-preference){.radio:checked,.radio[aria-checked=true]{animation:.2s ease-out radio}}:is(.radio:checked,.radio[aria-checked=true]):before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){:is(.radio:checked,.radio[aria-checked=true]):before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{:is(.radio:checked,.radio[aria-checked=true]):before{outline-offset:-1rem;outline:.25rem solid}}.drawer{grid-auto-columns:max-content auto;width:100%;display:grid;position:relative}.card{border-radius:var(--radius-box);outline-offset:2px;outline:0 solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.card:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.card:focus{outline-offset:2px;outline:2px solid #0000}}.card:focus-visible{outline-color:currentColor}.card :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.card :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.card figure{justify-content:center;align-items:center;display:flex}.card:has(>input:is(input[type=checkbox],input[type=radio])){cursor:pointer;-webkit-user-select:none;user-select:none}.card:has(>:checked){outline:2px solid}.stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.progress{appearance:none;border-radius:var(--radius-box);background-color:currentColor;width:100%;height:.5rem;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.progress{background-color:color-mix(in oklab, currentcolor 20%, transparent)}}.progress{color:var(--color-base-content)}.progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%}@media (prefers-reduced-motion:no-preference){.progress:indeterminate{animation:5s ease-in-out infinite progress}}@supports ((-moz-appearance:none)){.progress:indeterminate::-moz-progress-bar{background-color:#0000}@media (prefers-reduced-motion:no-preference){.progress:indeterminate::-moz-progress-bar{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}.progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor}}@supports ((-webkit-appearance:none)){.progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor}}.hero-content{isolation:isolate;justify-content:center;align-items:center;gap:1rem;max-width:80rem;padding:1rem;display:flex}.textarea{border:var(--border) solid #0000;appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,.875rem), .875rem);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}@supports (color:color-mix(in lab, red, red)){.textarea{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.textarea{--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.textarea textarea{appearance:none;background-color:#0000;border:none}.textarea textarea:focus,.textarea textarea:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.textarea textarea:focus,.textarea textarea:focus-within{outline-offset:2px;outline:2px solid #0000}}.textarea:focus,.textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.textarea:focus,.textarea:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.textarea:focus,.textarea:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.textarea:focus,.textarea:focus-within{--font-size:1rem}}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){box-shadow:none}.textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.stack{grid-template-rows:3px 4px 1fr 4px 3px;grid-template-columns:3px 4px 1fr 4px 3px;display:inline-grid}.stack>*{width:100%;height:100%}.stack>:nth-child(n+2){opacity:.7;width:100%}.stack>:nth-child(2){z-index:2;opacity:.9}.stack>:first-child{z-index:3;width:100%}.modal-backdrop{color:#0000;z-index:-1;grid-row-start:1;grid-column-start:1;place-self:stretch stretch;display:grid}.modal-backdrop button{cursor:pointer}.hero{background-position:50%;background-size:cover;place-items:center;width:100%;display:grid}.hero>*{grid-row-start:1;grid-column-start:1}.modal-box{background-color:var(--color-base-100);border-top-left-radius:var(--modal-tl,var(--radius-box));border-top-right-radius:var(--modal-tr,var(--radius-box));border-bottom-left-radius:var(--modal-bl,var(--radius-box));border-bottom-right-radius:var(--modal-br,var(--radius-box));opacity:0;overscroll-behavior:contain;grid-row-start:1;grid-column-start:1;width:91.6667%;max-width:32rem;max-height:100vh;padding:1.5rem;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out;overflow-y:auto;scale:.95;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)}.drawer-content{grid-row-start:1;grid-column-start:2;min-width:0}.divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:var(--color-base-content);flex-direction:row;align-self:stretch;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.divider{--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.divider:before,.divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.divider:before,.divider:after{border:.5px solid}}.divider:not(:empty){gap:1rem}.filter{flex-wrap:wrap;display:flex}.filter input[type=radio]{width:auto}.filter input{opacity:1;transition:margin .1s,opacity .3s,padding .3s,border-width .1s;overflow:hidden;scale:1}.filter input:not(:last-child){margin-inline-end:.25rem}.filter input.filter-reset{aspect-ratio:1}.filter input.filter-reset:after{--tw-content:"×";content:var(--tw-content)}.filter:not(:has(input:checked:not(.filter-reset))) .filter-reset,.filter:not(:has(input:checked:not(.filter-reset))) input[type=reset],.filter:has(input:checked:not(.filter-reset)) input:not(:checked,.filter-reset,input[type=reset]){opacity:0;border-width:0;width:0;margin-inline:0;padding-inline:0;scale:0}.label{white-space:nowrap;color:currentColor;align-items:center;gap:.375rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.label{color:color-mix(in oklab, currentcolor 60%, transparent)}}.label:has(input){cursor:pointer}.label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex}.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid currentColor;margin-inline:-.75rem .75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid currentColor;margin-inline:.75rem -.75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.modal-action{justify-content:flex-end;gap:.5rem;margin-top:1.5rem;display:flex}.fieldset-legend{color:var(--color-base-content);justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:-.25rem;padding-block:.5rem;font-weight:600;display:flex}.status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:var(--color-base-content);width:.5rem;height:.5rem;display:inline-block}@supports (color:color-mix(in lab, red, red)){.status{background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.status{vertical-align:middle;color:#0000004d;background-position:50%;background-repeat:no-repeat}@supports (color:color-mix(in lab, red, red)){.status{color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.status{background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px}@supports (color:color-mix(in lab, red, red)){.status{box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000)}}.badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}.kbd{border-radius:var(--radius-field);background-color:var(--color-base-200);vertical-align:middle;border:var(--border) solid var(--color-base-content);justify-content:center;align-items:center;padding-inline:.5em;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.kbd{border:var(--border) solid color-mix(in srgb, var(--color-base-content) 20%, #0000)}}.kbd{border-bottom:calc(var(--border) + 1px) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.kbd{border-bottom:calc(var(--border) + 1px) solid color-mix(in srgb, var(--color-base-content) 20%, #0000)}}.kbd{--size:calc(var(--size-selector,.25rem) * 6);height:var(--size);min-width:var(--size);font-size:.875rem}.tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem) * 10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.footer>*{place-items:start;gap:.5rem;display:grid}.footer.footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.footer.footer-center>*{place-items:center}.stat{grid-template-columns:repeat(1,1fr);column-gap:1rem;width:100%;padding-block:1rem;padding-inline:1.5rem;display:inline-grid}.stat:not(:last-child){border-inline-end:var(--border) dashed currentColor}@supports (color:color-mix(in lab, red, red)){.stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000)}}.stat:not(:last-child){border-block-end:none}.alert{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px #000, 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid}@supports (color:color-mix(in lab, red, red)){.alert{box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08))}}.alert:has(:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}.fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");grid-auto-rows:min-content;column-gap:.75rem;padding-block:.25rem;display:grid}.mask{vertical-align:middle;display:inline-block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.mask\!{vertical-align:middle!important;display:inline-block!important;-webkit-mask-position:50%!important;mask-position:50%!important;-webkit-mask-size:contain!important;mask-size:contain!important;-webkit-mask-repeat:no-repeat!important;mask-repeat:no-repeat!important}.skeleton{border-radius:var(--radius-box);background-color:var(--color-base-300)}@media (prefers-reduced-motion:reduce){.skeleton{transition-duration:15s}}.skeleton{will-change:background-position;background-image:linear-gradient(105deg, #0000 0% 40%, var(--color-base-100) 50%, #0000 60% 100%);background-position-x:-50%;background-size:200%}@media (prefers-reduced-motion:no-preference){.skeleton{animation:1.8s ease-in-out infinite skeleton}}.link{cursor:pointer;text-decoration-line:underline}.link:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.link:focus{outline-offset:2px;outline:2px solid #0000}}.link:focus-visible{outline-offset:2px;outline:2px solid}.menu-title{color:var(--color-base-content);padding-block:.5rem;padding-inline:.75rem}@supports (color:color-mix(in lab, red, red)){.menu-title{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}.menu-title{font-size:.875rem;font-weight:600}.btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content)}.btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.btn-success{--btn-color:var(--color-success);--btn-fg:var(--color-success-content)}.btn-warning{--btn-color:var(--color-warning);--btn-fg:var(--color-warning-content)}}.\@container{container-type:inline-size}@layer daisyui.l1.l2{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4)}:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal) .modal-box{opacity:1;translate:0;scale:1}:root:has(:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal)){--page-has-backdrop:1;--page-overflow:hidden;--page-scroll-bg:var(--page-scroll-bg-on);--page-scroll-gutter:stable;--page-scroll-transition:var(--page-scroll-transition-on);animation:forwards set-page-has-scroll;animation-timeline:scroll()}@starting-style{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{opacity:0}}:where(.drawer-toggle:checked~.drawer-side){pointer-events:auto;visibility:visible;opacity:1;overflow-y:auto}:where(.drawer-toggle:checked~.drawer-side)>:not(.drawer-overlay){translate:0%}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline:2px solid}.tooltip>.tooltip-content,.tooltip[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.tooltip:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.collapse-arrow>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;transform:translateY(-100%)rotate(45deg)}@media (prefers-reduced-motion:no-preference){.collapse-arrow>.collapse-title:after{transition-property:all;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}}.collapse-arrow>.collapse-title:after{content:"";transform-origin:75% 75%;pointer-events:none;top:50%;inset-inline-end:1.4rem;box-shadow:2px 2px}.collapse-plus>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.collapse-plus>.collapse-title:after{transition-property:all;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1)}}.collapse-plus>.collapse-title:after{--tw-content:"+";content:var(--tw-content);pointer-events:none;top:.9rem;inset-inline-end:1.4rem}.btn:disabled:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn:disabled:not(.btn-link,.btn-ghost){box-shadow:none}.btn:disabled{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}.btn[disabled]:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn[disabled]:not(.btn-link,.btn-ghost){box-shadow:none}.btn[disabled]{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}@media (prefers-reduced-motion:no-preference){.collapse[open].collapse-arrow>.collapse-title:after,.collapse.collapse-open.collapse-arrow>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}.collapse.collapse-open.collapse-plus>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}:is(.collapse[tabindex].collapse-arrow:focus:not(.collapse-close),.collapse.collapse-arrow[tabindex]:focus-within:not(.collapse-close))>.collapse-title:after,.collapse.collapse-arrow:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}.collapse[open].collapse-plus>.collapse-title:after,.collapse[tabindex].collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse.collapse-plus:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}.collapse-open{grid-template-rows:max-content 1fr}.collapse-open>.collapse-content{content-visibility:visible;min-height:fit-content;padding-bottom:1rem}@supports not (content-visibility:visible){.collapse-open>.collapse-content{visibility:visible}}.tabs-lift{--tabs-height:auto;--tabs-direction:row}.tabs-lift>.tab{--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;border-width:var(--tab-border);padding:var(--tab-paddings);border-color:var(--tab-border-colors);border-start-start-radius:var(--tab-radius-ss);border-start-end-radius:var(--tab-radius-se);border-end-end-radius:var(--tab-radius-ee);border-end-start-radius:var(--tab-radius-es)}.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad));background-color:var(--tab-bg)}:is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):before{z-index:1;content:"";width:var(--tab-corner-width);height:var(--tab-corner-height);background-position:var(--tab-corner-position);background-image:var(--radius-start), var(--radius-end);background-size:var(--tab-radius-limit) var(--tab-radius-limit);inset:var(--tab-inset);background-repeat:no-repeat;display:block;position:absolute}:is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):first-child:before{--radius-start:none}[dir=rtl] :is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):first-child:before{transform:rotateY(180deg)}:is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):last-child:before{--radius-end:none}[dir=rtl] :is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):last-child:before{transform:rotateY(180deg)}.tabs-lift:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.tabs-lift>.tab-content{--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.list .list-row:has(.list-col-grow:first-child){--list-grid-cols:1fr}.list .list-row:has(.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row>*{grid-row-start:1}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after,.steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.steps .step-primary+.step-primary:before,.steps .step-primary:after,.steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after,.steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.steps .step-accent+.step-accent:before,.steps .step-accent:after,.steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.steps .step-info+.step-info:before,.steps .step-info:after,.steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.steps .step-success+.step-success:before,.steps .step-success:after,.steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.steps .step-warning+.step-warning:before,.steps .step-warning:after,.steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.steps .step-error+.step-error:before,.steps .step-error:after,.steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.tabs-border>.tab{--tab-border-color:#0000 #0000 var(--tab-border-color) #0000;border-radius:var(--radius-field);position:relative}.tabs-border>.tab:before{content:"";background-color:var(--tab-border-color);border-radius:var(--radius-field);width:80%;height:3px;transition:background-color .2s;position:absolute;bottom:0;left:10%}:is(.tabs-border>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-border>.tab:is(input:checked),.tabs-border>.tab:is(label:has(:checked))):before{--tab-border-color:currentColor;border-top:3px solid}.checkbox:disabled,.radio:disabled{cursor:not-allowed;opacity:.2}.tooltip-bottom>.tooltip-content,.tooltip-bottom[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,-.25rem));inset:var(--tt-off) auto auto 50%}.tooltip-bottom:after{transform:translateX(-50%) translateY(var(--tt-pos,-.25rem)) rotate(180deg);inset:var(--tt-tail) auto auto 50%}.tooltip-left>.tooltip-content,.tooltip-left[data-tip]:before{transform:translateX(calc(var(--tt-pos,.25rem) - .25rem)) translateY(-50%);inset:50% var(--tt-off) auto auto}.tooltip-left:after{transform:translateX(var(--tt-pos,.25rem)) translateY(-50%) rotate(-90deg);inset:50% calc(var(--tt-tail) + 1px) auto auto}.tooltip-right>.tooltip-content,.tooltip-right[data-tip]:before{transform:translateX(calc(var(--tt-pos,-.25rem) + .25rem)) translateY(-50%);inset:50% auto auto var(--tt-off)}.tooltip-right:after{transform:translateX(var(--tt-pos,-.25rem)) translateY(-50%) rotate(90deg);inset:50% auto auto calc(var(--tt-tail) + 1px)}.tooltip-top>.tooltip-content,.tooltip-top[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.tooltip-top:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.toast-end{--toast-x:0;inset-inline:auto 1rem}.toast-top{--toast-y:0;top:1rem;bottom:auto}.btn-active{--btn-bg:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn-active{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}.btn-active{--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0);isolation:isolate}:is(.stack,.stack.stack-bottom)>*{grid-area:3/3/6/4}:is(.stack,.stack.stack-bottom)>:nth-child(2){grid-area:2/2/5/5}:is(.stack,.stack.stack-bottom)>:first-child{grid-area:1/1/4/6}.stack.stack-top>*{grid-area:1/3/4/4}.stack.stack-top>:nth-child(2){grid-area:2/2/5/5}.stack.stack-top>:first-child{grid-area:3/1/6/6}.stack.stack-start>*{grid-area:3/1/4/4}.stack.stack-start>:nth-child(2){grid-area:2/2/5/5}.stack.stack-start>:first-child{grid-area:1/3/6/6}.stack.stack-end>*{grid-area:3/3/4/6}.stack.stack-end>:nth-child(2){grid-area:2/2/5/5}.stack.stack-end>:first-child{grid-area:1/1/6/4}.drawer-end{grid-auto-columns:auto max-content}.drawer-end>.drawer-toggle~.drawer-content{grid-column-start:1}.drawer-end>.drawer-toggle~.drawer-side{grid-column-start:2;justify-items:end}.drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:100%}[dir=rtl] :is(.drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay)){translate:-100%}.drawer-end>.drawer-toggle:checked~.drawer-side>:not(.drawer-overlay){translate:0%}.input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:max(var(--font-size,.75rem), .75rem)}.input-sm[type=number]::-webkit-inner-spin-button{margin-block:-.5rem;margin-inline-end:-.75rem}.input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:max(var(--font-size,.6875rem), .6875rem)}.input-xs[type=number]::-webkit-inner-spin-button{margin-block:-.25rem;margin-inline-end:-.75rem}.btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}.btn-square{width:var(--size);height:var(--size);padding-inline:0}.loading-lg{width:calc(var(--size-selector,.25rem) * 7)}.loading-xs{width:calc(var(--size-selector,.25rem) * 4)}.swap-rotate .swap-on,.swap-rotate input:indeterminate~.swap-on{rotate:45deg}.swap-rotate input:is(:checked,:indeterminate)~.swap-on,.swap-rotate.swap-active .swap-on{rotate:0deg}.swap-rotate input:is(:checked,:indeterminate)~.swap-off,.swap-rotate.swap-active .swap-off{rotate:-45deg}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.625rem;font-size:.75rem}.menu-sm .menu-title{padding-block:.5rem;padding-inline:.75rem}.badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.badge-soft{color:var(--badge-color,var(--color-base-content));background-color:var(--badge-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.badge-soft{background-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 8%, var(--color-base-100))}}.badge-soft{border-color:var(--badge-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.badge-soft{border-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 10%, var(--color-base-100))}}.badge-soft{background-image:none}.select-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.select-ghost:focus,.select-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}:where(:not(ul,details,.menu-title,.btn)).menu-active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:where(:not(ul,details,.menu-title,.btn)).menu-active{outline-offset:2px;outline:2px solid #0000}}:where(:not(ul,details,.menu-title,.btn)).menu-active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}.skeleton-text{webkit-background-clip:text;color:#0000;-webkit-background-clip:text;background-clip:text;background-image:linear-gradient(105deg, var(--color-base-content) 0% 40%, var(--color-base-content) 50%, var(--color-base-content) 60% 100%)}@supports (color:color-mix(in lab, red, red)){.skeleton-text{background-image:linear-gradient(105deg, color-mix(in oklab, var(--color-base-content) 20%, transparent) 0% 40%, var(--color-base-content) 50%, color-mix(in oklab, var(--color-base-content) 20%, transparent) 60% 100%)}}.loading-ring{-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='44' height='44' viewBox='0 0 44 44' xmlns='http://www.w3.org/2000/svg' stroke='white'%3E%3Cg fill='none' fill-rule='evenodd' stroke-width='2'%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='0s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='0s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='-0.9s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='-0.9s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg width='44' height='44' viewBox='0 0 44 44' xmlns='http://www.w3.org/2000/svg' stroke='white'%3E%3Cg fill='none' fill-rule='evenodd' stroke-width='2'%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='0s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='0s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='-0.9s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='-0.9s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")}.loading-spinner{-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")}.checkbox-sm{--size:calc(var(--size-selector,.25rem) * 5);padding:.1875rem}.radio-sm{padding:.1875rem}.radio-sm[type=radio]{--size:calc(var(--size-selector,.25rem) * 5)}.select-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem}.select-sm option{padding-block:.25rem;padding-inline:.625rem}.select-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem}.select-xs option{padding-block:.25rem;padding-inline:.5rem}.table-sm :not(thead,tfoot) tr{font-size:.75rem}.table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.badge-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.badge-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.kbd-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.textarea-sm{font-size:max(var(--font-size,.75rem), .75rem)}.alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.alert-info{color:var(--color-info-content);--alert-border-color:var(--color-info);--alert-color:var(--color-info)}.alert-success{color:var(--color-success-content);--alert-border-color:var(--color-success);--alert-color:var(--color-success)}.alert-warning{color:var(--color-warning-content);--alert-border-color:var(--color-warning);--alert-color:var(--color-warning)}.checkbox-accent{color:var(--color-accent-content);--input-color:var(--color-accent)}.checkbox-primary{color:var(--color-primary-content);--input-color:var(--color-primary)}.tooltip-accent{--tt-bg:var(--color-accent)}.tooltip-accent>.tooltip-content,.tooltip-accent[data-tip]:before{color:var(--color-accent-content)}.tooltip-error{--tt-bg:var(--color-error)}.tooltip-error>.tooltip-content,.tooltip-error[data-tip]:before{color:var(--color-error-content)}.tooltip-info{--tt-bg:var(--color-info)}.tooltip-info>.tooltip-content,.tooltip-info[data-tip]:before{color:var(--color-info-content)}.tooltip-primary{--tt-bg:var(--color-primary)}.tooltip-primary>.tooltip-content,.tooltip-primary[data-tip]:before{color:var(--color-primary-content)}.tooltip-secondary{--tt-bg:var(--color-secondary)}.tooltip-secondary>.tooltip-content,.tooltip-secondary[data-tip]:before{color:var(--color-secondary-content)}.tooltip-success{--tt-bg:var(--color-success)}.tooltip-success>.tooltip-content,.tooltip-success[data-tip]:before{color:var(--color-success-content)}.tooltip-warning{--tt-bg:var(--color-warning)}.tooltip-warning>.tooltip-content,.tooltip-warning[data-tip]:before{color:var(--color-warning-content)}.btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.badge-primary{--badge-color:var(--color-primary);--badge-fg:var(--color-primary-content)}.badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.radio-error{--input-color:var(--color-error)}.toggle-primary:checked,.toggle-primary[aria-checked=true]{--input-color:var(--color-primary)}}.pointer-events-none{pointer-events:none}.countdown.countdown{line-height:1em}.collapse:not(td,tr,colgroup){visibility:revert-layer}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:calc(var(--spacing) * 0)}.top-2{top:calc(var(--spacing) * 2)}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:calc(var(--spacing) * 0)}.right-1{right:calc(var(--spacing) * 1)}.bottom-full{bottom:100%}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;align-items:stretch;display:inline-flex}.join :where(.join-item){border-start-start-radius:var(--join-ss,0);border-start-end-radius:var(--join-se,0);border-end-end-radius:var(--join-ee,0);border-end-start-radius:var(--join-es,0)}.join :where(.join-item) *{--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}.join>.join-item:where(:first-child),.join :first-child:not(:last-child) :where(.join-item){--join-ss:var(--radius-field);--join-se:0;--join-es:var(--radius-field);--join-ee:0}.join>.join-item:where(:last-child),.join :last-child:not(:first-child) :where(.join-item){--join-ss:0;--join-se:var(--radius-field);--join-es:0;--join-ee:var(--radius-field)}.join>.join-item:where(:only-child),.join :only-child :where(.join-item){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}.join>:where(:focus,:has(:focus)){z-index:1}@media (hover:hover){.join>:where(.btn:hover,:has(.btn:hover)){isolation:isolate}}.isolate{isolation:isolate}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-60{z-index:60}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.m-0{margin:calc(var(--spacing) * 0)}.m-1{margin:calc(var(--spacing) * 1)}.m-2{margin:calc(var(--spacing) * 2)}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.my-4{margin-block:calc(var(--spacing) * 4)}.join-item:where(:not(:first-child,:disabled,[disabled],.btn-disabled)){margin-block-start:0;margin-inline-start:calc(var(--border,1px) * -1)}.join-item:where(:is(:disabled,[disabled],.btn-disabled)){border-width:var(--border,1px) 0 var(--border,1px) var(--border,1px)}.-ms-px{margin-inline-start:-1px}.-mt-2{margin-top:calc(var(--spacing) * -2)}.mt-0{margin-top:calc(var(--spacing) * 0)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-7{margin-left:calc(var(--spacing) * 7)}.ml-auto{margin-left:auto}.kbd{box-shadow:none}.alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200))}:root .prose{--tw-prose-body:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-body:color-mix(in oklab, var(--color-base-content) 80%, #0000)}}:root .prose{--tw-prose-headings:var(--color-base-content);--tw-prose-lead:var(--color-base-content);--tw-prose-links:var(--color-base-content);--tw-prose-bold:var(--color-base-content);--tw-prose-counters:var(--color-base-content);--tw-prose-bullets:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-bullets:color-mix(in oklab, var(--color-base-content) 50%, #0000)}}:root .prose{--tw-prose-hr:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-hr:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}:root .prose{--tw-prose-quotes:var(--color-base-content);--tw-prose-quote-borders:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-quote-borders:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}:root .prose{--tw-prose-captions:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-captions:color-mix(in oklab, var(--color-base-content) 50%, #0000)}}:root .prose{--tw-prose-code:var(--color-base-content);--tw-prose-pre-code:var(--color-neutral-content);--tw-prose-pre-bg:var(--color-neutral);--tw-prose-th-borders:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-th-borders:color-mix(in oklab, var(--color-base-content) 50%, #0000)}}:root .prose{--tw-prose-td-borders:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-td-borders:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}:root .prose{--tw-prose-kbd:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-kbd:color-mix(in oklab, var(--color-base-content) 80%, #0000)}}:root .prose :where(code):not(pre>code){background-color:var(--color-base-200);border-radius:var(--radius-selector);border:var(--border) solid var(--color-base-300);font-weight:inherit;padding-block:.2em;padding-inline:.5em}:root .prose :where(code):not(pre>code):before,:root .prose :where(code):not(pre>code):after{display:none}.block{display:block}.block\!{display:block!important}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.h-0{height:calc(var(--spacing) * 0)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-24{height:calc(var(--spacing) * 24)}.h-32{height:calc(var(--spacing) * 32)}.h-48{height:calc(var(--spacing) * 48)}.h-\[70vh\]{height:70vh}.h-full{height:100%}.h-screen{height:100vh}.max-h-0{max-height:calc(var(--spacing) * 0)}.max-h-24{max-height:calc(var(--spacing) * 24)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-\[32rem\]{max-height:32rem}.max-h-\[60vh\]{max-height:60vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[150px\]{max-height:150px}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-\[80px\]{min-height:80px}.min-h-screen{min-height:100vh}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-44{width:calc(var(--spacing) * 44)}.w-52{width:calc(var(--spacing) * 52)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-\[70vw\]{width:70vw}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-48{max-width:calc(var(--spacing) * 48)}.max-w-\[80px\]{max-width:80px}.max-w-\[85\%\]{max-width:85%}.max-w-\[95\%\]{max-width:95%}.max-w-\[95vw\]{max-width:95vw}.max-w-\[150px\]{max-width:150px}.max-w-\[200px\]{max-width:200px}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-\[140px\]{min-width:140px}.flex-1{flex:1}.flex-auto{flex:auto}.flex-shrink,.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-ns-resize{cursor:ns-resize}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.cursor-wait{cursor:wait}.touch-none{touch-action:none}.resize{resize:both}.resize\!{resize:both!important}.resize-none{resize:none}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:calc(var(--spacing) * 0)}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-700\/50>:not(:last-child)){border-color:#31415880}@supports (color:color-mix(in lab, red, red)){:where(.divide-slate-700\/50>:not(:last-child)){border-color:color-mix(in oklab, var(--color-slate-700) 50%, transparent)}}.self-center{align-self:center}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-box{border-radius:var(--radius-box);border-radius:var(--radius-box)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-sm{border-bottom-right-radius:var(--radius-sm)}.rounded-bl-sm{border-bottom-left-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-0\!{border-style:var(--tw-border-style)!important;border-width:0!important}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500\/50{border-color:#f99c0080}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/50{border-color:color-mix(in oklab, var(--color-amber-500) 50%, transparent)}}.border-amber-700\/50{border-color:#b7500080}@supports (color:color-mix(in lab, red, red)){.border-amber-700\/50{border-color:color-mix(in oklab, var(--color-amber-700) 50%, transparent)}}.border-base-300\/50{border-color:var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.border-base-300\/50{border-color:color-mix(in oklab, var(--color-base-300) 50%, transparent)}}.border-error,.border-error\/40{border-color:var(--color-error)}@supports (color:color-mix(in lab, red, red)){.border-error\/40{border-color:color-mix(in oklab, var(--color-error) 40%, transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-mitto-accent{border-color:var(--color-mitto-accent)}.border-mitto-accent-500\/50{border-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.border-mitto-accent-500\/50{border-color:color-mix(in oklab, var(--color-mitto-accent-500) 50%, transparent)}}.border-mitto-accent-500\/60{border-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.border-mitto-accent-500\/60{border-color:color-mix(in oklab, var(--color-mitto-accent-500) 60%, transparent)}}.border-mitto-accent-600{border-color:var(--color-mitto-accent-600)}.border-mitto-border{border-color:var(--color-mitto-border)}.border-mitto-border-1,.border-mitto-border-1\/50{border-color:var(--color-mitto-border-1)}@supports (color:color-mix(in lab, red, red)){.border-mitto-border-1\/50{border-color:color-mix(in oklab, var(--color-mitto-border-1) 50%, transparent)}}.border-mitto-border-2,.border-mitto-border-2\/30{border-color:var(--color-mitto-border-2)}@supports (color:color-mix(in lab, red, red)){.border-mitto-border-2\/30{border-color:color-mix(in oklab, var(--color-mitto-border-2) 30%, transparent)}}.border-mitto-border-2\/40{border-color:var(--color-mitto-border-2)}@supports (color:color-mix(in lab, red, red)){.border-mitto-border-2\/40{border-color:color-mix(in oklab, var(--color-mitto-border-2) 40%, transparent)}}.border-mitto-border-2\/50{border-color:var(--color-mitto-border-2)}@supports (color:color-mix(in lab, red, red)){.border-mitto-border-2\/50{border-color:color-mix(in oklab, var(--color-mitto-border-2) 50%, transparent)}}.border-mitto-border-3{border-color:var(--color-mitto-border-3)}.border-mitto-user-border{border-color:var(--color-mitto-user-border)}.border-purple-500\/30{border-color:#ac4bff4d}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/30{border-color:color-mix(in oklab, var(--color-purple-500) 30%, transparent)}}.border-red-500{border-color:var(--color-red-500)}.border-red-700{border-color:var(--color-red-700)}.border-l-amber-500{border-left-color:var(--color-amber-500)}.border-l-amber-500\/70{border-left-color:#f99c00b3}@supports (color:color-mix(in lab, red, red)){.border-l-amber-500\/70{border-left-color:color-mix(in oklab, var(--color-amber-500) 70%, transparent)}}.border-l-mitto-accent-500\/70{border-left-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.border-l-mitto-accent-500\/70{border-left-color:color-mix(in oklab, var(--color-mitto-accent-500) 70%, transparent)}}.border-l-purple-500{border-left-color:var(--color-purple-500)}.glass{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:#0000;background-image:linear-gradient(135deg, oklch(100% 0 0 / var(--glass-opacity,30%)) 0%, oklch(0% 0 0/0) 100%), linear-gradient(var(--glass-reflect-degree,100deg), oklch(100% 0 0 / var(--glass-reflect-opacity,5%)) 25%, oklch(0% 0 0/0) 25%);box-shadow:0 0 0 1px oklch(100% 0 0 / var(--glass-border-opacity,20%)) inset, 0 0 0 2px oklch(0% 0 0/.05);text-shadow:0 1px oklch(0% 0 0 / var(--glass-text-shadow-opacity,5%));border:none}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-600{background-color:var(--color-amber-600)}.bg-amber-700{background-color:var(--color-amber-700)}.bg-base-200,.bg-base-200\/95{background-color:var(--color-base-200)}@supports (color:color-mix(in lab, red, red)){.bg-base-200\/95{background-color:color-mix(in oklab, var(--color-base-200) 95%, transparent)}}.bg-base-300\/50{background-color:var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.bg-base-300\/50{background-color:color-mix(in oklab, var(--color-base-300) 50%, transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-cyan-800{background-color:var(--color-cyan-800)}.bg-error{background-color:var(--color-error)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-green-400{background-color:var(--color-green-400)}.bg-green-500\/20{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/20{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.bg-green-600\/80{background-color:#00a544cc}@supports (color:color-mix(in lab, red, red)){.bg-green-600\/80{background-color:color-mix(in oklab, var(--color-green-600) 80%, transparent)}}.bg-green-700{background-color:var(--color-green-700)}.bg-green-900{background-color:var(--color-green-900)}.bg-mitto-accent{background-color:var(--color-mitto-accent)}.bg-mitto-accent-400{background-color:var(--color-mitto-accent-400)}.bg-mitto-accent-500\/10{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-500\/10{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 10%, transparent)}}.bg-mitto-accent-500\/20{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-500\/20{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 20%, transparent)}}.bg-mitto-accent-500\/40{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-500\/40{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 40%, transparent)}}.bg-mitto-accent-600,.bg-mitto-accent-600\/10{background-color:var(--color-mitto-accent-600)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-600\/10{background-color:color-mix(in oklab, var(--color-mitto-accent-600) 10%, transparent)}}.bg-mitto-accent-600\/80{background-color:var(--color-mitto-accent-600)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-600\/80{background-color:color-mix(in oklab, var(--color-mitto-accent-600) 80%, transparent)}}.bg-mitto-accent-900{background-color:var(--color-mitto-accent-900)}.bg-mitto-accent-fg{background-color:var(--color-mitto-accent-fg)}.bg-mitto-agent{background-color:var(--color-mitto-agent)}.bg-mitto-bg{background-color:var(--color-mitto-bg)}.bg-mitto-border{background-color:var(--color-mitto-border)}.bg-mitto-danger{background-color:var(--color-mitto-danger)}.bg-mitto-input{background-color:var(--color-mitto-input)}.bg-mitto-input-box{background-color:var(--color-mitto-input-box)}.bg-mitto-sidebar{background-color:var(--color-mitto-sidebar)}.bg-mitto-success{background-color:var(--color-mitto-success)}.bg-mitto-surface-2,.bg-mitto-surface-2\/50{background-color:var(--color-mitto-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-2\/50{background-color:color-mix(in oklab, var(--color-mitto-surface-2) 50%, transparent)}}.bg-mitto-surface-3,.bg-mitto-surface-3\/20{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-3\/20{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 20%, transparent)}}.bg-mitto-surface-3\/30{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-3\/30{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 30%, transparent)}}.bg-mitto-surface-3\/50{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-3\/50{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 50%, transparent)}}.bg-mitto-surface-3\/95{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-3\/95{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 95%, transparent)}}.bg-mitto-surface-4,.bg-mitto-surface-4\/30{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-4\/30{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 30%, transparent)}}.bg-mitto-surface-4\/50{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-4\/50{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 50%, transparent)}}.bg-mitto-surface-4\/80{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-4\/80{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 80%, transparent)}}.bg-mitto-surface-hover{background-color:var(--color-mitto-surface-hover)}.bg-mitto-user{background-color:var(--color-mitto-user)}.bg-orange-500\/20{background-color:#fe6e0033}@supports (color:color-mix(in lab, red, red)){.bg-orange-500\/20{background-color:color-mix(in oklab, var(--color-orange-500) 20%, transparent)}}.bg-primary{background-color:var(--color-primary)}.bg-purple-500\/20{background-color:#ac4bff33}@supports (color:color-mix(in lab, red, red)){.bg-purple-500\/20{background-color:color-mix(in oklab, var(--color-purple-500) 20%, transparent)}}.bg-purple-600\/80{background-color:#9810facc}@supports (color:color-mix(in lab, red, red)){.bg-purple-600\/80{background-color:color-mix(in oklab, var(--color-purple-600) 80%, transparent)}}.bg-purple-700{background-color:var(--color-purple-700)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-500\/20{background-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/20{background-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.bg-red-600{background-color:var(--color-red-600)}.bg-red-700{background-color:var(--color-red-700)}.bg-red-900\/50{background-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/50{background-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.bg-secondary{background-color:var(--color-secondary)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-500{background-color:var(--color-slate-500)}.bg-success{background-color:var(--color-success)}.bg-transparent{background-color:#0000}.bg-warning{background-color:var(--color-warning)}.bg-white{background-color:var(--color-white)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-yellow-500\/20{background-color:#edb20033}@supports (color:color-mix(in lab, red, red)){.bg-yellow-500\/20{background-color:color-mix(in oklab, var(--color-yellow-500) 20%, transparent)}}@layer daisyui.l1{.alert-soft{color:var(--alert-color,var(--color-base-content));background:var(--alert-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.alert-soft{background:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 8%, var(--color-base-100))}}.alert-soft{--alert-border-color:var(--alert-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.alert-soft{--alert-border-color:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 10%, var(--color-base-100))}}.alert-soft{box-shadow:none;background-image:none}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)){--btn-shadow:"";--btn-bg:#0000;--btn-border:#0000;--btn-noise:none}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)):not(:disabled,[disabled],.btn-disabled){--btn-fg:var(--btn-color,currentColor);outline-color:currentColor}@media (hover:none){.btn-ghost:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:"";--btn-bg:#0000;--btn-fg:var(--btn-color,currentColor);--btn-border:#0000;--btn-noise:none;outline-color:currentColor}}.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-shadow:"";--btn-fg:var(--btn-color,var(--color-base-content));--btn-bg:var(--btn-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--color-base-100))}}.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-border:var(--btn-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--color-base-100))}}.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-noise:none}@media (hover:none){.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:"";--btn-fg:var(--btn-color,var(--color-base-content));--btn-bg:var(--btn-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--color-base-100))}}.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-border:var(--btn-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--color-base-100))}}.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-noise:none}}}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-6{padding-right:calc(var(--spacing) * 6)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-7{padding-left:calc(var(--spacing) * 7)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.5625rem\]{font-size:.5625rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.wrap-break-word{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-400{color:var(--color-amber-400)}.text-amber-400\/70{color:#fcbb00b3}@supports (color:color-mix(in lab, red, red)){.text-amber-400\/70{color:color-mix(in oklab, var(--color-amber-400) 70%, transparent)}}.text-amber-400\/80{color:#fcbb00cc}@supports (color:color-mix(in lab, red, red)){.text-amber-400\/80{color:color-mix(in oklab, var(--color-amber-400) 80%, transparent)}}.text-amber-500\/70{color:#f99c00b3}@supports (color:color-mix(in lab, red, red)){.text-amber-500\/70{color:color-mix(in oklab, var(--color-amber-500) 70%, transparent)}}.text-blue-100{color:var(--color-blue-100)}.text-cyan-100{color:var(--color-cyan-100)}.text-error{color:var(--color-error)}.text-error-content{color:var(--color-error-content)}.text-gray-400{color:var(--color-gray-400)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-100{color:var(--color-green-100)}.text-green-300{color:var(--color-green-300)}.text-green-400{color:var(--color-green-400)}.text-mitto-accent{color:var(--color-mitto-accent)}.text-mitto-accent-300{color:var(--color-mitto-accent-300)}.text-mitto-accent-400{color:var(--color-mitto-accent-400)}.text-mitto-accent-500{color:var(--color-mitto-accent-500)}.text-mitto-accent-fg,.text-mitto-accent-fg\/80{color:var(--color-mitto-accent-fg)}@supports (color:color-mix(in lab, red, red)){.text-mitto-accent-fg\/80{color:color-mix(in oklab, var(--color-mitto-accent-fg) 80%, transparent)}}.text-mitto-border-3{color:var(--color-mitto-border-3)}.text-mitto-danger{color:var(--color-mitto-danger)}.text-mitto-danger-fg{color:var(--color-mitto-danger-fg)}.text-mitto-success{color:var(--color-mitto-success)}.text-mitto-text{color:var(--color-mitto-text)}.text-mitto-text-300{color:var(--color-mitto-text-300)}.text-mitto-text-500{color:var(--color-mitto-text-500)}.text-mitto-text-muted,.text-mitto-text-muted\/60{color:var(--color-mitto-text-muted)}@supports (color:color-mix(in lab, red, red)){.text-mitto-text-muted\/60{color:color-mix(in oklab, var(--color-mitto-text-muted) 60%, transparent)}}.text-mitto-text-secondary{color:var(--color-mitto-text-secondary)}.text-mitto-text-strong{color:var(--color-mitto-text-strong)}.text-mitto-user-text{color:var(--color-mitto-user-text)}.text-mitto-warning{color:var(--color-mitto-warning)}.text-orange-400{color:var(--color-orange-400)}.text-primary-content{color:var(--color-primary-content)}.text-purple-100{color:var(--color-purple-100)}.text-purple-300{color:var(--color-purple-300)}.text-purple-400{color:var(--color-purple-400)}.text-red-100{color:var(--color-red-100)}.text-red-200{color:var(--color-red-200)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-secondary-content{color:var(--color-secondary-content)}.text-success{color:var(--color-success)}.text-success-content{color:var(--color-success-content)}.text-warning-content{color:var(--color-warning-content)}.text-white{color:var(--color-white)}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.text-yellow-500{color:var(--color-yellow-500)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-500\/50{--tw-ring-color:#f99c0080}@supports (color:color-mix(in lab, red, red)){.ring-amber-500\/50{--tw-ring-color:color-mix(in oklab, var(--color-amber-500) 50%, transparent)}}.ring-mitto-accent-400\/50{--tw-ring-color:var(--color-mitto-accent-400)}@supports (color:color-mix(in lab, red, red)){.ring-mitto-accent-400\/50{--tw-ring-color:color-mix(in oklab, var(--color-mitto-accent-400) 50%, transparent)}}.ring-mitto-accent-500{--tw-ring-color:var(--color-mitto-accent-500)}.ring-mitto-border-3{--tw-ring-color:var(--color-mitto-border-3)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[--radius-field\:0\.25rem\]{--radius-field:.25rem}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-slate-500:is(:where(.group):hover *){background-color:var(--color-slate-500)}.group-hover\:text-mitto-text-strong:is(:where(.group):hover *){color:var(--color-mitto-text-strong)}.group-hover\:text-red-400:is(:where(.group):hover *){color:var(--color-red-400)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:text-gray-400::placeholder{color:var(--color-gray-400)}.placeholder\:text-mitto-text-muted::placeholder{color:var(--color-mitto-text-muted)}.placeholder\:text-mitto-text-secondary::placeholder{color:var(--color-mitto-text-secondary)}.after\:hidden:after{content:var(--tw-content);display:none}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.focus-within\:border-mitto-text-secondary:focus-within{border-color:var(--color-mitto-text-secondary)}@media (hover:hover){.hover\:border-mitto-accent:hover{border-color:var(--color-mitto-accent)}.hover\:border-mitto-accent-500\/50:hover{border-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.hover\:border-mitto-accent-500\/50:hover{border-color:color-mix(in oklab, var(--color-mitto-accent-500) 50%, transparent)}}.hover\:border-mitto-text-secondary:hover{border-color:var(--color-mitto-text-secondary)}.hover\:border-l-amber-500:hover{border-left-color:var(--color-amber-500)}.hover\:bg-amber-800:hover{background-color:var(--color-amber-800)}.hover\:bg-base-200\/40:hover{background-color:var(--color-base-200)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-base-200\/40:hover{background-color:color-mix(in oklab, var(--color-base-200) 40%, transparent)}}.hover\:bg-base-300\/50:hover{background-color:var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-base-300\/50:hover{background-color:color-mix(in oklab, var(--color-base-300) 50%, transparent)}}.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-mitto-accent-500\/30:hover{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-accent-500\/30:hover{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 30%, transparent)}}.hover\:bg-mitto-accent-500\/50:hover{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-accent-500\/50:hover{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 50%, transparent)}}.hover\:bg-mitto-accent-700:hover{background-color:var(--color-mitto-accent-700)}.hover\:bg-mitto-danger-hover:hover{background-color:var(--color-mitto-danger-hover)}.hover\:bg-mitto-input-box:hover{background-color:var(--color-mitto-input-box)}.hover\:bg-mitto-surface-3\/30:hover{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-surface-3\/30:hover{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 30%, transparent)}}.hover\:bg-mitto-surface-3\/50:hover{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-surface-3\/50:hover{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 50%, transparent)}}.hover\:bg-mitto-surface-4\/30:hover{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-surface-4\/30:hover{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 30%, transparent)}}.hover\:bg-mitto-surface-4\/50:hover{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-surface-4\/50:hover{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 50%, transparent)}}.hover\:bg-mitto-surface-hover:hover{background-color:var(--color-mitto-surface-hover)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-600\/80:hover{background-color:#e40014cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-600\/80:hover{background-color:color-mix(in oklab, var(--color-red-600) 80%, transparent)}}.hover\:bg-red-800:hover{background-color:var(--color-red-800)}.hover\:bg-red-900\/50:hover{background-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-900\/50:hover{background-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-mitto-accent:hover{color:var(--color-mitto-accent)}.hover\:text-mitto-accent-300:hover{color:var(--color-mitto-accent-300)}.hover\:text-mitto-accent-400:hover{color:var(--color-mitto-accent-400)}.hover\:text-mitto-accent-fg:hover{color:var(--color-mitto-accent-fg)}.hover\:text-mitto-danger:hover{color:var(--color-mitto-danger)}.hover\:text-mitto-text-200:hover{color:var(--color-mitto-text-200)}.hover\:text-mitto-text-300:hover{color:var(--color-mitto-text-300)}.hover\:text-mitto-text-secondary:hover{color:var(--color-mitto-text-secondary)}.hover\:text-mitto-text-strong:hover{color:var(--color-mitto-text-strong)}.hover\:text-red-300:hover{color:var(--color-red-300)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-blue-300:focus{border-color:var(--color-blue-300)}.focus\:border-mitto-accent:focus{border-color:var(--color-mitto-accent)}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-blue-300:focus{--tw-ring-color:var(--color-blue-300)}.focus\:ring-mitto-accent-500:focus{--tw-ring-color:var(--color-mitto-accent-500)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-mitto-sidebar:focus{--tw-ring-offset-color:var(--color-mitto-sidebar)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:48rem){@layer daisyui.l1.l2.l3{.md\:drawer-open>.drawer-toggle:checked~.drawer-side{scrollbar-color:revert-layer}:root:has(.md\:drawer-open>.drawer-toggle:checked){--page-overflow:revert-layer;--page-scroll-gutter:revert-layer;--page-scroll-bg:revert-layer;--page-scroll-transition:revert-layer;--page-has-backdrop:revert-layer;animation:revert-layer;animation-timeline:revert-layer}}@layer daisyui.l1.l2{.md\:drawer-open>.drawer-side{overflow-y:auto}.md\:drawer-open>.drawer-toggle{display:none}.md\:drawer-open>.drawer-toggle~.drawer-side{pointer-events:auto;visibility:visible;overscroll-behavior:auto;opacity:1;width:auto;display:block;position:sticky}.md\:drawer-open>.drawer-toggle~.drawer-side>.drawer-overlay{cursor:default;background-color:#0000}.md\:drawer-open>.drawer-toggle:checked~.drawer-side{pointer-events:auto;visibility:visible}}@layer daisyui.l1{.md\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay),[dir=rtl] :is(.md\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay)){translate:0%}}.md\:hidden{display:none}.md\:max-w-\[75\%\]{max-width:75%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}.dark\:border-gray-600:where(.dark,.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:where(.dark,.dark *){border-color:var(--color-gray-700)}.dark\:border-mitto-border-2:where(.dark,.dark *){border-color:var(--color-mitto-border-2)}.dark\:bg-blue-600:where(.dark,.dark *){background-color:var(--color-blue-600)}.dark\:bg-gray-700:where(.dark,.dark *){background-color:var(--color-gray-700)}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}.dark\:bg-mitto-surface-2:where(.dark,.dark *){background-color:var(--color-mitto-surface-2)}.dark\:bg-mitto-surface-3\/95:where(.dark,.dark *){background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-mitto-surface-3\/95:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-mitto-surface-3) 95%, transparent)}}.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:where(.dark,.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\:text-gray-500:where(.dark,.dark *){color:var(--color-gray-500)}.dark\:text-mitto-text-300:where(.dark,.dark *){color:var(--color-mitto-text-300)}.dark\:text-mitto-text-500:where(.dark,.dark *){color:var(--color-mitto-text-500)}.dark\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.dark\:placeholder\:text-gray-500:where(.dark,.dark *)::placeholder{color:var(--color-gray-500)}@media (hover:hover){.dark\:hover\:bg-blue-700:where(.dark,.dark *):hover{background-color:var(--color-blue-700)}.dark\:hover\:bg-gray-600:where(.dark,.dark *):hover{background-color:var(--color-gray-600)}.dark\:hover\:bg-gray-700:where(.dark,.dark *):hover{background-color:var(--color-gray-700)}.dark\:hover\:bg-mitto-surface-3:where(.dark,.dark *):hover{background-color:var(--color-mitto-surface-3)}.dark\:hover\:text-gray-300:where(.dark,.dark *):hover{color:var(--color-gray-300)}}.dark\:focus\:border-blue-500:where(.dark,.dark *):focus{border-color:var(--color-blue-500)}.dark\:focus\:ring-blue-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-500)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-bg:var(--color-base-100);--mitto-chat:var(--color-base-100);--mitto-input:var(--color-base-200);--mitto-input-box:var(--color-base-300);--mitto-sidebar:var(--color-base-200);--mitto-agent:var(--color-base-200);--mitto-surface-1:var(--color-base-100);--mitto-surface-2:var(--color-base-200);--mitto-surface-3:var(--color-base-300);--mitto-surface-4:var(--color-neutral);--mitto-surface-hover:var(--color-base-300);--mitto-user:var(--color-base-200);--mitto-user-text:var(--color-base-content);--mitto-user-border:var(--color-primary);--mitto-text:var(--color-base-content);--mitto-text-strong:var(--color-base-content);--mitto-text-secondary:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-secondary:color-mix(in oklch, var(--color-base-content) 65%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-muted:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-muted:color-mix(in oklch, var(--color-base-content) 55%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-200:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-200:color-mix(in oklch, var(--color-base-content) 90%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-300:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-300:color-mix(in oklch, var(--color-base-content) 80%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-500:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-500:color-mix(in oklch, var(--color-base-content) 60%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border:color-mix(in oklch, var(--color-base-content) 15%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-1:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-1:color-mix(in oklch, var(--color-base-content) 12%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-2:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-2:color-mix(in oklch, var(--color-base-content) 22%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-3:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-3:color-mix(in oklch, var(--color-base-content) 32%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent:var(--color-primary);--mitto-accent-hover:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-hover:color-mix(in oklch, var(--color-primary) 85%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-fg:var(--color-primary-content);--mitto-accent-100:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-100:color-mix(in oklch, var(--color-primary) 18%, var(--color-base-100))}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-300:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-300:color-mix(in oklch, var(--color-primary) 45%, var(--color-base-100))}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-400:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-400:color-mix(in oklch, var(--color-primary) 65%, var(--color-base-100))}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-500:var(--color-primary);--mitto-accent-600:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-600:color-mix(in oklch, var(--color-primary) 88%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-700:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-700:color-mix(in oklch, var(--color-primary) 75%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-900:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-900:color-mix(in oklch, var(--color-primary) 55%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-danger:var(--color-error);--mitto-danger-hover:var(--color-error)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-danger-hover:color-mix(in oklch, var(--color-error) 85%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-danger-fg:var(--color-error-content);--mitto-success:var(--color-success);--mitto-warning:var(--color-warning);--mitto-info:var(--color-info)}@keyframes rating{0%,40%{filter:brightness(1.05)contrast(1.05);scale:1.1}}@keyframes menu{0%{opacity:0}}@keyframes rotator{89.9999%,to{--first-item-position:0 0%}90%,99.9999%{--first-item-position:0 calc(var(--items) * 100%)}to{translate:0 -100%}}@keyframes progress{50%{background-position-x:-115%}}@keyframes radio{0%{padding:5px}50%{padding:3px}}@keyframes dropdown{0%{opacity:0}}@keyframes toast{0%{opacity:0;scale:.9}to{opacity:1;scale:1}}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-100:oklch(96.2% .044 156.743);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-900:oklch(39.3% .095 152.535);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-800:oklch(45% .085 224.283);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-700:oklch(37.2% .044 257.287);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-mitto-bg:var(--mitto-bg);--color-mitto-sidebar:var(--mitto-sidebar);--color-mitto-input:var(--mitto-input);--color-mitto-input-box:var(--mitto-input-box);--color-mitto-user:var(--mitto-user);--color-mitto-user-text:var(--mitto-user-text);--color-mitto-user-border:var(--mitto-user-border);--color-mitto-agent:var(--mitto-agent);--color-mitto-border:var(--mitto-border);--color-mitto-text:var(--mitto-text);--color-mitto-text-secondary:var(--mitto-text-secondary);--color-mitto-accent:var(--mitto-accent);--color-mitto-accent-fg:var(--mitto-accent-fg);--color-mitto-accent-300:var(--mitto-accent-300);--color-mitto-accent-400:var(--mitto-accent-400);--color-mitto-accent-500:var(--mitto-accent-500);--color-mitto-accent-600:var(--mitto-accent-600);--color-mitto-accent-700:var(--mitto-accent-700);--color-mitto-accent-900:var(--mitto-accent-900);--color-mitto-danger:var(--mitto-danger);--color-mitto-danger-hover:var(--mitto-danger-hover);--color-mitto-danger-fg:var(--mitto-danger-fg);--color-mitto-surface-hover:var(--mitto-surface-hover);--color-mitto-surface-2:var(--mitto-surface-2);--color-mitto-surface-3:var(--mitto-surface-3);--color-mitto-surface-4:var(--mitto-surface-4);--color-mitto-text-strong:var(--mitto-text-strong);--color-mitto-text-muted:var(--mitto-text-muted);--color-mitto-text-200:var(--mitto-text-200);--color-mitto-text-300:var(--mitto-text-300);--color-mitto-text-500:var(--mitto-text-500);--color-mitto-border-1:var(--mitto-border-1);--color-mitto-border-2:var(--mitto-border-2);--color-mitto-border-3:var(--mitto-border-3);--color-mitto-success:var(--mitto-success);--color-mitto-warning:var(--mitto-warning)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:after,:before,::backdrop{border-color:var(--color-gray-200,currentColor)}::file-selector-button{border-color:var(--color-gray-200,currentColor)}.light{color-scheme:light}:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dark]:checked),[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=cupcake]:checked),[data-theme=cupcake]{color-scheme:light;--color-base-100:oklch(97.788% .004 56.375);--color-base-200:oklch(93.982% .007 61.449);--color-base-300:oklch(91.586% .006 53.44);--color-base-content:oklch(23.574% .066 313.189);--color-primary:oklch(85% .138 181.071);--color-primary-content:oklch(43% .078 188.216);--color-secondary:oklch(89% .061 343.231);--color-secondary-content:oklch(45% .187 3.815);--color-accent:oklch(90% .076 70.697);--color-accent-content:oklch(47% .157 37.304);--color-neutral:oklch(27% .006 286.033);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(68% .169 237.323);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(69% .17 162.48);--color-success-content:oklch(26% .051 172.552);--color-warning:oklch(79% .184 86.047);--color-warning-content:oklch(28% .066 53.813);--color-error:oklch(64% .246 16.439);--color-error-content:oklch(27% .105 12.094);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:0}:root:has(input.theme-controller[value=bumblebee]:checked),[data-theme=bumblebee]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(92% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(85% .199 91.936);--color-primary-content:oklch(42% .095 57.708);--color-secondary:oklch(75% .183 55.934);--color-secondary-content:oklch(40% .123 38.172);--color-accent:oklch(0% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(37% .01 67.558);--color-neutral-content:oklch(92% .003 48.717);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(39% .09 240.876);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(39% .141 25.723);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=emerald]:checked),[data-theme=emerald]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(35.519% .032 262.988);--color-primary:oklch(76.662% .135 153.45);--color-primary-content:oklch(33.387% .04 162.24);--color-secondary:oklch(61.302% .202 261.294);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(72.772% .149 33.2);--color-accent-content:oklch(0% 0 0);--color-neutral:oklch(35.519% .032 262.988);--color-neutral-content:oklch(98.462% .001 247.838);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=corporate]:checked),[data-theme=corporate]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(22.389% .031 278.072);--color-primary:oklch(58% .158 241.966);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(55% .046 257.417);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(60% .118 184.704);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(0% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(60% .126 221.723);--color-info-content:oklch(100% 0 0);--color-success:oklch(62% .194 149.214);--color-success-content:oklch(100% 0 0);--color-warning:oklch(85% .199 91.936);--color-warning-content:oklch(0% 0 0);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(0% 0 0);--radius-selector:.25rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=synthwave]:checked),[data-theme=synthwave]{color-scheme:dark;--color-base-100:oklch(15% .09 281.288);--color-base-200:oklch(20% .09 281.288);--color-base-300:oklch(25% .09 281.288);--color-base-content:oklch(78% .115 274.713);--color-primary:oklch(71% .202 349.761);--color-primary-content:oklch(28% .109 3.907);--color-secondary:oklch(82% .111 230.318);--color-secondary-content:oklch(29% .066 243.157);--color-accent:oklch(75% .183 55.934);--color-accent-content:oklch(26% .079 36.259);--color-neutral:oklch(45% .24 277.023);--color-neutral-content:oklch(87% .065 274.039);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(77% .152 181.912);--color-success-content:oklch(27% .046 192.524);--color-warning:oklch(90% .182 98.111);--color-warning-content:oklch(42% .095 57.708);--color-error:oklch(73.7% .121 32.639);--color-error-content:oklch(23.501% .096 290.329);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=retro]:checked),[data-theme=retro]{color-scheme:light;--color-base-100:oklch(91.637% .034 90.515);--color-base-200:oklch(88.272% .049 91.774);--color-base-300:oklch(84.133% .065 90.856);--color-base-content:oklch(41% .112 45.904);--color-primary:oklch(80% .114 19.571);--color-primary-content:oklch(39% .141 25.723);--color-secondary:oklch(92% .084 155.995);--color-secondary-content:oklch(44% .119 151.328);--color-accent:oklch(68% .162 75.834);--color-accent-content:oklch(41% .112 45.904);--color-neutral:oklch(44% .011 73.639);--color-neutral-content:oklch(86% .005 56.366);--color-info:oklch(58% .158 241.966);--color-info-content:oklch(96% .059 95.617);--color-success:oklch(51% .096 186.391);--color-success-content:oklch(96% .059 95.617);--color-warning:oklch(64% .222 41.116);--color-warning-content:oklch(96% .059 95.617);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(40% .123 38.172);--radius-selector:.25rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=cyberpunk]:checked),[data-theme=cyberpunk]{color-scheme:light;--color-base-100:oklch(94.51% .179 104.32);--color-base-200:oklch(91.51% .179 104.32);--color-base-300:oklch(85.51% .179 104.32);--color-base-content:oklch(0% 0 0);--color-primary:oklch(74.22% .209 6.35);--color-primary-content:oklch(14.844% .041 6.35);--color-secondary:oklch(83.33% .184 204.72);--color-secondary-content:oklch(16.666% .036 204.72);--color-accent:oklch(71.86% .217 310.43);--color-accent-content:oklch(14.372% .043 310.43);--color-neutral:oklch(23.04% .065 269.31);--color-neutral-content:oklch(94.51% .179 104.32);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:0rem;--radius-field:0rem;--radius-box:0rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=valentine]:checked),[data-theme=valentine]{color-scheme:light;--color-base-100:oklch(97% .014 343.198);--color-base-200:oklch(94% .028 342.258);--color-base-300:oklch(89% .061 343.231);--color-base-content:oklch(52% .223 3.958);--color-primary:oklch(65% .241 354.308);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(62% .265 303.9);--color-secondary-content:oklch(97% .014 308.299);--color-accent:oklch(82% .111 230.318);--color-accent-content:oklch(39% .09 240.876);--color-neutral:oklch(40% .153 2.432);--color-neutral-content:oklch(89% .061 343.231);--color-info:oklch(86% .127 207.078);--color-info-content:oklch(44% .11 240.79);--color-success:oklch(84% .143 164.978);--color-success-content:oklch(43% .095 166.913);--color-warning:oklch(75% .183 55.934);--color-warning-content:oklch(26% .079 36.259);--color-error:oklch(63% .237 25.331);--color-error-content:oklch(97% .013 17.38);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=halloween]:checked),[data-theme=halloween]{color-scheme:dark;--color-base-100:oklch(21% .006 56.043);--color-base-200:oklch(14% .004 49.25);--color-base-300:oklch(0% 0 0);--color-base-content:oklch(84.955% 0 0);--color-primary:oklch(77.48% .204 60.62);--color-primary-content:oklch(19.693% .004 196.779);--color-secondary:oklch(45.98% .248 305.03);--color-secondary-content:oklch(89.196% .049 305.03);--color-accent:oklch(64.8% .223 136.073);--color-accent-content:oklch(0% 0 0);--color-neutral:oklch(24.371% .046 65.681);--color-neutral-content:oklch(84.874% .009 65.681);--color-info:oklch(54.615% .215 262.88);--color-info-content:oklch(90.923% .043 262.88);--color-success:oklch(62.705% .169 149.213);--color-success-content:oklch(12.541% .033 149.213);--color-warning:oklch(66.584% .157 58.318);--color-warning-content:oklch(13.316% .031 58.318);--color-error:oklch(65.72% .199 27.33);--color-error-content:oklch(13.144% .039 27.33);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=garden]:checked),[data-theme=garden]{color-scheme:light;--color-base-100:oklch(92.951% .002 17.197);--color-base-200:oklch(86.445% .002 17.197);--color-base-300:oklch(79.938% .001 17.197);--color-base-content:oklch(16.961% .001 17.32);--color-primary:oklch(62.45% .278 3.836);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(48.495% .11 355.095);--color-secondary-content:oklch(89.699% .022 355.095);--color-accent:oklch(56.273% .054 154.39);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(24.155% .049 89.07);--color-neutral-content:oklch(92.951% .002 17.197);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=forest]:checked),[data-theme=forest]{color-scheme:dark;--color-base-100:oklch(20.84% .008 17.911);--color-base-200:oklch(18.522% .007 17.911);--color-base-300:oklch(16.203% .007 17.911);--color-base-content:oklch(83.768% .001 17.911);--color-primary:oklch(68.628% .185 148.958);--color-primary-content:oklch(0% 0 0);--color-secondary:oklch(69.776% .135 168.327);--color-secondary-content:oklch(13.955% .027 168.327);--color-accent:oklch(70.628% .119 185.713);--color-accent-content:oklch(14.125% .023 185.713);--color-neutral:oklch(30.698% .039 171.364);--color-neutral-content:oklch(86.139% .007 171.364);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=aqua]:checked),[data-theme=aqua]{color-scheme:dark;--color-base-100:oklch(37% .146 265.522);--color-base-200:oklch(28% .091 267.935);--color-base-300:oklch(22% .091 267.935);--color-base-content:oklch(90% .058 230.902);--color-primary:oklch(85.661% .144 198.645);--color-primary-content:oklch(40.124% .068 197.603);--color-secondary:oklch(60.682% .108 309.782);--color-secondary-content:oklch(96% .016 293.756);--color-accent:oklch(93.426% .102 94.555);--color-accent-content:oklch(18.685% .02 94.555);--color-neutral:oklch(27% .146 265.522);--color-neutral-content:oklch(80% .146 265.522);--color-info:oklch(54.615% .215 262.88);--color-info-content:oklch(90.923% .043 262.88);--color-success:oklch(62.705% .169 149.213);--color-success-content:oklch(12.541% .033 149.213);--color-warning:oklch(66.584% .157 58.318);--color-warning-content:oklch(27% .077 45.635);--color-error:oklch(73.95% .19 27.33);--color-error-content:oklch(14.79% .038 27.33);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=lofi]:checked),[data-theme=lofi]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(94% 0 0);--color-base-content:oklch(0% 0 0);--color-primary:oklch(15.906% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(21.455% .001 17.278);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(26.861% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(0% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(79.54% .103 205.9);--color-info-content:oklch(15.908% .02 205.9);--color-success:oklch(90.13% .153 164.14);--color-success-content:oklch(18.026% .03 164.14);--color-warning:oklch(88.37% .135 79.94);--color-warning-content:oklch(17.674% .027 79.94);--color-error:oklch(78.66% .15 28.47);--color-error-content:oklch(15.732% .03 28.47);--radius-selector:2rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=pastel]:checked),[data-theme=pastel]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98.462% .001 247.838);--color-base-300:oklch(92.462% .001 247.838);--color-base-content:oklch(20% 0 0);--color-primary:oklch(90% .063 306.703);--color-primary-content:oklch(49% .265 301.924);--color-secondary:oklch(89% .058 10.001);--color-secondary-content:oklch(51% .222 16.935);--color-accent:oklch(90% .093 164.15);--color-accent-content:oklch(50% .118 165.612);--color-neutral:oklch(55% .046 257.417);--color-neutral-content:oklch(92% .013 255.508);--color-info:oklch(86% .127 207.078);--color-info-content:oklch(52% .105 223.128);--color-success:oklch(87% .15 154.449);--color-success-content:oklch(52% .154 150.069);--color-warning:oklch(83% .128 66.29);--color-warning-content:oklch(55% .195 38.402);--color-error:oklch(80% .114 19.571);--color-error-content:oklch(50% .213 27.518);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:0;--noise:0}:root:has(input.theme-controller[value=fantasy]:checked),[data-theme=fantasy]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(27.807% .029 256.847);--color-primary:oklch(37.45% .189 325.02);--color-primary-content:oklch(87.49% .037 325.02);--color-secondary:oklch(53.92% .162 241.36);--color-secondary-content:oklch(90.784% .032 241.36);--color-accent:oklch(75.98% .204 56.72);--color-accent-content:oklch(15.196% .04 56.72);--color-neutral:oklch(27.807% .029 256.847);--color-neutral-content:oklch(85.561% .005 256.847);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=wireframe]:checked),[data-theme=wireframe]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(94% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(87% 0 0);--color-primary-content:oklch(26% 0 0);--color-secondary:oklch(87% 0 0);--color-secondary-content:oklch(26% 0 0);--color-accent:oklch(87% 0 0);--color-accent-content:oklch(26% 0 0);--color-neutral:oklch(87% 0 0);--color-neutral-content:oklch(26% 0 0);--color-info:oklch(44% .11 240.79);--color-info-content:oklch(90% .058 230.902);--color-success:oklch(43% .095 166.913);--color-success-content:oklch(90% .093 164.15);--color-warning:oklch(47% .137 46.201);--color-warning-content:oklch(92% .12 95.746);--color-error:oklch(44% .177 26.899);--color-error-content:oklch(88% .062 18.334);--radius-selector:0rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=black]:checked),[data-theme=black]{color-scheme:dark;--color-base-100:oklch(0% 0 0);--color-base-200:oklch(19% 0 0);--color-base-300:oklch(22% 0 0);--color-base-content:oklch(87.609% 0 0);--color-primary:oklch(35% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(35% 0 0);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(35% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(35% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(45.201% .313 264.052);--color-info-content:oklch(89.04% .062 264.052);--color-success:oklch(51.975% .176 142.495);--color-success-content:oklch(90.395% .035 142.495);--color-warning:oklch(96.798% .211 109.769);--color-warning-content:oklch(19.359% .042 109.769);--color-error:oklch(62.795% .257 29.233);--color-error-content:oklch(12.559% .051 29.233);--radius-selector:0rem;--radius-field:0rem;--radius-box:0rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=luxury]:checked),[data-theme=luxury]{color-scheme:dark;--color-base-100:oklch(14.076% .004 285.822);--color-base-200:oklch(20.219% .004 308.229);--color-base-300:oklch(23.219% .004 308.229);--color-base-content:oklch(75.687% .123 76.89);--color-primary:oklch(100% 0 0);--color-primary-content:oklch(20% 0 0);--color-secondary:oklch(27.581% .064 261.069);--color-secondary-content:oklch(85.516% .012 261.069);--color-accent:oklch(36.674% .051 338.825);--color-accent-content:oklch(87.334% .01 338.825);--color-neutral:oklch(24.27% .057 59.825);--color-neutral-content:oklch(93.203% .089 90.861);--color-info:oklch(79.061% .121 237.133);--color-info-content:oklch(15.812% .024 237.133);--color-success:oklch(78.119% .192 132.154);--color-success-content:oklch(15.623% .038 132.154);--color-warning:oklch(86.127% .136 102.891);--color-warning-content:oklch(17.225% .027 102.891);--color-error:oklch(71.753% .176 22.568);--color-error-content:oklch(14.35% .035 22.568);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dracula]:checked),[data-theme=dracula]{color-scheme:dark;--color-base-100:oklch(28.822% .022 277.508);--color-base-200:oklch(26.805% .02 277.508);--color-base-300:oklch(24.787% .019 277.508);--color-base-content:oklch(97.747% .007 106.545);--color-primary:oklch(75.461% .183 346.812);--color-primary-content:oklch(15.092% .036 346.812);--color-secondary:oklch(74.202% .148 301.883);--color-secondary-content:oklch(14.84% .029 301.883);--color-accent:oklch(83.392% .124 66.558);--color-accent-content:oklch(16.678% .024 66.558);--color-neutral:oklch(39.445% .032 275.524);--color-neutral-content:oklch(87.889% .006 275.524);--color-info:oklch(88.263% .093 212.846);--color-info-content:oklch(17.652% .018 212.846);--color-success:oklch(87.099% .219 148.024);--color-success-content:oklch(17.419% .043 148.024);--color-warning:oklch(95.533% .134 112.757);--color-warning-content:oklch(19.106% .026 112.757);--color-error:oklch(68.22% .206 24.43);--color-error-content:oklch(13.644% .041 24.43);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=cmyk]:checked),[data-theme=cmyk]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(95% 0 0);--color-base-300:oklch(90% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(71.772% .133 239.443);--color-primary-content:oklch(14.354% .026 239.443);--color-secondary:oklch(64.476% .202 359.339);--color-secondary-content:oklch(12.895% .04 359.339);--color-accent:oklch(94.228% .189 105.306);--color-accent-content:oklch(18.845% .037 105.306);--color-neutral:oklch(21.778% 0 0);--color-neutral-content:oklch(84.355% 0 0);--color-info:oklch(68.475% .094 217.284);--color-info-content:oklch(13.695% .018 217.284);--color-success:oklch(46.949% .162 321.406);--color-success-content:oklch(89.389% .032 321.406);--color-warning:oklch(71.236% .159 52.023);--color-warning-content:oklch(14.247% .031 52.023);--color-error:oklch(62.013% .208 28.717);--color-error-content:oklch(12.402% .041 28.717);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=autumn]:checked),[data-theme=autumn]{color-scheme:light;--color-base-100:oklch(95.814% 0 0);--color-base-200:oklch(89.107% 0 0);--color-base-300:oklch(82.4% 0 0);--color-base-content:oklch(19.162% 0 0);--color-primary:oklch(40.723% .161 17.53);--color-primary-content:oklch(88.144% .032 17.53);--color-secondary:oklch(61.676% .169 23.865);--color-secondary-content:oklch(12.335% .033 23.865);--color-accent:oklch(73.425% .094 60.729);--color-accent-content:oklch(14.685% .018 60.729);--color-neutral:oklch(54.367% .037 51.902);--color-neutral-content:oklch(90.873% .007 51.902);--color-info:oklch(69.224% .097 207.284);--color-info-content:oklch(13.844% .019 207.284);--color-success:oklch(60.995% .08 174.616);--color-success-content:oklch(12.199% .016 174.616);--color-warning:oklch(70.081% .164 56.844);--color-warning-content:oklch(14.016% .032 56.844);--color-error:oklch(53.07% .241 24.16);--color-error-content:oklch(90.614% .048 24.16);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=business]:checked),[data-theme=business]{color-scheme:dark;--color-base-100:oklch(24.353% 0 0);--color-base-200:oklch(22.648% 0 0);--color-base-300:oklch(20.944% 0 0);--color-base-content:oklch(84.87% 0 0);--color-primary:oklch(41.703% .099 251.473);--color-primary-content:oklch(88.34% .019 251.473);--color-secondary:oklch(64.092% .027 229.389);--color-secondary-content:oklch(12.818% .005 229.389);--color-accent:oklch(67.271% .167 35.791);--color-accent-content:oklch(13.454% .033 35.791);--color-neutral:oklch(27.441% .013 253.041);--color-neutral-content:oklch(85.488% .002 253.041);--color-info:oklch(62.616% .143 240.033);--color-info-content:oklch(12.523% .028 240.033);--color-success:oklch(70.226% .094 156.596);--color-success-content:oklch(14.045% .018 156.596);--color-warning:oklch(77.482% .115 81.519);--color-warning-content:oklch(15.496% .023 81.519);--color-error:oklch(51.61% .146 29.674);--color-error-content:oklch(90.322% .029 29.674);--radius-selector:0rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=acid]:checked),[data-theme=acid]{color-scheme:light;--color-base-100:oklch(98% 0 0);--color-base-200:oklch(95% 0 0);--color-base-300:oklch(91% 0 0);--color-base-content:oklch(0% 0 0);--color-primary:oklch(71.9% .357 330.759);--color-primary-content:oklch(14.38% .071 330.759);--color-secondary:oklch(73.37% .224 48.25);--color-secondary-content:oklch(14.674% .044 48.25);--color-accent:oklch(92.78% .264 122.962);--color-accent-content:oklch(18.556% .052 122.962);--color-neutral:oklch(21.31% .128 278.68);--color-neutral-content:oklch(84.262% .025 278.68);--color-info:oklch(60.72% .227 252.05);--color-info-content:oklch(12.144% .045 252.05);--color-success:oklch(85.72% .266 158.53);--color-success-content:oklch(17.144% .053 158.53);--color-warning:oklch(91.01% .212 100.5);--color-warning-content:oklch(18.202% .042 100.5);--color-error:oklch(64.84% .293 29.349);--color-error-content:oklch(12.968% .058 29.349);--radius-selector:1rem;--radius-field:1rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=lemonade]:checked),[data-theme=lemonade]{color-scheme:light;--color-base-100:oklch(98.71% .02 123.72);--color-base-200:oklch(91.8% .018 123.72);--color-base-300:oklch(84.89% .017 123.72);--color-base-content:oklch(19.742% .004 123.72);--color-primary:oklch(58.92% .199 134.6);--color-primary-content:oklch(11.784% .039 134.6);--color-secondary:oklch(77.75% .196 111.09);--color-secondary-content:oklch(15.55% .039 111.09);--color-accent:oklch(85.39% .201 100.73);--color-accent-content:oklch(17.078% .04 100.73);--color-neutral:oklch(30.98% .075 108.6);--color-neutral-content:oklch(86.196% .015 108.6);--color-info:oklch(86.19% .047 224.14);--color-info-content:oklch(17.238% .009 224.14);--color-success:oklch(86.19% .047 157.85);--color-success-content:oklch(17.238% .009 157.85);--color-warning:oklch(86.19% .047 102.15);--color-warning-content:oklch(17.238% .009 102.15);--color-error:oklch(86.19% .047 25.85);--color-error-content:oklch(17.238% .009 25.85);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=night]:checked),[data-theme=night]{color-scheme:dark;--color-base-100:oklch(20.768% .039 265.754);--color-base-200:oklch(19.314% .037 265.754);--color-base-300:oklch(17.86% .034 265.754);--color-base-content:oklch(84.153% .007 265.754);--color-primary:oklch(75.351% .138 232.661);--color-primary-content:oklch(15.07% .027 232.661);--color-secondary:oklch(68.011% .158 276.934);--color-secondary-content:oklch(13.602% .031 276.934);--color-accent:oklch(72.36% .176 350.048);--color-accent-content:oklch(14.472% .035 350.048);--color-neutral:oklch(27.949% .036 260.03);--color-neutral-content:oklch(85.589% .007 260.03);--color-info:oklch(68.455% .148 237.251);--color-info-content:oklch(0% 0 0);--color-success:oklch(78.452% .132 181.911);--color-success-content:oklch(15.69% .026 181.911);--color-warning:oklch(83.242% .139 82.95);--color-warning-content:oklch(16.648% .027 82.95);--color-error:oklch(71.785% .17 13.118);--color-error-content:oklch(14.357% .034 13.118);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=coffee]:checked),[data-theme=coffee]{color-scheme:dark;--color-base-100:oklch(24% .023 329.708);--color-base-200:oklch(21% .021 329.708);--color-base-300:oklch(16% .019 329.708);--color-base-content:oklch(72.354% .092 79.129);--color-primary:oklch(71.996% .123 62.756);--color-primary-content:oklch(14.399% .024 62.756);--color-secondary:oklch(34.465% .029 199.194);--color-secondary-content:oklch(86.893% .005 199.194);--color-accent:oklch(42.621% .074 224.389);--color-accent-content:oklch(88.524% .014 224.389);--color-neutral:oklch(16.51% .015 326.261);--color-neutral-content:oklch(83.302% .003 326.261);--color-info:oklch(79.49% .063 184.558);--color-info-content:oklch(15.898% .012 184.558);--color-success:oklch(74.722% .072 131.116);--color-success-content:oklch(14.944% .014 131.116);--color-warning:oklch(88.15% .14 87.722);--color-warning-content:oklch(17.63% .028 87.722);--color-error:oklch(77.318% .128 31.871);--color-error-content:oklch(15.463% .025 31.871);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=winter]:checked),[data-theme=winter]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97.466% .011 259.822);--color-base-300:oklch(93.268% .016 262.751);--color-base-content:oklch(41.886% .053 255.824);--color-primary:oklch(56.86% .255 257.57);--color-primary-content:oklch(91.372% .051 257.57);--color-secondary:oklch(42.551% .161 282.339);--color-secondary-content:oklch(88.51% .032 282.339);--color-accent:oklch(59.939% .191 335.171);--color-accent-content:oklch(11.988% .038 335.171);--color-neutral:oklch(19.616% .063 257.651);--color-neutral-content:oklch(83.923% .012 257.651);--color-info:oklch(88.127% .085 214.515);--color-info-content:oklch(17.625% .017 214.515);--color-success:oklch(80.494% .077 197.823);--color-success-content:oklch(16.098% .015 197.823);--color-warning:oklch(89.172% .045 71.47);--color-warning-content:oklch(17.834% .009 71.47);--color-error:oklch(73.092% .11 20.076);--color-error-content:oklch(14.618% .022 20.076);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=dim]:checked),[data-theme=dim]{color-scheme:dark;--color-base-100:oklch(30.857% .023 264.149);--color-base-200:oklch(28.036% .019 264.182);--color-base-300:oklch(26.346% .018 262.177);--color-base-content:oklch(82.901% .031 222.959);--color-primary:oklch(86.133% .141 139.549);--color-primary-content:oklch(17.226% .028 139.549);--color-secondary:oklch(73.375% .165 35.353);--color-secondary-content:oklch(14.675% .033 35.353);--color-accent:oklch(74.229% .133 311.379);--color-accent-content:oklch(14.845% .026 311.379);--color-neutral:oklch(24.731% .02 264.094);--color-neutral-content:oklch(82.901% .031 222.959);--color-info:oklch(86.078% .142 206.182);--color-info-content:oklch(17.215% .028 206.182);--color-success:oklch(86.171% .142 166.534);--color-success-content:oklch(17.234% .028 166.534);--color-warning:oklch(86.163% .142 94.818);--color-warning-content:oklch(17.232% .028 94.818);--color-error:oklch(82.418% .099 33.756);--color-error-content:oklch(16.483% .019 33.756);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=nord]:checked),[data-theme=nord]{color-scheme:light;--color-base-100:oklch(95.127% .007 260.731);--color-base-200:oklch(93.299% .01 261.788);--color-base-300:oklch(89.925% .016 262.749);--color-base-content:oklch(32.437% .022 264.182);--color-primary:oklch(59.435% .077 254.027);--color-primary-content:oklch(11.887% .015 254.027);--color-secondary:oklch(69.651% .059 248.687);--color-secondary-content:oklch(13.93% .011 248.687);--color-accent:oklch(77.464% .062 217.469);--color-accent-content:oklch(15.492% .012 217.469);--color-neutral:oklch(45.229% .035 264.131);--color-neutral-content:oklch(89.925% .016 262.749);--color-info:oklch(69.207% .062 332.664);--color-info-content:oklch(13.841% .012 332.664);--color-success:oklch(76.827% .074 131.063);--color-success-content:oklch(15.365% .014 131.063);--color-warning:oklch(85.486% .089 84.093);--color-warning-content:oklch(17.097% .017 84.093);--color-error:oklch(60.61% .12 15.341);--color-error-content:oklch(12.122% .024 15.341);--radius-selector:1rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=sunset]:checked),[data-theme=sunset]{color-scheme:dark;--color-base-100:oklch(22% .019 237.69);--color-base-200:oklch(20% .019 237.69);--color-base-300:oklch(18% .019 237.69);--color-base-content:oklch(77.383% .043 245.096);--color-primary:oklch(74.703% .158 39.947);--color-primary-content:oklch(14.94% .031 39.947);--color-secondary:oklch(72.537% .177 2.72);--color-secondary-content:oklch(14.507% .035 2.72);--color-accent:oklch(71.294% .166 299.844);--color-accent-content:oklch(14.258% .033 299.844);--color-neutral:oklch(26% .019 237.69);--color-neutral-content:oklch(70% .019 237.69);--color-info:oklch(85.559% .085 206.015);--color-info-content:oklch(17.111% .017 206.015);--color-success:oklch(85.56% .085 144.778);--color-success-content:oklch(17.112% .017 144.778);--color-warning:oklch(85.569% .084 74.427);--color-warning-content:oklch(17.113% .016 74.427);--color-error:oklch(85.511% .078 16.886);--color-error-content:oklch(17.102% .015 16.886);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=caramellatte]:checked),[data-theme=caramellatte]{color-scheme:light;--color-base-100:oklch(98% .016 73.684);--color-base-200:oklch(95% .038 75.164);--color-base-300:oklch(90% .076 70.697);--color-base-content:oklch(40% .123 38.172);--color-primary:oklch(0% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(22.45% .075 37.85);--color-secondary-content:oklch(90% .076 70.697);--color-accent:oklch(46.44% .111 37.85);--color-accent-content:oklch(90% .076 70.697);--color-neutral:oklch(55% .195 38.402);--color-neutral-content:oklch(98% .016 73.684);--color-info:oklch(42% .199 265.638);--color-info-content:oklch(90% .076 70.697);--color-success:oklch(43% .095 166.913);--color-success-content:oklch(90% .076 70.697);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(39% .141 25.723);--radius-selector:2rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:1}:root:has(input.theme-controller[value=abyss]:checked),[data-theme=abyss]{color-scheme:dark;--color-base-100:oklch(20% .08 209);--color-base-200:oklch(15% .08 209);--color-base-300:oklch(10% .08 209);--color-base-content:oklch(90% .076 70.697);--color-primary:oklch(92% .2653 125);--color-primary-content:oklch(50% .2653 125);--color-secondary:oklch(83.27% .0764 298.3);--color-secondary-content:oklch(43.27% .0764 298.3);--color-accent:oklch(43% 0 0);--color-accent-content:oklch(98% 0 0);--color-neutral:oklch(30% .08 209);--color-neutral-content:oklch(90% .076 70.697);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(79% .209 151.711);--color-success-content:oklch(26% .065 152.934);--color-warning:oklch(84.8% .1962 84.62);--color-warning-content:oklch(44.8% .1962 84.62);--color-error:oklch(65% .1985 24.22);--color-error-content:oklch(27% .1985 24.22);--radius-selector:2rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=silk]:checked),[data-theme=silk]{color-scheme:light;--color-base-100:oklch(97% .0035 67.78);--color-base-200:oklch(95% .0081 61.42);--color-base-300:oklch(90% .0081 61.42);--color-base-content:oklch(40% .0081 61.42);--color-primary:oklch(23.27% .0249 284.3);--color-primary-content:oklch(94.22% .2505 117.44);--color-secondary:oklch(23.27% .0249 284.3);--color-secondary-content:oklch(73.92% .2135 50.94);--color-accent:oklch(23.27% .0249 284.3);--color-accent-content:oklch(88.92% .2061 189.9);--color-neutral:oklch(20% 0 0);--color-neutral-content:oklch(80% .0081 61.42);--color-info:oklch(80.39% .1148 241.68);--color-info-content:oklch(30.39% .1148 241.68);--color-success:oklch(83.92% .0901 136.87);--color-success-content:oklch(23.92% .0901 136.87);--color-warning:oklch(83.92% .1085 80);--color-warning-content:oklch(43.92% .1085 80);--color-error:oklch(75.1% .1814 22.37);--color-error-content:oklch(35.1% .1814 22.37);--radius-selector:2rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:0}:root:not(span){overflow:var(--page-overflow)}:root{background:var(--page-scroll-bg,var(--root-bg));--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) var(--root-bg,#0000)}@supports (color:color-mix(in lab, red, red)){:root{--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) color-mix(in srgb, var(--root-bg,#0000), oklch(0% 0 0) calc(var(--page-has-backdrop,0) * 40%))}}:root{--page-scroll-transition-on:background-color .3s ease-out;transition:var(--page-scroll-transition);scrollbar-gutter:var(--page-scroll-gutter,unset);scrollbar-gutter:if(style(--page-has-scroll: 1): var(--page-scroll-gutter,unset) ; else: unset)}@keyframes set-page-has-scroll{0%,to{--page-has-scroll:1}}:root{--fx-noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E")}:root,[data-theme]{background:var(--page-scroll-bg,var(--root-bg));color:var(--color-base-content)}:where(:root,[data-theme]){--root-bg:var(--color-base-100)}:root{scrollbar-color:currentColor #0000}@supports (color:color-mix(in lab, red, red)){:root{scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) #0000}}@property --radialprogress{syntax:"<percentage>";inherits:true;initial-value:0%}:where(:root),:root:has(input.theme-controller[value=mitto]:checked),[data-theme=mitto]{color-scheme:dark;--color-base-100:var(--mitto-bg);--color-base-200:var(--mitto-surface-2);--color-base-300:var(--mitto-surface-3);--color-base-content:var(--mitto-text);--color-primary:var(--mitto-accent);--color-primary-content:var(--mitto-accent-fg);--color-secondary:var(--mitto-accent-hover);--color-secondary-content:var(--mitto-accent-fg);--color-accent:var(--mitto-accent);--color-accent-content:var(--mitto-accent-fg);--color-neutral:var(--mitto-surface-4);--color-neutral-content:var(--mitto-text);--color-info:var(--mitto-info);--color-info-content:#fff;--color-success:var(--mitto-success);--color-success-content:#fff;--color-warning:var(--mitto-warning);--color-warning-content:#000;--color-error:var(--mitto-danger);--color-error-content:var(--mitto-danger-fg);--radius-selector:.5rem;--radius-field:.5rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}}@layer components;@layer utilities{@layer daisyui.l1.l2.l3{.diff{webkit-user-select:none;-webkit-user-select:none;user-select:none;direction:ltr;grid-template-rows:1fr 1.8rem 1fr;grid-template-columns:auto 1fr;width:100%;display:grid;position:relative;overflow:hidden;container-type:inline-size}.diff:focus-visible,.diff:has(.diff-item-1:focus-visible),.diff:focus-visible{outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content)}.diff:focus-visible .diff-resizer{min-width:95cqi;max-width:95cqi}.diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px}.diff:has(.diff-item-1:focus-visible) .diff-resizer{min-width:5cqi;max-width:5cqi}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}.modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.modal::backdrop{display:none}:where(.drawer-side){overflow:hidden}.drawer-side{pointer-events:none;visibility:hidden;z-index:10;overscroll-behavior:contain;opacity:0;width:100%;transition:opacity .2s ease-out .1s allow-discrete, visibility .3s ease-out .1s allow-discrete;inset-inline-start:0;background-color:#0000;grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:repeat(1,minmax(0,1fr));grid-row-start:1;grid-column-start:1;place-items:flex-start start;height:100dvh;display:grid;position:fixed;top:0}.drawer-side>.drawer-overlay{cursor:pointer;background-color:oklch(0% 0 0/.4);place-self:stretch stretch;position:sticky;top:0}.drawer-side>*{grid-row-start:1;grid-column-start:1}.drawer-side>:not(.drawer-overlay){will-change:transform;transition:translate .3s ease-out,width .2s ease-out;translate:-100%}[dir=rtl] :is(.drawer-side>:not(.drawer-overlay)){translate:100%}.drawer-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:currentColor oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4))}@supports (color:color-mix(in lab, red, red)){:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4))}}:where(.drawer-toggle:checked~.drawer-side)>:not(.drawer-overlay){will-change:auto;transform:none}:where(:root:has(.drawer-toggle:checked)){--page-has-backdrop:1;--page-overflow:hidden;--page-scroll-bg:var(--page-scroll-bg-on);--page-scroll-gutter:stable;--page-scroll-transition:var(--page-scroll-transition-on);animation:forwards set-page-has-scroll;animation-timeline:scroll()}.tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-tail:calc(100% + 1px + .25rem);display:inline-block;position:relative}.tooltip>.tooltip-content,.tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;line-height:1.25;position:absolute}.tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;-webkit-mask-position:-1px 0;mask-position:-1px 0;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-image:var(--mask-tooltip);-webkit-mask-image:var(--mask-tooltip);mask-image:var(--mask-tooltip);display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.tooltip>.tooltip-content,.tooltip[data-tip]:before,.tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem}@media (prefers-reduced-motion:no-preference){:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}.tab{cursor:pointer;appearance:none;text-align:center;webkit-user-select:none;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative}@media (hover:hover){.tab:hover{color:var(--color-base-content)}}.tab{--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem}.tab:is(input[type=radio]){min-width:fit-content}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:is(label){position:relative}.tab:is(label) input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}:is(.tab:checked,.tab:is(label:has(:checked)),.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content{display:block}.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.tab:not(input):empty{cursor:default;flex-grow:1}.tab:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.tab:focus{outline-offset:2px;outline:2px solid #0000}}.tab:focus-visible,.tab:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}.tab[disabled]{pointer-events:none;opacity:.4}.\!menu{--menu-active-fg:var(--color-neutral-content)!important;--menu-active-bg:var(--color-neutral)!important;flex-flow:column wrap!important;width:fit-content!important;padding:.5rem!important;font-size:.875rem!important;display:flex!important}.\!menu :where(li ul){white-space:nowrap!important;margin-inline-start:1rem!important;padding-inline-start:.5rem!important;position:relative!important}.\!menu :where(li ul):before{background-color:var(--color-base-content)!important;opacity:.1!important;width:var(--border)!important;content:""!important;inset-inline-start:0!important;position:absolute!important;top:.75rem!important;bottom:.75rem!important}.\!menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none!important}.\!menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.\!menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field)!important;text-align:start!important;text-wrap:balance!important;-webkit-user-select:none!important;user-select:none!important;grid-auto-columns:minmax(auto,max-content) auto max-content!important;grid-auto-flow:column!important;align-content:flex-start!important;align-items:center!important;gap:.5rem!important;padding-block:.375rem!important;padding-inline:.75rem!important;transition-property:color,background-color,box-shadow!important;transition-duration:.2s!important;transition-timing-function:cubic-bezier(0,0,.2,1)!important;display:grid!important}.\!menu :where(li>details>summary){--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.\!menu :where(li>details>summary){outline-offset:2px!important;outline:2px solid #0000!important}}.\!menu :where(li>details>summary)::-webkit-details-marker{display:none!important}:is(.\!menu :where(li>details>summary),.\!menu :where(li>.menu-dropdown-toggle)):after{content:""!important;transform-origin:50%!important;pointer-events:none!important;justify-self:flex-end!important;width:.375rem!important;height:.375rem!important;transition-property:rotate,translate!important;transition-duration:.2s!important;display:block!important;translate:0 -1px!important;rotate:-135deg!important;box-shadow:inset 2px 2px!important}.\!menu details{interpolate-size:allow-keywords!important;overflow:hidden!important}.\!menu details::details-content{block-size:0!important}@media (prefers-reduced-motion:no-preference){.\!menu details::details-content{transition-behavior:allow-discrete!important;transition-property:block-size,content-visibility!important;transition-duration:.2s!important;transition-timing-function:cubic-bezier(0,0,.2,1)!important}}.\!menu details[open]::details-content{block-size:auto!important}.\!menu :where(li>details[open]>summary):after,.\!menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px!important;rotate:45deg!important}.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{cursor:pointer!important;background-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)!important}}.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{color:var(--color-base-content)!important;--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{outline-offset:2px!important;outline:2px solid #0000!important}}.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){cursor:pointer!important;background-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)!important}}.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){outline-offset:2px!important;outline:2px solid #0000!important}}.\!menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)!important}.\!menu :where(li:empty){background-color:var(--color-base-content)!important;opacity:.1!important;height:1px!important;margin:.5rem 1rem!important}.\!menu :where(li){flex-flow:column wrap!important;flex-shrink:0!important;align-items:stretch!important;display:flex!important;position:relative!important}.\!menu :where(li) .badge{justify-self:flex-end!important}.\!menu :where(li)>:not(ul,.menu-title,details,.btn):active,.\!menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.\!menu :where(li)>details>summary:active{--tw-outline-style:none!important;outline-style:none!important}@media (forced-colors:active){.\!menu :where(li)>:not(ul,.menu-title,details,.btn):active,.\!menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.\!menu :where(li)>details>summary:active{outline-offset:2px!important;outline:2px solid #0000!important}}.\!menu :where(li)>:not(ul,.menu-title,details,.btn):active,.\!menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.\!menu :where(li)>details>summary:active{color:var(--menu-active-fg)!important;background-color:var(--menu-active-bg)!important;background-size:auto, calc(var(--noise) * 100%)!important;background-image:none, var(--fx-noise)!important}:is(.\!menu :where(li)>:not(ul,.menu-title,details,.btn):active,.\!menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.\!menu :where(li)>details>summary:active):not(:is(.\!menu :where(li)>:not(ul,.menu-title,details,.btn):active,.\!menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.\!menu :where(li)>details>summary:active):active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)!important}.\!menu :where(li).menu-disabled{pointer-events:none!important;color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.\!menu :where(li).menu-disabled{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)!important}}.\!menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px!important;rotate:45deg!important}.\!menu .dropdown-content{margin-top:.5rem!important;padding:.5rem!important}.\!menu .dropdown-content:before{display:none!important}.menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.menu :where(li ul){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative}.menu :where(li ul):before{background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";inset-inline-start:0;position:absolute;top:.75rem;bottom:.75rem}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;text-wrap:balance;-webkit-user-select:none;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.menu :where(li>details>summary){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li>details>summary){outline-offset:2px;outline:2px solid #0000}}.menu :where(li>details>summary)::-webkit-details-marker{display:none}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.menu details{interpolate-size:allow-keywords;overflow:hidden}.menu details::details-content{block-size:0}@media (prefers-reduced-motion:no-preference){.menu details::details-content{transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.menu details[open]::details-content{block-size:auto}.menu :where(li>details[open]>summary):after,.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{color:var(--color-base-content);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative}.menu :where(li) .badge{justify-self:flex-end}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{outline-offset:2px;outline:2px solid #0000}}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):not(:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}.menu :where(li).menu-disabled{pointer-events:none;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li).menu-disabled{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.menu .dropdown-content{margin-top:.5rem;padding:.5rem}.menu .dropdown-content:before{display:none}.dock{z-index:1;background-color:var(--color-base-100);color:currentColor;border-top:.5px solid var(--color-base-content);flex-direction:row;justify-content:space-around;align-items:center;width:100%;padding:.5rem;display:flex;position:fixed;bottom:0;left:0;right:0}@supports (color:color-mix(in lab, red, red)){.dock{border-top:.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000)}}.dock{height:4rem;height:calc(4rem + env(safe-area-inset-bottom));padding-bottom:env(safe-area-inset-bottom)}.dock>*{cursor:pointer;border-radius:var(--radius-box);background-color:#0000;flex-direction:column;flex-shrink:1;flex-basis:100%;justify-content:center;align-items:center;gap:1px;max-width:8rem;height:100%;margin-bottom:.5rem;transition:opacity .2s ease-out;display:flex;position:relative}@media (hover:hover){.dock>:hover{opacity:.8}}:is(.dock>[aria-disabled=true],.dock>[disabled]),:is(.dock>[aria-disabled=true],.dock>[disabled]):hover{pointer-events:none;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.dock>[aria-disabled=true],.dock>[disabled]),:is(.dock>[aria-disabled=true],.dock>[disabled]):hover{color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}:is(.dock>[aria-disabled=true],.dock>[disabled]),:is(.dock>[aria-disabled=true],.dock>[disabled]):hover{opacity:1}.dock>* .dock-label{font-size:.6875rem}.dock>:after{content:"";background-color:#0000;border-top:3px solid #0000;border-radius:3.40282e38px;width:1.5rem;height:.25rem;transition:background-color .1s ease-out,text-color .1s ease-out,width .1s ease-out;position:absolute;bottom:.2rem}.dropdown{position-area:var(--anchor-v,bottom) var(--anchor-h,span-right);display:inline-block;position:relative}.dropdown>:not(:has(~[class*=dropdown-content])):focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.dropdown>:not(:has(~[class*=dropdown-content])):focus{outline-offset:2px;outline:2px solid #0000}}.dropdown .dropdown-content{position:absolute}.dropdown.dropdown-close .dropdown-content,.dropdown:not(details,.dropdown-open,.dropdown-hover:hover,:focus-within) .dropdown-content,.dropdown.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible)~.dropdown-content{transform-origin:top;opacity:0;display:none;scale:.95}.dropdown[popover],.dropdown .dropdown-content{z-index:999}@media (prefers-reduced-motion:no-preference){.dropdown[popover],.dropdown .dropdown-content{transition-behavior:allow-discrete;transition-property:opacity,scale,display,overlay;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s dropdown}}@starting-style{.dropdown[popover],.dropdown .dropdown-content{opacity:0;scale:.95}}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within)>[tabindex]:first-child{pointer-events:none}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within) .dropdown-content,.dropdown:not(.dropdown-close).dropdown-hover:hover .dropdown-content{opacity:1;scale:1}.dropdown:is(details) summary::-webkit-details-marker{display:none}.dropdown:where([popover]){background:0 0}.dropdown[popover]{color:inherit;position:fixed}@supports not (position-area:bottom){.dropdown[popover]{margin:auto}.dropdown[popover].dropdown-close{transform-origin:top;opacity:0;display:none;scale:.95}.dropdown[popover].dropdown-open:not(:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}.dropdown[popover]::backdrop{background-color:oklab(0% none none/.3)}}:is(.dropdown[popover].dropdown-close,.dropdown[popover]:not(.dropdown-open,:popover-open)){transform-origin:top;opacity:0;display:none;scale:.95}:where(.btn){width:unset}.btn{cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;webkit-user-select:none;-webkit-user-select:none;user-select:none;padding-inline:var(--btn-p);color:var(--btn-fg);--tw-prose-links:var(--btn-fg);height:var(--size);font-size:var(--fontsize,.875rem);outline-color:var(--btn-color,var(--color-base-content));background-color:var(--btn-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--btn-noise);border-width:var(--border);border-style:solid;border-color:var(--btn-border);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));touch-action:manipulation;box-shadow:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-p:1rem;--btn-border:var(--btn-bg);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;font-weight:600;transition-property:color,background-color,border-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}@supports (color:color-mix(in lab, red, red)){.btn{--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%))}}.btn{--btn-shadow:0 3px 2px -2px var(--btn-bg), 0 4px 3px -2px var(--btn-bg)}@supports (color:color-mix(in lab, red, red)){.btn{--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.btn{--btn-noise:var(--fx-noise)}@media (hover:hover){.btn:hover{--btn-bg:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}}.btn:focus-visible,.btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}.btn:active:not(.btn-active){--btn-bg:var(--btn-color,var(--color-base-200));translate:0 .5px}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%)}}.btn:active:not(.btn-active){--btn-border:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}.btn:active:not(.btn-active){--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0)}.btn:is(input[type=checkbox],input[type=radio]){appearance:none}.btn:is(input[type=checkbox],input[type=radio])[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}.btn:where(input:checked:not(.filter .btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);isolation:isolate}.\!loading{pointer-events:none!important;aspect-ratio:1!important;vertical-align:middle!important;width:calc(var(--size-selector,.25rem) * 6)!important;background-color:currentColor!important;display:inline-block!important;-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")!important;mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")!important;-webkit-mask-position:50%!important;mask-position:50%!important;-webkit-mask-size:100%!important;mask-size:100%!important;-webkit-mask-repeat:no-repeat!important;mask-repeat:no-repeat!important}.loading{pointer-events:none;aspect-ratio:1;vertical-align:middle;width:calc(var(--size-selector,.25rem) * 6);background-color:currentColor;display:inline-block;-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:100%;mask-size:100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.countdown{display:inline-flex}.countdown>*{visibility:hidden;--value-v:calc(mod(max(0, var(--value)), 1000));--value-hundreds:calc(round(to-zero, var(--value-v) / 100, 1));--value-tens:calc(round(to-zero, mod(var(--value-v), 100) / 10, 1));--value-ones:calc(mod(var(--value-v), 100));--show-hundreds:clamp(clamp(0, var(--digits,1) - 2, 1), var(--value-hundreds), 1);--show-tens:clamp(clamp(0, var(--digits,1) - 1, 1), var(--value-tens) + var(--show-hundreds), 1);--first-digits:calc(round(to-zero, var(--value-v) / 10, 1));height:1em;width:calc(1ch + var(--show-tens) * 1ch + var(--show-hundreds) * 1ch);direction:ltr;transition:width .4s ease-out .2s;display:inline-block;position:relative;overflow-y:clip}.countdown>:before,.countdown>:after{visibility:visible;--tw-content:"00\a 01\a 02\a 03\a 04\a 05\a 06\a 07\a 08\a 09\a 10\a 11\a 12\a 13\a 14\a 15\a 16\a 17\a 18\a 19\a 20\a 21\a 22\a 23\a 24\a 25\a 26\a 27\a 28\a 29\a 30\a 31\a 32\a 33\a 34\a 35\a 36\a 37\a 38\a 39\a 40\a 41\a 42\a 43\a 44\a 45\a 46\a 47\a 48\a 49\a 50\a 51\a 52\a 53\a 54\a 55\a 56\a 57\a 58\a 59\a 60\a 61\a 62\a 63\a 64\a 65\a 66\a 67\a 68\a 69\a 70\a 71\a 72\a 73\a 74\a 75\a 76\a 77\a 78\a 79\a 80\a 81\a 82\a 83\a 84\a 85\a 86\a 87\a 88\a 89\a 90\a 91\a 92\a 93\a 94\a 95\a 96\a 97\a 98\a 99\a ";content:var(--tw-content);font-variant-numeric:tabular-nums;white-space:pre;text-align:end;direction:rtl;transition:all 1s cubic-bezier(1,0,0,1),width .2s ease-out .2s,opacity .2s ease-out .2s;position:absolute;overflow-x:clip}.countdown>:before{width:calc(1ch + var(--show-hundreds) * 1ch);top:calc(var(--first-digits) * -1em);opacity:var(--show-tens);inset-inline-end:0}.countdown>:after{width:1ch;top:calc(var(--value-ones) * -1em);inset-inline-start:0}.collapse{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;overflow:hidden}@media (prefers-reduced-motion:no-preference){.collapse{transition:grid-template-rows .2s}}.collapse>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;z-index:1;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),.collapse:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){content-visibility:visible;min-height:fit-content}@supports not (content-visibility:visible){.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){visibility:visible}}.collapse:focus-visible,.collapse:has(>input:is([type=checkbox],[type=radio]):focus-visible),.collapse:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}.collapse:not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-close)>input[type=radio]:not(:checked),.collapse:not(.collapse-close)>.collapse-title{cursor:pointer}:is(.collapse[tabindex]:focus:not(.collapse-close,.collapse[open]),.collapse[tabindex]:focus-within:not(.collapse-close,.collapse[open]))>.collapse-title{cursor:unset}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}.collapse:is(details){width:100%}@media (prefers-reduced-motion:no-preference){.collapse:is(details)::details-content{transition:content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords;height:0}.collapse:is(details):where([open])::details-content{height:auto}}.collapse:is(details) summary{display:block;position:relative}.collapse:is(details) summary::-webkit-details-marker{display:none}.collapse:is(details)>.collapse-content{content-visibility:visible}.collapse:is(details) summary{outline:none}.collapse-content{content-visibility:hidden;min-height:0;cursor:unset;grid-row-start:2;grid-column-start:1;padding-left:1rem;padding-right:1rem}@supports not (content-visibility:hidden){.collapse-content{visibility:hidden}}@media (prefers-reduced-motion:no-preference){.collapse-content{transition:content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out}}.validator:user-valid{--input-color:var(--color-success)}.validator:user-valid:focus{--input-color:var(--color-success)}.validator:user-valid:checked{--input-color:var(--color-success)}.validator:user-valid[aria-checked=true]{--input-color:var(--color-success)}.validator:user-valid:focus-within{--input-color:var(--color-success)}.validator:has(:user-valid){--input-color:var(--color-success)}.validator:has(:user-valid):focus{--input-color:var(--color-success)}.validator:has(:user-valid):checked{--input-color:var(--color-success)}.validator:has(:user-valid)[aria-checked=true]{--input-color:var(--color-success)}.validator:has(:user-valid):focus-within{--input-color:var(--color-success)}.validator:user-invalid{--input-color:var(--color-error)}.validator:user-invalid:focus{--input-color:var(--color-error)}.validator:user-invalid:checked{--input-color:var(--color-error)}.validator:user-invalid[aria-checked=true]{--input-color:var(--color-error)}.validator:user-invalid:focus-within{--input-color:var(--color-error)}.validator:user-invalid~.validator-hint{visibility:visible;color:var(--color-error)}.validator:has(:user-invalid){--input-color:var(--color-error)}.validator:has(:user-invalid):focus{--input-color:var(--color-error)}.validator:has(:user-invalid):checked{--input-color:var(--color-error)}.validator:has(:user-invalid)[aria-checked=true]{--input-color:var(--color-error)}.validator:has(:user-invalid):focus-within{--input-color:var(--color-error)}.validator:has(:user-invalid)~.validator-hint{visibility:visible;color:var(--color-error)}:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))),:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):focus,:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):checked,:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))[aria-checked=true],:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):focus-within{--input-color:var(--color-error)}:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))~.validator-hint{visibility:visible;color:var(--color-error)}.list{flex-direction:column;font-size:.875rem;display:flex}.list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:var(--color-base-content);position:absolute;bottom:0}@supports (color:color-mix(in lab, red, red)){:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent)}}.toast{translate:var(--toast-x,0) var(--toast-y,0);inset-inline:auto 1rem;background-color:#0000;flex-direction:column;gap:.5rem;width:max-content;max-width:calc(100vw - 2rem);display:flex;position:fixed;top:auto;bottom:1rem}@media (prefers-reduced-motion:no-preference){.toast>*{animation:.25s ease-out toast}}.toggle{border:var(--border) solid currentColor;color:var(--input-color);cursor:pointer;appearance:none;vertical-align:middle;webkit-user-select:none;-webkit-user-select:none;user-select:none;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)));padding:var(--toggle-p);flex-shrink:0;grid-template-columns:0fr 1fr 1fr;place-content:center;display:inline-grid;position:relative;box-shadow:inset 0 1px}@supports (color:color-mix(in lab, red, red)){.toggle{box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset}}.toggle{--input-color:var(--color-base-content);transition:color .3s,grid-template-columns .2s}@supports (color:color-mix(in lab, red, red)){.toggle{--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000)}}.toggle{--toggle-p:calc(var(--size) * .125);--size:calc(var(--size-selector,.25rem) * 6);width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);height:var(--size)}.toggle>*{z-index:1;cursor:pointer;appearance:none;background-color:#0000;border:none;grid-column:2/span 1;grid-row-start:1;height:100%;padding:.125rem;transition:opacity .2s,rotate .4s}.toggle>:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.toggle>:focus{outline-offset:2px;outline:2px solid #0000}}.toggle>:nth-child(2){color:var(--color-base-100);rotate:0deg}.toggle>:nth-child(3){color:var(--color-base-100);opacity:0;rotate:-15deg}.toggle:has(:checked)>:nth-child(2){opacity:0;rotate:15deg}.toggle:has(:checked)>:nth-child(3){opacity:1;rotate:0deg}.toggle:before{aspect-ratio:1;border-radius:var(--radius-selector);--tw-content:"";content:var(--tw-content);width:100%;height:100%;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor;background-color:currentColor;grid-row-start:1;grid-column-start:2;transition:background-color .1s,translate .2s,inset-inline-start .2s;position:relative;inset-inline-start:0;translate:0}@supports (color:color-mix(in lab, red, red)){.toggle:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)}}.toggle:before{background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}@media (forced-colors:active){.toggle:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{.toggle:before{outline-offset:-1rem;outline:.25rem solid}}.toggle:focus-visible,.toggle:has(:focus-visible){outline-offset:2px;outline:2px solid}.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked){background-color:var(--color-base-100);--input-color:var(--color-base-content);grid-template-columns:1fr 1fr 0fr}:is(.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked)):before{background-color:currentColor}@starting-style{:is(.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked)):before{opacity:0}}.toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr}.toggle:disabled{cursor:not-allowed;opacity:.3}.toggle:disabled:before{border:var(--border) solid currentColor;background-color:#0000}.input{cursor:text;border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,.875rem), .875rem);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}@supports (color:color-mix(in lab, red, red)){.input{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.input{--size:calc(var(--size-field,.25rem) * 10);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.input:where(input){display:inline-flex}.input :where(input){appearance:none;background-color:#0000;border:none;width:100%;height:100%;display:inline-flex}.input :where(input):focus,.input :where(input):focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.input :where(input):focus,.input :where(input):focus-within{outline-offset:2px;outline:2px solid #0000}}.input :where(input[type=url]),.input :where(input[type=email]){direction:ltr}.input :where(input[type=date]){display:inline-flex}.input:focus,.input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.input:focus,.input:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.input:focus,.input:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.input:focus,.input:focus-within{--font-size:1rem}}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{box-shadow:none}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.input[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input::-webkit-calendar-picker-indicator{position:absolute;inset-inline-end:.75em}.input:has(>input[type=date]) :where(input[type=date]){webkit-appearance:none;appearance:none;display:inline-flex}.input:has(>input[type=date]) input[type=date]::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute;inset-inline-end:.75em}.indicator{width:max-content;display:inline-flex;position:relative}.indicator :where(.indicator-item){z-index:1;white-space:nowrap;top:var(--indicator-t,0);bottom:var(--indicator-b,auto);left:var(--indicator-s,auto);right:var(--indicator-e,0);translate:var(--indicator-x,50%) var(--indicator-y,-50%);position:absolute}.table{border-collapse:separate;--tw-border-spacing-x:calc(.25rem * 0);--tw-border-spacing-y:calc(.25rem * 0);width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(.table tr.row-hover,.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.table :where(thead,tfoot){white-space:nowrap;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead,tfoot){color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.table :where(thead,tfoot){font-size:.875rem;font-weight:600}.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}.table :where(.table-pin-rows thead tr){z-index:1;background-color:var(--color-base-100);position:sticky;top:0}.table :where(.table-pin-rows tfoot tr){z-index:1;background-color:var(--color-base-100);position:sticky;bottom:0}.table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}.steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid}.steps .step:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}.steps .step>.step-icon,.steps .step:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}.steps .step:first-child:before{--tw-content:none;content:var(--tw-content)}.steps .step[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}.range{appearance:none;webkit-appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:currentColor}@supports (color:color-mix(in lab, red, red)){.range{--range-bg:color-mix(in oklab, currentColor 10%, #0000)}}.range{cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden}[dir=rtl] .range{--range-dir:-1}.range:focus{outline:none}.range:focus-visible{outline-offset:2px;outline:2px solid}.range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.range::-webkit-slider-runnable-track{border:1px solid}.range::-moz-range-track{border:1px solid}}.range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;appearance:none;webkit-appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));position:relative;top:50%;transform:translateY(-50%)}@supports (color:color-mix(in lab, red, red)){.range::-webkit-slider-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}.range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));background-color:currentColor;position:relative;top:50%}@supports (color:color-mix(in lab, red, red)){.range::-moz-range-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range:disabled{cursor:not-allowed;opacity:.3}.chat-bubble{border-radius:var(--radius-field);background-color:var(--color-base-300);width:fit-content;color:var(--color-base-content);grid-row-end:3;min-width:2.5rem;max-width:90%;min-height:2rem;padding-block:.5rem;padding-inline:1rem;display:block;position:relative}.chat-bubble:before{background-color:inherit;content:"";width:.75rem;height:.75rem;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-image:var(--mask-chat);-webkit-mask-image:var(--mask-chat);mask-image:var(--mask-chat);position:absolute;bottom:0;-webkit-mask-position:0 -1px;mask-position:0 -1px;-webkit-mask-size:.8125rem;mask-size:.8125rem}.select{border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);height:var(--size);touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;font-size:.875rem;display:inline-flex;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.select{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.select{border-color:var(--input-color);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.select{--size:calc(var(--size-field,.25rem) * 10)}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}[dir=rtl] .select::picker(select){translate:.5rem}[dir=rtl] .select select::picker(select){translate:.5rem}.select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem}.select select:focus,.select select:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.select select:focus,.select select:focus-within{outline-offset:2px;outline:2px solid #0000}}.select select:not(:last-child){background-image:none;margin-inline-end:-1.375rem}.select:focus,.select:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.select:focus,.select:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.select:focus,.select:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.select:has(>select[disabled])>select[disabled]{cursor:not-allowed}@supports (appearance:base-select){.select,.select select{appearance:base-select}:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) optgroup{padding-top:.5em}:is(.select,.select select) optgroup option:first-child{margin-top:.5em}:is(.select,.select select) option{border-radius:var(--radius-field);white-space:normal;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{outline-offset:2px;outline:2px solid #0000}}:is(.select,.select select) option:not(:disabled):active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}.timeline{display:flex;position:relative}.timeline>li{grid-template-rows:var(--timeline-row-start,minmax(0, 1fr)) auto var(--timeline-row-end,minmax(0, 1fr));grid-template-columns:var(--timeline-col-start,minmax(0, 1fr)) auto var(--timeline-col-end,minmax(0, 1fr));flex-shrink:0;align-items:center;display:grid;position:relative}.timeline>li>hr{border:none;width:100%}.timeline>li>hr:first-child{grid-row-start:2;grid-column-start:1}.timeline>li>hr:last-child{grid-area:2/3/auto/none}@media print{.timeline>li>hr{border:.1px solid var(--color-base-300)}}.timeline :where(hr){background-color:var(--color-base-300);height:.25rem}.timeline:has(.timeline-middle hr):first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.timeline:has(.timeline-middle hr):last-child,.timeline:not(:has(.timeline-middle)) :first-child hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.timeline:not(:has(.timeline-middle)) :last-child hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.swap{cursor:pointer;vertical-align:middle;webkit-user-select:none;-webkit-user-select:none;user-select:none;place-content:center;display:inline-grid;position:relative}.swap input{appearance:none;border:none}.swap>*{grid-row-start:1;grid-column-start:1}@media (prefers-reduced-motion:no-preference){.swap>*{transition-property:transform,rotate,opacity;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.swap .swap-on,.swap .swap-indeterminate,.swap input:indeterminate~.swap-on,.swap input:is(:checked,:indeterminate)~.swap-off{opacity:0}.swap input:checked~.swap-on,.swap input:indeterminate~.swap-indeterminate{opacity:1;backface-visibility:visible}.collapse-title{grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out;position:relative}.checkbox{border:var(--border) solid var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox{cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1))}:is(.checkbox:checked,.checkbox[aria-checked=true]):before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1}@media (forced-colors:active){:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}@media print{:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}.checkbox:indeterminate{background-color:var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox:indeterminate{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox:indeterminate:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -35%;rotate:0deg}.radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}@supports (color:color-mix(in lab, red, red)){.radio{border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000))}}.radio{box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor)}.radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.radio:focus-visible{outline:2px solid}.radio:checked,.radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor}@media (prefers-reduced-motion:no-preference){.radio:checked,.radio[aria-checked=true]{animation:.2s ease-out radio}}:is(.radio:checked,.radio[aria-checked=true]):before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){:is(.radio:checked,.radio[aria-checked=true]):before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{:is(.radio:checked,.radio[aria-checked=true]):before{outline-offset:-1rem;outline:.25rem solid}}.drawer{grid-auto-columns:max-content auto;width:100%;display:grid;position:relative}.card{border-radius:var(--radius-box);outline-offset:2px;outline:0 solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.card:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.card:focus{outline-offset:2px;outline:2px solid #0000}}.card:focus-visible{outline-color:currentColor}.card :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.card :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.card figure{justify-content:center;align-items:center;display:flex}.card:has(>input:is(input[type=checkbox],input[type=radio])){cursor:pointer;-webkit-user-select:none;user-select:none}.card:has(>:checked){outline:2px solid}.stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.progress{appearance:none;border-radius:var(--radius-box);background-color:currentColor;width:100%;height:.5rem;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.progress{background-color:color-mix(in oklab, currentcolor 20%, transparent)}}.progress{color:var(--color-base-content)}.progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%}@media (prefers-reduced-motion:no-preference){.progress:indeterminate{animation:5s ease-in-out infinite progress}}@supports ((-moz-appearance:none)){.progress:indeterminate::-moz-progress-bar{background-color:#0000}@media (prefers-reduced-motion:no-preference){.progress:indeterminate::-moz-progress-bar{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}.progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor}}@supports ((-webkit-appearance:none)){.progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor}}.hero-content{isolation:isolate;justify-content:center;align-items:center;gap:1rem;max-width:80rem;padding:1rem;display:flex}.textarea{border:var(--border) solid #0000;appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,.875rem), .875rem);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}@supports (color:color-mix(in lab, red, red)){.textarea{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.textarea{--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.textarea textarea{appearance:none;background-color:#0000;border:none}.textarea textarea:focus,.textarea textarea:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.textarea textarea:focus,.textarea textarea:focus-within{outline-offset:2px;outline:2px solid #0000}}.textarea:focus,.textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.textarea:focus,.textarea:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.textarea:focus,.textarea:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.textarea:focus,.textarea:focus-within{--font-size:1rem}}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){box-shadow:none}.textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.stack{grid-template-rows:3px 4px 1fr 4px 3px;grid-template-columns:3px 4px 1fr 4px 3px;display:inline-grid}.stack>*{width:100%;height:100%}.stack>:nth-child(n+2){opacity:.7;width:100%}.stack>:nth-child(2){z-index:2;opacity:.9}.stack>:first-child{z-index:3;width:100%}.modal-backdrop{color:#0000;z-index:-1;grid-row-start:1;grid-column-start:1;place-self:stretch stretch;display:grid}.modal-backdrop button{cursor:pointer}.hero{background-position:50%;background-size:cover;place-items:center;width:100%;display:grid}.hero>*{grid-row-start:1;grid-column-start:1}.modal-box{background-color:var(--color-base-100);border-top-left-radius:var(--modal-tl,var(--radius-box));border-top-right-radius:var(--modal-tr,var(--radius-box));border-bottom-left-radius:var(--modal-bl,var(--radius-box));border-bottom-right-radius:var(--modal-br,var(--radius-box));opacity:0;overscroll-behavior:contain;grid-row-start:1;grid-column-start:1;width:91.6667%;max-width:32rem;max-height:100vh;padding:1.5rem;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out;overflow-y:auto;scale:.95;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)}.drawer-content{grid-row-start:1;grid-column-start:2;min-width:0}.divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:var(--color-base-content);flex-direction:row;align-self:stretch;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.divider{--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.divider:before,.divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.divider:before,.divider:after{border:.5px solid}}.divider:not(:empty){gap:1rem}.filter{flex-wrap:wrap;display:flex}.filter input[type=radio]{width:auto}.filter input{opacity:1;transition:margin .1s,opacity .3s,padding .3s,border-width .1s;overflow:hidden;scale:1}.filter input:not(:last-child){margin-inline-end:.25rem}.filter input.filter-reset{aspect-ratio:1}.filter input.filter-reset:after{--tw-content:"×";content:var(--tw-content)}.filter:not(:has(input:checked:not(.filter-reset))) .filter-reset,.filter:not(:has(input:checked:not(.filter-reset))) input[type=reset],.filter:has(input:checked:not(.filter-reset)) input:not(:checked,.filter-reset,input[type=reset]){opacity:0;border-width:0;width:0;margin-inline:0;padding-inline:0;scale:0}.label{white-space:nowrap;color:currentColor;align-items:center;gap:.375rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.label{color:color-mix(in oklab, currentcolor 60%, transparent)}}.label:has(input){cursor:pointer}.label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex}.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid currentColor;margin-inline:-.75rem .75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid currentColor;margin-inline:.75rem -.75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.modal-action{justify-content:flex-end;gap:.5rem;margin-top:1.5rem;display:flex}.fieldset-legend{color:var(--color-base-content);justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:-.25rem;padding-block:.5rem;font-weight:600;display:flex}.status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:var(--color-base-content);width:.5rem;height:.5rem;display:inline-block}@supports (color:color-mix(in lab, red, red)){.status{background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.status{vertical-align:middle;color:#0000004d;background-position:50%;background-repeat:no-repeat}@supports (color:color-mix(in lab, red, red)){.status{color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.status{background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px}@supports (color:color-mix(in lab, red, red)){.status{box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000)}}.badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}.kbd{border-radius:var(--radius-field);background-color:var(--color-base-200);vertical-align:middle;border:var(--border) solid var(--color-base-content);justify-content:center;align-items:center;padding-inline:.5em;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.kbd{border:var(--border) solid color-mix(in srgb, var(--color-base-content) 20%, #0000)}}.kbd{border-bottom:calc(var(--border) + 1px) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.kbd{border-bottom:calc(var(--border) + 1px) solid color-mix(in srgb, var(--color-base-content) 20%, #0000)}}.kbd{--size:calc(var(--size-selector,.25rem) * 6);height:var(--size);min-width:var(--size);font-size:.875rem}.tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem) * 10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.footer>*{place-items:start;gap:.5rem;display:grid}.footer.footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.footer.footer-center>*{place-items:center}.stat{grid-template-columns:repeat(1,1fr);column-gap:1rem;width:100%;padding-block:1rem;padding-inline:1.5rem;display:inline-grid}.stat:not(:last-child){border-inline-end:var(--border) dashed currentColor}@supports (color:color-mix(in lab, red, red)){.stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000)}}.stat:not(:last-child){border-block-end:none}.alert{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px #000, 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid}@supports (color:color-mix(in lab, red, red)){.alert{box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08))}}.alert:has(:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}.fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");grid-auto-rows:min-content;column-gap:.75rem;padding-block:.25rem;display:grid}.mask{vertical-align:middle;display:inline-block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.mask\!{vertical-align:middle!important;display:inline-block!important;-webkit-mask-position:50%!important;mask-position:50%!important;-webkit-mask-size:contain!important;mask-size:contain!important;-webkit-mask-repeat:no-repeat!important;mask-repeat:no-repeat!important}.skeleton{border-radius:var(--radius-box);background-color:var(--color-base-300)}@media (prefers-reduced-motion:reduce){.skeleton{transition-duration:15s}}.skeleton{will-change:background-position;background-image:linear-gradient(105deg, #0000 0% 40%, var(--color-base-100) 50%, #0000 60% 100%);background-position-x:-50%;background-size:200%}@media (prefers-reduced-motion:no-preference){.skeleton{animation:1.8s ease-in-out infinite skeleton}}.link{cursor:pointer;text-decoration-line:underline}.link:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.link:focus{outline-offset:2px;outline:2px solid #0000}}.link:focus-visible{outline-offset:2px;outline:2px solid}.menu-title{color:var(--color-base-content);padding-block:.5rem;padding-inline:.75rem}@supports (color:color-mix(in lab, red, red)){.menu-title{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}.menu-title{font-size:.875rem;font-weight:600}.btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content)}.btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.btn-success{--btn-color:var(--color-success);--btn-fg:var(--color-success-content)}.btn-warning{--btn-color:var(--color-warning);--btn-fg:var(--color-warning-content)}}.\@container{container-type:inline-size}@layer daisyui.l1.l2{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4)}:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal) .modal-box{opacity:1;translate:0;scale:1}:root:has(:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal)){--page-has-backdrop:1;--page-overflow:hidden;--page-scroll-bg:var(--page-scroll-bg-on);--page-scroll-gutter:stable;--page-scroll-transition:var(--page-scroll-transition-on);animation:forwards set-page-has-scroll;animation-timeline:scroll()}@starting-style{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{opacity:0}}:where(.drawer-toggle:checked~.drawer-side){pointer-events:auto;visibility:visible;opacity:1;overflow-y:auto}:where(.drawer-toggle:checked~.drawer-side)>:not(.drawer-overlay){translate:0%}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline:2px solid}.tooltip>.tooltip-content,.tooltip[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.tooltip:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.collapse-arrow>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;transform:translateY(-100%)rotate(45deg)}@media (prefers-reduced-motion:no-preference){.collapse-arrow>.collapse-title:after{transition-property:all;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}}.collapse-arrow>.collapse-title:after{content:"";transform-origin:75% 75%;pointer-events:none;top:50%;inset-inline-end:1.4rem;box-shadow:2px 2px}.collapse-plus>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.collapse-plus>.collapse-title:after{transition-property:all;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1)}}.collapse-plus>.collapse-title:after{--tw-content:"+";content:var(--tw-content);pointer-events:none;top:.9rem;inset-inline-end:1.4rem}.btn:disabled:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn:disabled:not(.btn-link,.btn-ghost){box-shadow:none}.btn:disabled{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}.btn[disabled]:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn[disabled]:not(.btn-link,.btn-ghost){box-shadow:none}.btn[disabled]{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}.btn-disabled:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn-disabled:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn-disabled:not(.btn-link,.btn-ghost){box-shadow:none}.btn-disabled{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn-disabled{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}@media (prefers-reduced-motion:no-preference){.collapse[open].collapse-arrow>.collapse-title:after,.collapse.collapse-open.collapse-arrow>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}.collapse.collapse-open.collapse-plus>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}:is(.collapse[tabindex].collapse-arrow:focus:not(.collapse-close),.collapse.collapse-arrow[tabindex]:focus-within:not(.collapse-close))>.collapse-title:after,.collapse.collapse-arrow:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}.collapse[open].collapse-plus>.collapse-title:after,.collapse[tabindex].collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse.collapse-plus:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}.collapse-open{grid-template-rows:max-content 1fr}.collapse-open>.collapse-content{content-visibility:visible;min-height:fit-content;padding-bottom:1rem}@supports not (content-visibility:visible){.collapse-open>.collapse-content{visibility:visible}}.tabs-lift{--tabs-height:auto;--tabs-direction:row}.tabs-lift>.tab{--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;border-width:var(--tab-border);padding:var(--tab-paddings);border-color:var(--tab-border-colors);border-start-start-radius:var(--tab-radius-ss);border-start-end-radius:var(--tab-radius-se);border-end-end-radius:var(--tab-radius-ee);border-end-start-radius:var(--tab-radius-es)}.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad));background-color:var(--tab-bg)}:is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):before{z-index:1;content:"";width:var(--tab-corner-width);height:var(--tab-corner-height);background-position:var(--tab-corner-position);background-image:var(--radius-start), var(--radius-end);background-size:var(--tab-radius-limit) var(--tab-radius-limit);inset:var(--tab-inset);background-repeat:no-repeat;display:block;position:absolute}:is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):first-child:before{--radius-start:none}[dir=rtl] :is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):first-child:before{transform:rotateY(180deg)}:is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):last-child:before{--radius-end:none}[dir=rtl] :is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):last-child:before{transform:rotateY(180deg)}.tabs-lift:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.tabs-lift>.tab-content{--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.list .list-row:has(.list-col-grow:first-child){--list-grid-cols:1fr}.list .list-row:has(.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row>*{grid-row-start:1}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after,.steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.steps .step-primary+.step-primary:before,.steps .step-primary:after,.steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after,.steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.steps .step-accent+.step-accent:before,.steps .step-accent:after,.steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.steps .step-info+.step-info:before,.steps .step-info:after,.steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.steps .step-success+.step-success:before,.steps .step-success:after,.steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.steps .step-warning+.step-warning:before,.steps .step-warning:after,.steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.steps .step-error+.step-error:before,.steps .step-error:after,.steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.tabs-border>.tab{--tab-border-color:#0000 #0000 var(--tab-border-color) #0000;border-radius:var(--radius-field);position:relative}.tabs-border>.tab:before{content:"";background-color:var(--tab-border-color);border-radius:var(--radius-field);width:80%;height:3px;transition:background-color .2s;position:absolute;bottom:0;left:10%}:is(.tabs-border>.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-border>.tab:is(input:checked),.tabs-border>.tab:is(label:has(:checked))):before{--tab-border-color:currentColor;border-top:3px solid}.checkbox:disabled,.radio:disabled{cursor:not-allowed;opacity:.2}.tooltip-bottom>.tooltip-content,.tooltip-bottom[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,-.25rem));inset:var(--tt-off) auto auto 50%}.tooltip-bottom:after{transform:translateX(-50%) translateY(var(--tt-pos,-.25rem)) rotate(180deg);inset:var(--tt-tail) auto auto 50%}.tooltip-left>.tooltip-content,.tooltip-left[data-tip]:before{transform:translateX(calc(var(--tt-pos,.25rem) - .25rem)) translateY(-50%);inset:50% var(--tt-off) auto auto}.tooltip-left:after{transform:translateX(var(--tt-pos,.25rem)) translateY(-50%) rotate(-90deg);inset:50% calc(var(--tt-tail) + 1px) auto auto}.tooltip-right>.tooltip-content,.tooltip-right[data-tip]:before{transform:translateX(calc(var(--tt-pos,-.25rem) + .25rem)) translateY(-50%);inset:50% auto auto var(--tt-off)}.tooltip-right:after{transform:translateX(var(--tt-pos,-.25rem)) translateY(-50%) rotate(90deg);inset:50% auto auto calc(var(--tt-tail) + 1px)}.tooltip-top>.tooltip-content,.tooltip-top[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.tooltip-top:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.toast-end{--toast-x:0;inset-inline:auto 1rem}.dropdown-end{--anchor-h:span-left}.dropdown-end :where(.dropdown-content){inset-inline-end:0;translate:0}[dir=rtl] :is(.dropdown-end :where(.dropdown-content)){translate:0}.dropdown-end.dropdown-left{--anchor-h:left;--anchor-v:span-top}.dropdown-end.dropdown-left .dropdown-content{top:auto;bottom:0}.dropdown-end.dropdown-right{--anchor-h:right;--anchor-v:span-top}.dropdown-end.dropdown-right .dropdown-content{top:auto;bottom:0}.toast-top{--toast-y:0;top:1rem;bottom:auto}.btn-active{--btn-bg:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn-active{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}.btn-active{--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0);isolation:isolate}:is(.stack,.stack.stack-bottom)>*{grid-area:3/3/6/4}:is(.stack,.stack.stack-bottom)>:nth-child(2){grid-area:2/2/5/5}:is(.stack,.stack.stack-bottom)>:first-child{grid-area:1/1/4/6}.stack.stack-top>*{grid-area:1/3/4/4}.stack.stack-top>:nth-child(2){grid-area:2/2/5/5}.stack.stack-top>:first-child{grid-area:3/1/6/6}.stack.stack-start>*{grid-area:3/1/4/4}.stack.stack-start>:nth-child(2){grid-area:2/2/5/5}.stack.stack-start>:first-child{grid-area:1/3/6/6}.stack.stack-end>*{grid-area:3/3/4/6}.stack.stack-end>:nth-child(2){grid-area:2/2/5/5}.stack.stack-end>:first-child{grid-area:1/1/6/4}.drawer-end{grid-auto-columns:auto max-content}.drawer-end>.drawer-toggle~.drawer-content{grid-column-start:1}.drawer-end>.drawer-toggle~.drawer-side{grid-column-start:2;justify-items:end}.drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:100%}[dir=rtl] :is(.drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay)){translate:-100%}.drawer-end>.drawer-toggle:checked~.drawer-side>:not(.drawer-overlay){translate:0%}.input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:max(var(--font-size,.75rem), .75rem)}.input-sm[type=number]::-webkit-inner-spin-button{margin-block:-.5rem;margin-inline-end:-.75rem}.input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:max(var(--font-size,.6875rem), .6875rem)}.input-xs[type=number]::-webkit-inner-spin-button{margin-block:-.25rem;margin-inline-end:-.75rem}.btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}.btn-square{width:var(--size);height:var(--size);padding-inline:0}.loading-lg{width:calc(var(--size-selector,.25rem) * 7)}.loading-xs{width:calc(var(--size-selector,.25rem) * 4)}.swap-rotate .swap-on,.swap-rotate input:indeterminate~.swap-on{rotate:45deg}.swap-rotate input:is(:checked,:indeterminate)~.swap-on,.swap-rotate.swap-active .swap-on{rotate:0deg}.swap-rotate input:is(:checked,:indeterminate)~.swap-off,.swap-rotate.swap-active .swap-off{rotate:-45deg}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.625rem;font-size:.75rem}.menu-sm .menu-title{padding-block:.5rem;padding-inline:.75rem}.badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.badge-soft{color:var(--badge-color,var(--color-base-content));background-color:var(--badge-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.badge-soft{background-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 8%, var(--color-base-100))}}.badge-soft{border-color:var(--badge-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.badge-soft{border-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 10%, var(--color-base-100))}}.badge-soft{background-image:none}.badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}:where(:not(ul,details,.menu-title,.btn)).menu-active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:where(:not(ul,details,.menu-title,.btn)).menu-active{outline-offset:2px;outline:2px solid #0000}}:where(:not(ul,details,.menu-title,.btn)).menu-active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}.skeleton-text{webkit-background-clip:text;color:#0000;-webkit-background-clip:text;background-clip:text;background-image:linear-gradient(105deg, var(--color-base-content) 0% 40%, var(--color-base-content) 50%, var(--color-base-content) 60% 100%)}@supports (color:color-mix(in lab, red, red)){.skeleton-text{background-image:linear-gradient(105deg, color-mix(in oklab, var(--color-base-content) 20%, transparent) 0% 40%, var(--color-base-content) 50%, color-mix(in oklab, var(--color-base-content) 20%, transparent) 60% 100%)}}.loading-ring{-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='44' height='44' viewBox='0 0 44 44' xmlns='http://www.w3.org/2000/svg' stroke='white'%3E%3Cg fill='none' fill-rule='evenodd' stroke-width='2'%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='0s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='0s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='-0.9s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='-0.9s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg width='44' height='44' viewBox='0 0 44 44' xmlns='http://www.w3.org/2000/svg' stroke='white'%3E%3Cg fill='none' fill-rule='evenodd' stroke-width='2'%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='0s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='0s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='-0.9s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='-0.9s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")}.loading-spinner{-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")}.checkbox-sm{--size:calc(var(--size-selector,.25rem) * 5);padding:.1875rem}.radio-sm{padding:.1875rem}.radio-sm[type=radio]{--size:calc(var(--size-selector,.25rem) * 5)}.select-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem}.select-sm option{padding-block:.25rem;padding-inline:.625rem}.select-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem}.select-xs option{padding-block:.25rem;padding-inline:.5rem}.table-sm :not(thead,tfoot) tr{font-size:.75rem}.table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.badge-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.badge-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.kbd-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.textarea-sm{font-size:max(var(--font-size,.75rem), .75rem)}.alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.alert-info{color:var(--color-info-content);--alert-border-color:var(--color-info);--alert-color:var(--color-info)}.alert-success{color:var(--color-success-content);--alert-border-color:var(--color-success);--alert-color:var(--color-success)}.alert-warning{color:var(--color-warning-content);--alert-border-color:var(--color-warning);--alert-color:var(--color-warning)}.checkbox-accent{color:var(--color-accent-content);--input-color:var(--color-accent)}.checkbox-primary{color:var(--color-primary-content);--input-color:var(--color-primary)}.tooltip-accent{--tt-bg:var(--color-accent)}.tooltip-accent>.tooltip-content,.tooltip-accent[data-tip]:before{color:var(--color-accent-content)}.tooltip-error{--tt-bg:var(--color-error)}.tooltip-error>.tooltip-content,.tooltip-error[data-tip]:before{color:var(--color-error-content)}.tooltip-info{--tt-bg:var(--color-info)}.tooltip-info>.tooltip-content,.tooltip-info[data-tip]:before{color:var(--color-info-content)}.tooltip-primary{--tt-bg:var(--color-primary)}.tooltip-primary>.tooltip-content,.tooltip-primary[data-tip]:before{color:var(--color-primary-content)}.tooltip-secondary{--tt-bg:var(--color-secondary)}.tooltip-secondary>.tooltip-content,.tooltip-secondary[data-tip]:before{color:var(--color-secondary-content)}.tooltip-success{--tt-bg:var(--color-success)}.tooltip-success>.tooltip-content,.tooltip-success[data-tip]:before{color:var(--color-success-content)}.tooltip-warning{--tt-bg:var(--color-warning)}.tooltip-warning>.tooltip-content,.tooltip-warning[data-tip]:before{color:var(--color-warning-content)}.btn-md{--fontsize:.875rem;--btn-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.badge-primary{--badge-color:var(--color-primary);--badge-fg:var(--color-primary-content)}.badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.radio-error{--input-color:var(--color-error)}.toggle-primary:checked,.toggle-primary[aria-checked=true]{--input-color:var(--color-primary)}}.pointer-events-none{pointer-events:none}.countdown.countdown{line-height:1em}.collapse:not(td,tr,colgroup){visibility:revert-layer}.validator:user-invalid~.validator-hint{display:revert-layer}.validator:has(:user-invalid)~.validator-hint{display:revert-layer}:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))~.validator-hint{display:revert-layer}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:calc(var(--spacing) * 0)}.top-2{top:calc(var(--spacing) * 2)}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:calc(var(--spacing) * 0)}.right-1{right:calc(var(--spacing) * 1)}.bottom-full{bottom:100%}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;align-items:stretch;display:inline-flex}.join :where(.join-item){border-start-start-radius:var(--join-ss,0);border-start-end-radius:var(--join-se,0);border-end-end-radius:var(--join-ee,0);border-end-start-radius:var(--join-es,0)}.join :where(.join-item) *{--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}.join>.join-item:where(:first-child),.join :first-child:not(:last-child) :where(.join-item){--join-ss:var(--radius-field);--join-se:0;--join-es:var(--radius-field);--join-ee:0}.join>.join-item:where(:last-child),.join :last-child:not(:first-child) :where(.join-item){--join-ss:0;--join-se:var(--radius-field);--join-es:0;--join-ee:var(--radius-field)}.join>.join-item:where(:only-child),.join :only-child :where(.join-item){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}.join>:where(:focus,:has(:focus)){z-index:1}@media (hover:hover){.join>:where(.btn:hover,:has(.btn:hover)){isolation:isolate}}.isolate{isolation:isolate}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-60{z-index:60}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.m-0{margin:calc(var(--spacing) * 0)}.m-1{margin:calc(var(--spacing) * 1)}.m-2{margin:calc(var(--spacing) * 2)}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.my-4{margin-block:calc(var(--spacing) * 4)}.join-item:where(:not(:first-child,:disabled,[disabled],.btn-disabled)){margin-block-start:0;margin-inline-start:calc(var(--border,1px) * -1)}.join-item:where(:is(:disabled,[disabled],.btn-disabled)){border-width:var(--border,1px) 0 var(--border,1px) var(--border,1px)}.-mt-2{margin-top:calc(var(--spacing) * -2)}.mt-0{margin-top:calc(var(--spacing) * 0)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-7{margin-left:calc(var(--spacing) * 7)}.ml-auto{margin-left:auto}.kbd{box-shadow:none}.alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200))}:root .prose{--tw-prose-body:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-body:color-mix(in oklab, var(--color-base-content) 80%, #0000)}}:root .prose{--tw-prose-headings:var(--color-base-content);--tw-prose-lead:var(--color-base-content);--tw-prose-links:var(--color-base-content);--tw-prose-bold:var(--color-base-content);--tw-prose-counters:var(--color-base-content);--tw-prose-bullets:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-bullets:color-mix(in oklab, var(--color-base-content) 50%, #0000)}}:root .prose{--tw-prose-hr:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-hr:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}:root .prose{--tw-prose-quotes:var(--color-base-content);--tw-prose-quote-borders:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-quote-borders:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}:root .prose{--tw-prose-captions:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-captions:color-mix(in oklab, var(--color-base-content) 50%, #0000)}}:root .prose{--tw-prose-code:var(--color-base-content);--tw-prose-pre-code:var(--color-neutral-content);--tw-prose-pre-bg:var(--color-neutral);--tw-prose-th-borders:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-th-borders:color-mix(in oklab, var(--color-base-content) 50%, #0000)}}:root .prose{--tw-prose-td-borders:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-td-borders:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}:root .prose{--tw-prose-kbd:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root .prose{--tw-prose-kbd:color-mix(in oklab, var(--color-base-content) 80%, #0000)}}:root .prose :where(code):not(pre>code){background-color:var(--color-base-200);border-radius:var(--radius-selector);border:var(--border) solid var(--color-base-300);font-weight:inherit;padding-block:.2em;padding-inline:.5em}:root .prose :where(code):not(pre>code):before,:root .prose :where(code):not(pre>code):after{display:none}.block{display:block}.block\!{display:block!important}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.h-0{height:calc(var(--spacing) * 0)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-24{height:calc(var(--spacing) * 24)}.h-32{height:calc(var(--spacing) * 32)}.h-48{height:calc(var(--spacing) * 48)}.h-\[70vh\]{height:70vh}.h-full{height:100%}.h-screen{height:100vh}.max-h-0{max-height:calc(var(--spacing) * 0)}.max-h-24{max-height:calc(var(--spacing) * 24)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-\[32rem\]{max-height:32rem}.max-h-\[60vh\]{max-height:60vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[150px\]{max-height:150px}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-\[80px\]{min-height:80px}.min-h-screen{min-height:100vh}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-44{width:calc(var(--spacing) * 44)}.w-52{width:calc(var(--spacing) * 52)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-\[70vw\]{width:70vw}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-48{max-width:calc(var(--spacing) * 48)}.max-w-\[80px\]{max-width:80px}.max-w-\[85\%\]{max-width:85%}.max-w-\[95\%\]{max-width:95%}.max-w-\[95vw\]{max-width:95vw}.max-w-\[150px\]{max-width:150px}.max-w-\[200px\]{max-width:200px}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-\[140px\]{min-width:140px}.flex-1{flex:1}.flex-shrink,.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-ns-resize{cursor:ns-resize}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.cursor-wait{cursor:wait}.touch-none{touch-action:none}.resize{resize:both}.resize\!{resize:both!important}.resize-none{resize:none}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:calc(var(--spacing) * 0)}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-700\/50>:not(:last-child)){border-color:#31415880}@supports (color:color-mix(in lab, red, red)){:where(.divide-slate-700\/50>:not(:last-child)){border-color:color-mix(in oklab, var(--color-slate-700) 50%, transparent)}}.self-center{align-self:center}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-box{border-radius:var(--radius-box);border-radius:var(--radius-box)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-br-sm{border-bottom-right-radius:var(--radius-sm)}.rounded-bl-sm{border-bottom-left-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-0\!{border-style:var(--tw-border-style)!important;border-width:0!important}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500\/50{border-color:#f99c0080}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/50{border-color:color-mix(in oklab, var(--color-amber-500) 50%, transparent)}}.border-amber-700\/50{border-color:#b7500080}@supports (color:color-mix(in lab, red, red)){.border-amber-700\/50{border-color:color-mix(in oklab, var(--color-amber-700) 50%, transparent)}}.border-base-300\/50{border-color:var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.border-base-300\/50{border-color:color-mix(in oklab, var(--color-base-300) 50%, transparent)}}.border-error,.border-error\/40{border-color:var(--color-error)}@supports (color:color-mix(in lab, red, red)){.border-error\/40{border-color:color-mix(in oklab, var(--color-error) 40%, transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-mitto-accent{border-color:var(--color-mitto-accent)}.border-mitto-accent-500\/50{border-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.border-mitto-accent-500\/50{border-color:color-mix(in oklab, var(--color-mitto-accent-500) 50%, transparent)}}.border-mitto-accent-500\/60{border-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.border-mitto-accent-500\/60{border-color:color-mix(in oklab, var(--color-mitto-accent-500) 60%, transparent)}}.border-mitto-accent-600{border-color:var(--color-mitto-accent-600)}.border-mitto-border{border-color:var(--color-mitto-border)}.border-mitto-border-1,.border-mitto-border-1\/50{border-color:var(--color-mitto-border-1)}@supports (color:color-mix(in lab, red, red)){.border-mitto-border-1\/50{border-color:color-mix(in oklab, var(--color-mitto-border-1) 50%, transparent)}}.border-mitto-border-2,.border-mitto-border-2\/30{border-color:var(--color-mitto-border-2)}@supports (color:color-mix(in lab, red, red)){.border-mitto-border-2\/30{border-color:color-mix(in oklab, var(--color-mitto-border-2) 30%, transparent)}}.border-mitto-border-2\/40{border-color:var(--color-mitto-border-2)}@supports (color:color-mix(in lab, red, red)){.border-mitto-border-2\/40{border-color:color-mix(in oklab, var(--color-mitto-border-2) 40%, transparent)}}.border-mitto-border-2\/50{border-color:var(--color-mitto-border-2)}@supports (color:color-mix(in lab, red, red)){.border-mitto-border-2\/50{border-color:color-mix(in oklab, var(--color-mitto-border-2) 50%, transparent)}}.border-mitto-border-3{border-color:var(--color-mitto-border-3)}.border-mitto-user-border{border-color:var(--color-mitto-user-border)}.border-purple-500\/30{border-color:#ac4bff4d}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/30{border-color:color-mix(in oklab, var(--color-purple-500) 30%, transparent)}}.border-red-500{border-color:var(--color-red-500)}.border-red-700{border-color:var(--color-red-700)}.border-l-amber-500{border-left-color:var(--color-amber-500)}.border-l-amber-500\/70{border-left-color:#f99c00b3}@supports (color:color-mix(in lab, red, red)){.border-l-amber-500\/70{border-left-color:color-mix(in oklab, var(--color-amber-500) 70%, transparent)}}.border-l-mitto-accent-500\/70{border-left-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.border-l-mitto-accent-500\/70{border-left-color:color-mix(in oklab, var(--color-mitto-accent-500) 70%, transparent)}}.border-l-purple-500{border-left-color:var(--color-purple-500)}.glass{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:#0000;background-image:linear-gradient(135deg, oklch(100% 0 0 / var(--glass-opacity,30%)) 0%, oklch(0% 0 0/0) 100%), linear-gradient(var(--glass-reflect-degree,100deg), oklch(100% 0 0 / var(--glass-reflect-opacity,5%)) 25%, oklch(0% 0 0/0) 25%);box-shadow:0 0 0 1px oklch(100% 0 0 / var(--glass-border-opacity,20%)) inset, 0 0 0 2px oklch(0% 0 0/.05);text-shadow:0 1px oklch(0% 0 0 / var(--glass-text-shadow-opacity,5%));border:none}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-600{background-color:var(--color-amber-600)}.bg-amber-700{background-color:var(--color-amber-700)}.bg-base-200,.bg-base-200\/95{background-color:var(--color-base-200)}@supports (color:color-mix(in lab, red, red)){.bg-base-200\/95{background-color:color-mix(in oklab, var(--color-base-200) 95%, transparent)}}.bg-base-300,.bg-base-300\/50{background-color:var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.bg-base-300\/50{background-color:color-mix(in oklab, var(--color-base-300) 50%, transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-cyan-800{background-color:var(--color-cyan-800)}.bg-error{background-color:var(--color-error)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-green-400{background-color:var(--color-green-400)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/20{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/20{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.bg-green-600\/80{background-color:#00a544cc}@supports (color:color-mix(in lab, red, red)){.bg-green-600\/80{background-color:color-mix(in oklab, var(--color-green-600) 80%, transparent)}}.bg-green-700{background-color:var(--color-green-700)}.bg-green-900{background-color:var(--color-green-900)}.bg-mitto-accent{background-color:var(--color-mitto-accent)}.bg-mitto-accent-400{background-color:var(--color-mitto-accent-400)}.bg-mitto-accent-500\/10{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-500\/10{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 10%, transparent)}}.bg-mitto-accent-500\/20{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-500\/20{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 20%, transparent)}}.bg-mitto-accent-500\/40{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-500\/40{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 40%, transparent)}}.bg-mitto-accent-600,.bg-mitto-accent-600\/10{background-color:var(--color-mitto-accent-600)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-600\/10{background-color:color-mix(in oklab, var(--color-mitto-accent-600) 10%, transparent)}}.bg-mitto-accent-600\/80{background-color:var(--color-mitto-accent-600)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-accent-600\/80{background-color:color-mix(in oklab, var(--color-mitto-accent-600) 80%, transparent)}}.bg-mitto-accent-900{background-color:var(--color-mitto-accent-900)}.bg-mitto-accent-fg{background-color:var(--color-mitto-accent-fg)}.bg-mitto-agent{background-color:var(--color-mitto-agent)}.bg-mitto-bg{background-color:var(--color-mitto-bg)}.bg-mitto-border{background-color:var(--color-mitto-border)}.bg-mitto-danger{background-color:var(--color-mitto-danger)}.bg-mitto-input{background-color:var(--color-mitto-input)}.bg-mitto-input-box{background-color:var(--color-mitto-input-box)}.bg-mitto-sidebar{background-color:var(--color-mitto-sidebar)}.bg-mitto-success{background-color:var(--color-mitto-success)}.bg-mitto-surface-2,.bg-mitto-surface-2\/50{background-color:var(--color-mitto-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-2\/50{background-color:color-mix(in oklab, var(--color-mitto-surface-2) 50%, transparent)}}.bg-mitto-surface-3,.bg-mitto-surface-3\/20{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-3\/20{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 20%, transparent)}}.bg-mitto-surface-3\/30{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-3\/30{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 30%, transparent)}}.bg-mitto-surface-3\/50{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-3\/50{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 50%, transparent)}}.bg-mitto-surface-3\/95{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-3\/95{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 95%, transparent)}}.bg-mitto-surface-4,.bg-mitto-surface-4\/30{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-4\/30{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 30%, transparent)}}.bg-mitto-surface-4\/50{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-4\/50{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 50%, transparent)}}.bg-mitto-surface-4\/80{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.bg-mitto-surface-4\/80{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 80%, transparent)}}.bg-mitto-surface-hover{background-color:var(--color-mitto-surface-hover)}.bg-mitto-user{background-color:var(--color-mitto-user)}.bg-orange-500\/20{background-color:#fe6e0033}@supports (color:color-mix(in lab, red, red)){.bg-orange-500\/20{background-color:color-mix(in oklab, var(--color-orange-500) 20%, transparent)}}.bg-primary{background-color:var(--color-primary)}.bg-purple-500\/20{background-color:#ac4bff33}@supports (color:color-mix(in lab, red, red)){.bg-purple-500\/20{background-color:color-mix(in oklab, var(--color-purple-500) 20%, transparent)}}.bg-purple-600\/80{background-color:#9810facc}@supports (color:color-mix(in lab, red, red)){.bg-purple-600\/80{background-color:color-mix(in oklab, var(--color-purple-600) 80%, transparent)}}.bg-purple-700{background-color:var(--color-purple-700)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/20{background-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/20{background-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.bg-red-600{background-color:var(--color-red-600)}.bg-red-700{background-color:var(--color-red-700)}.bg-red-900\/50{background-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/50{background-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.bg-secondary{background-color:var(--color-secondary)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-500{background-color:var(--color-slate-500)}.bg-success{background-color:var(--color-success)}.bg-transparent{background-color:#0000}.bg-warning{background-color:var(--color-warning)}.bg-white{background-color:var(--color-white)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-yellow-500\/20{background-color:#edb20033}@supports (color:color-mix(in lab, red, red)){.bg-yellow-500\/20{background-color:color-mix(in oklab, var(--color-yellow-500) 20%, transparent)}}@layer daisyui.l1{.alert-soft{color:var(--alert-color,var(--color-base-content));background:var(--alert-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.alert-soft{background:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 8%, var(--color-base-100))}}.alert-soft{--alert-border-color:var(--alert-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.alert-soft{--alert-border-color:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 10%, var(--color-base-100))}}.alert-soft{box-shadow:none;background-image:none}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)){--btn-shadow:"";--btn-bg:#0000;--btn-border:#0000;--btn-noise:none}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)):not(:disabled,[disabled],.btn-disabled){--btn-fg:var(--btn-color,currentColor);outline-color:currentColor}@media (hover:none){.btn-ghost:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:"";--btn-bg:#0000;--btn-fg:var(--btn-color,currentColor);--btn-border:#0000;--btn-noise:none;outline-color:currentColor}}.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-shadow:"";--btn-fg:var(--btn-color,var(--color-base-content));--btn-bg:var(--btn-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--color-base-100))}}.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-border:var(--btn-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--color-base-100))}}.btn-soft:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-noise:none}@media (hover:none){.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:"";--btn-fg:var(--btn-color,var(--color-base-content));--btn-bg:var(--btn-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--color-base-100))}}.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-border:var(--btn-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--color-base-100))}}.btn-soft:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-noise:none}}}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-6{padding-right:calc(var(--spacing) * 6)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-7{padding-left:calc(var(--spacing) * 7)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.5625rem\]{font-size:.5625rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.wrap-break-word{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-400{color:var(--color-amber-400)}.text-amber-400\/70{color:#fcbb00b3}@supports (color:color-mix(in lab, red, red)){.text-amber-400\/70{color:color-mix(in oklab, var(--color-amber-400) 70%, transparent)}}.text-amber-400\/80{color:#fcbb00cc}@supports (color:color-mix(in lab, red, red)){.text-amber-400\/80{color:color-mix(in oklab, var(--color-amber-400) 80%, transparent)}}.text-amber-500\/70{color:#f99c00b3}@supports (color:color-mix(in lab, red, red)){.text-amber-500\/70{color:color-mix(in oklab, var(--color-amber-500) 70%, transparent)}}.text-blue-100{color:var(--color-blue-100)}.text-cyan-100{color:var(--color-cyan-100)}.text-error{color:var(--color-error)}.text-error-content{color:var(--color-error-content)}.text-gray-400{color:var(--color-gray-400)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-100{color:var(--color-green-100)}.text-green-300{color:var(--color-green-300)}.text-green-400{color:var(--color-green-400)}.text-mitto-accent{color:var(--color-mitto-accent)}.text-mitto-accent-300{color:var(--color-mitto-accent-300)}.text-mitto-accent-400{color:var(--color-mitto-accent-400)}.text-mitto-accent-500{color:var(--color-mitto-accent-500)}.text-mitto-accent-fg,.text-mitto-accent-fg\/80{color:var(--color-mitto-accent-fg)}@supports (color:color-mix(in lab, red, red)){.text-mitto-accent-fg\/80{color:color-mix(in oklab, var(--color-mitto-accent-fg) 80%, transparent)}}.text-mitto-border-3{color:var(--color-mitto-border-3)}.text-mitto-danger{color:var(--color-mitto-danger)}.text-mitto-danger-fg{color:var(--color-mitto-danger-fg)}.text-mitto-success{color:var(--color-mitto-success)}.text-mitto-text{color:var(--color-mitto-text)}.text-mitto-text-300{color:var(--color-mitto-text-300)}.text-mitto-text-500{color:var(--color-mitto-text-500)}.text-mitto-text-muted,.text-mitto-text-muted\/60{color:var(--color-mitto-text-muted)}@supports (color:color-mix(in lab, red, red)){.text-mitto-text-muted\/60{color:color-mix(in oklab, var(--color-mitto-text-muted) 60%, transparent)}}.text-mitto-text-secondary{color:var(--color-mitto-text-secondary)}.text-mitto-text-strong{color:var(--color-mitto-text-strong)}.text-mitto-user-text{color:var(--color-mitto-user-text)}.text-mitto-warning{color:var(--color-mitto-warning)}.text-orange-400{color:var(--color-orange-400)}.text-primary-content{color:var(--color-primary-content)}.text-purple-100{color:var(--color-purple-100)}.text-purple-300{color:var(--color-purple-300)}.text-purple-400{color:var(--color-purple-400)}.text-red-100{color:var(--color-red-100)}.text-red-200{color:var(--color-red-200)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-secondary-content{color:var(--color-secondary-content)}.text-success{color:var(--color-success)}.text-success-content{color:var(--color-success-content)}.text-warning-content{color:var(--color-warning-content)}.text-white{color:var(--color-white)}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.text-yellow-500{color:var(--color-yellow-500)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-500\/50{--tw-ring-color:#f99c0080}@supports (color:color-mix(in lab, red, red)){.ring-amber-500\/50{--tw-ring-color:color-mix(in oklab, var(--color-amber-500) 50%, transparent)}}.ring-mitto-accent{--tw-ring-color:var(--color-mitto-accent)}.ring-mitto-accent-400\/50{--tw-ring-color:var(--color-mitto-accent-400)}@supports (color:color-mix(in lab, red, red)){.ring-mitto-accent-400\/50{--tw-ring-color:color-mix(in oklab, var(--color-mitto-accent-400) 50%, transparent)}}.ring-mitto-accent-500{--tw-ring-color:var(--color-mitto-accent-500)}.ring-mitto-border-3{--tw-ring-color:var(--color-mitto-border-3)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[--radius-field\:0\.25rem\]{--radius-field:.25rem}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-slate-500:is(:where(.group):hover *){background-color:var(--color-slate-500)}.group-hover\:text-mitto-text-strong:is(:where(.group):hover *){color:var(--color-mitto-text-strong)}.group-hover\:text-red-400:is(:where(.group):hover *){color:var(--color-red-400)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:text-gray-400::placeholder{color:var(--color-gray-400)}.placeholder\:text-mitto-text-muted::placeholder{color:var(--color-mitto-text-muted)}.placeholder\:text-mitto-text-secondary::placeholder{color:var(--color-mitto-text-secondary)}.after\:hidden:after{content:var(--tw-content);display:none}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.focus-within\:border-mitto-text-secondary:focus-within{border-color:var(--color-mitto-text-secondary)}@media (hover:hover){.hover\:border-mitto-accent:hover{border-color:var(--color-mitto-accent)}.hover\:border-mitto-accent-500\/50:hover{border-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.hover\:border-mitto-accent-500\/50:hover{border-color:color-mix(in oklab, var(--color-mitto-accent-500) 50%, transparent)}}.hover\:border-mitto-text-secondary:hover{border-color:var(--color-mitto-text-secondary)}.hover\:border-l-amber-500:hover{border-left-color:var(--color-amber-500)}.hover\:bg-amber-800:hover{background-color:var(--color-amber-800)}.hover\:bg-base-200\/40:hover{background-color:var(--color-base-200)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-base-200\/40:hover{background-color:color-mix(in oklab, var(--color-base-200) 40%, transparent)}}.hover\:bg-base-300\/50:hover{background-color:var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-base-300\/50:hover{background-color:color-mix(in oklab, var(--color-base-300) 50%, transparent)}}.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-mitto-accent-500\/30:hover{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-accent-500\/30:hover{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 30%, transparent)}}.hover\:bg-mitto-accent-500\/50:hover{background-color:var(--color-mitto-accent-500)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-accent-500\/50:hover{background-color:color-mix(in oklab, var(--color-mitto-accent-500) 50%, transparent)}}.hover\:bg-mitto-accent-700:hover{background-color:var(--color-mitto-accent-700)}.hover\:bg-mitto-danger-hover:hover{background-color:var(--color-mitto-danger-hover)}.hover\:bg-mitto-input-box:hover{background-color:var(--color-mitto-input-box)}.hover\:bg-mitto-surface-3\/30:hover{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-surface-3\/30:hover{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 30%, transparent)}}.hover\:bg-mitto-surface-3\/50:hover{background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-surface-3\/50:hover{background-color:color-mix(in oklab, var(--color-mitto-surface-3) 50%, transparent)}}.hover\:bg-mitto-surface-4\/30:hover{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-surface-4\/30:hover{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 30%, transparent)}}.hover\:bg-mitto-surface-4\/50:hover{background-color:var(--color-mitto-surface-4)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mitto-surface-4\/50:hover{background-color:color-mix(in oklab, var(--color-mitto-surface-4) 50%, transparent)}}.hover\:bg-mitto-surface-hover:hover{background-color:var(--color-mitto-surface-hover)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-600\/80:hover{background-color:#e40014cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-600\/80:hover{background-color:color-mix(in oklab, var(--color-red-600) 80%, transparent)}}.hover\:bg-red-800:hover{background-color:var(--color-red-800)}.hover\:bg-red-900\/50:hover{background-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-900\/50:hover{background-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.hover\:text-error:hover{color:var(--color-error)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-mitto-accent:hover{color:var(--color-mitto-accent)}.hover\:text-mitto-accent-300:hover{color:var(--color-mitto-accent-300)}.hover\:text-mitto-accent-400:hover{color:var(--color-mitto-accent-400)}.hover\:text-mitto-accent-fg:hover{color:var(--color-mitto-accent-fg)}.hover\:text-mitto-danger:hover{color:var(--color-mitto-danger)}.hover\:text-mitto-text-200:hover{color:var(--color-mitto-text-200)}.hover\:text-mitto-text-300:hover{color:var(--color-mitto-text-300)}.hover\:text-mitto-text-secondary:hover{color:var(--color-mitto-text-secondary)}.hover\:text-mitto-text-strong:hover{color:var(--color-mitto-text-strong)}.hover\:text-red-300:hover{color:var(--color-red-300)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-blue-300:focus{border-color:var(--color-blue-300)}.focus\:border-mitto-accent:focus{border-color:var(--color-mitto-accent)}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-blue-300:focus{--tw-ring-color:var(--color-blue-300)}.focus\:ring-mitto-accent-500:focus{--tw-ring-color:var(--color-mitto-accent-500)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-mitto-sidebar:focus{--tw-ring-offset-color:var(--color-mitto-sidebar)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:48rem){@layer daisyui.l1.l2.l3{.md\:drawer-open>.drawer-toggle:checked~.drawer-side{scrollbar-color:revert-layer}:root:has(.md\:drawer-open>.drawer-toggle:checked){--page-overflow:revert-layer;--page-scroll-gutter:revert-layer;--page-scroll-bg:revert-layer;--page-scroll-transition:revert-layer;--page-has-backdrop:revert-layer;animation:revert-layer;animation-timeline:revert-layer}}@layer daisyui.l1.l2{.md\:drawer-open>.drawer-side{overflow-y:auto}.md\:drawer-open>.drawer-toggle{display:none}.md\:drawer-open>.drawer-toggle~.drawer-side{pointer-events:auto;visibility:visible;overscroll-behavior:auto;opacity:1;width:auto;display:block;position:sticky}.md\:drawer-open>.drawer-toggle~.drawer-side>.drawer-overlay{cursor:default;background-color:#0000}.md\:drawer-open>.drawer-toggle:checked~.drawer-side{pointer-events:auto;visibility:visible}}@layer daisyui.l1{.md\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay),[dir=rtl] :is(.md\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay)){translate:0%}}.md\:hidden{display:none}.md\:max-w-\[75\%\]{max-width:75%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}.dark\:border-gray-600:where(.dark,.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:where(.dark,.dark *){border-color:var(--color-gray-700)}.dark\:border-mitto-border-2:where(.dark,.dark *){border-color:var(--color-mitto-border-2)}.dark\:bg-blue-600:where(.dark,.dark *){background-color:var(--color-blue-600)}.dark\:bg-gray-700:where(.dark,.dark *){background-color:var(--color-gray-700)}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}.dark\:bg-mitto-surface-2:where(.dark,.dark *){background-color:var(--color-mitto-surface-2)}.dark\:bg-mitto-surface-3:where(.dark,.dark *),.dark\:bg-mitto-surface-3\/95:where(.dark,.dark *){background-color:var(--color-mitto-surface-3)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-mitto-surface-3\/95:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-mitto-surface-3) 95%, transparent)}}.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:where(.dark,.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\:text-gray-500:where(.dark,.dark *){color:var(--color-gray-500)}.dark\:text-mitto-text-300:where(.dark,.dark *){color:var(--color-mitto-text-300)}.dark\:text-mitto-text-500:where(.dark,.dark *){color:var(--color-mitto-text-500)}.dark\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.dark\:placeholder\:text-gray-500:where(.dark,.dark *)::placeholder{color:var(--color-gray-500)}@media (hover:hover){.dark\:hover\:bg-blue-700:where(.dark,.dark *):hover{background-color:var(--color-blue-700)}.dark\:hover\:bg-gray-600:where(.dark,.dark *):hover{background-color:var(--color-gray-600)}.dark\:hover\:bg-gray-700:where(.dark,.dark *):hover{background-color:var(--color-gray-700)}.dark\:hover\:bg-mitto-surface-3:where(.dark,.dark *):hover{background-color:var(--color-mitto-surface-3)}.dark\:hover\:text-gray-300:where(.dark,.dark *):hover{color:var(--color-gray-300)}}.dark\:focus\:border-blue-500:where(.dark,.dark *):focus{border-color:var(--color-blue-500)}.dark\:focus\:ring-blue-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-500)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-bg:var(--color-base-100);--mitto-chat:var(--color-base-100);--mitto-input:var(--color-base-200);--mitto-input-box:var(--color-base-300);--mitto-sidebar:var(--color-base-200);--mitto-agent:var(--color-base-200);--mitto-surface-1:var(--color-base-100);--mitto-surface-2:var(--color-base-200);--mitto-surface-3:var(--color-base-300);--mitto-surface-4:var(--color-neutral);--mitto-surface-hover:var(--color-base-300);--mitto-user:var(--color-base-200);--mitto-user-text:var(--color-base-content);--mitto-user-border:var(--color-primary);--mitto-text:var(--color-base-content);--mitto-text-strong:var(--color-base-content);--mitto-text-secondary:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-secondary:color-mix(in oklch, var(--color-base-content) 65%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-muted:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-muted:color-mix(in oklch, var(--color-base-content) 55%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-200:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-200:color-mix(in oklch, var(--color-base-content) 90%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-300:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-300:color-mix(in oklch, var(--color-base-content) 80%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-500:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-text-500:color-mix(in oklch, var(--color-base-content) 60%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border:color-mix(in oklch, var(--color-base-content) 15%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-1:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-1:color-mix(in oklch, var(--color-base-content) 12%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-2:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-2:color-mix(in oklch, var(--color-base-content) 22%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-3:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-border-3:color-mix(in oklch, var(--color-base-content) 32%, transparent)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent:var(--color-primary);--mitto-accent-hover:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-hover:color-mix(in oklch, var(--color-primary) 85%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-fg:var(--color-primary-content);--mitto-accent-100:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-100:color-mix(in oklch, var(--color-primary) 18%, var(--color-base-100))}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-300:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-300:color-mix(in oklch, var(--color-primary) 45%, var(--color-base-100))}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-400:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-400:color-mix(in oklch, var(--color-primary) 65%, var(--color-base-100))}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-500:var(--color-primary);--mitto-accent-600:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-600:color-mix(in oklch, var(--color-primary) 88%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-700:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-700:color-mix(in oklch, var(--color-primary) 75%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-900:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-accent-900:color-mix(in oklch, var(--color-primary) 55%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-danger:var(--color-error);--mitto-danger-hover:var(--color-error)}@supports (color:color-mix(in lab, red, red)){:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-danger-hover:color-mix(in oklch, var(--color-error) 85%, black)}}:root[data-theme]:not([data-theme=mitto]),:root[data-theme]:not([data-theme=mitto]) body{--mitto-danger-fg:var(--color-error-content);--mitto-success:var(--color-success);--mitto-warning:var(--color-warning);--mitto-info:var(--color-info)}@keyframes rating{0%,40%{filter:brightness(1.05)contrast(1.05);scale:1.1}}@keyframes menu{0%{opacity:0}}@keyframes rotator{89.9999%,to{--first-item-position:0 0%}90%,99.9999%{--first-item-position:0 calc(var(--items) * 100%)}to{translate:0 -100%}}@keyframes progress{50%{background-position-x:-115%}}@keyframes radio{0%{padding:5px}50%{padding:3px}}@keyframes dropdown{0%{opacity:0}}@keyframes toast{0%{opacity:0;scale:.9}to{opacity:1;scale:1}}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/web/static/utils/endpoints.js b/web/static/utils/endpoints.js index 898e490f..35234236 100644 --- a/web/static/utils/endpoints.js +++ b/web/static/utils/endpoints.js @@ -41,6 +41,8 @@ export const endpoints = { apiUrl(`/api/issues/${enc(id)}/comments`) + qs(params), dependencies: (id, params) => apiUrl(`/api/issues/${enc(id)}/dependencies`) + qs(params), + labels: (id, params) => apiUrl(`/api/issues/${enc(id)}/labels`) + qs(params), + labelsAll: (params) => apiUrl("/api/issues/labels") + qs(params), cleanup: (params) => apiUrl("/api/issues/cleanup") + qs(params), config: (params) => apiUrl("/api/issues/config") + qs(params), upstream: (params) => apiUrl("/api/issues/upstream") + qs(params), @@ -60,8 +62,8 @@ export const endpoints = { ws: (id) => wsUrl(`/api/sessions/${enc(id)}/ws`), changes: (id) => apiUrl(`/api/sessions/${enc(id)}/changes`), settings: (id) => apiUrl(`/api/sessions/${enc(id)}/settings`), - periodic: (id) => apiUrl(`/api/sessions/${enc(id)}/periodic`), - periodicRunNow: (id) => apiUrl(`/api/sessions/${enc(id)}/periodic/run-now`), + loop: (id) => apiUrl(`/api/sessions/${enc(id)}/loop`), + loopRunNow: (id) => apiUrl(`/api/sessions/${enc(id)}/loop/run-now`), flush: (id) => apiUrl(`/api/sessions/${enc(id)}/flush`), callback: (id) => apiUrl(`/api/sessions/${enc(id)}/callback`), userData: (id) => apiUrl(`/api/sessions/${enc(id)}/user-data`), @@ -122,6 +124,11 @@ export const endpoints = { shortcuts: (params) => apiUrl("/api/folders/shortcuts") + qs(params), }, + /** Global settings (stored in settings.json). */ + global: { + shortcuts: () => apiUrl("/api/global/shortcuts"), + }, + /** Global server configuration. */ config: { get: (params) => apiUrl("/api/config" + qs(params)), diff --git a/web/static/utils/endpoints.test.js b/web/static/utils/endpoints.test.js index 95c26938..84d3f3f9 100644 --- a/web/static/utils/endpoints.test.js +++ b/web/static/utils/endpoints.test.js @@ -233,13 +233,11 @@ describe("endpoints registry", () => { expect(endpoints.sessions.running()).toBe("/api/sessions/running")); test("get(id)", () => expect(endpoints.sessions.get("s1")).toBe("/api/sessions/s1")); - test("periodic", () => - expect(endpoints.sessions.periodic("s1")).toBe( - "/api/sessions/s1/periodic", - )); - test("periodicRunNow", () => - expect(endpoints.sessions.periodicRunNow("s1")).toBe( - "/api/sessions/s1/periodic/run-now", + test("loop", () => + expect(endpoints.sessions.loop("s1")).toBe("/api/sessions/s1/loop")); + test("loopRunNow", () => + expect(endpoints.sessions.loopRunNow("s1")).toBe( + "/api/sessions/s1/loop/run-now", )); test("queueMove", () => expect(endpoints.sessions.queueMove("s1", "m1")).toBe( diff --git a/web/static/utils/prompts.js b/web/static/utils/prompts.js index 5fae6acf..6fed42c9 100644 --- a/web/static/utils/prompts.js +++ b/web/static/utils/prompts.js @@ -6,7 +6,7 @@ import { endpoints } from "./endpoints.js"; /** * Returns the list of UI menus a prompt opts INTO (positive tokens only). * The `menus` front-matter is a comma-separated list (e.g. "prompts, conversation"). - * Tokens prefixed with `!` (e.g. "!promptsPeriodic") are exclusions and are + * Tokens prefixed with `!` (e.g. "!promptsLoop") are exclusions and are * stripped from the returned list — use `promptMenuExcludes` to read them. * A missing or empty value (after stripping exclusion tokens) defaults to * ["prompts"], so prompts that explicitly target other menus (e.g. "conversation") @@ -25,7 +25,7 @@ export function promptMenus(prompt) { /** * Returns a Set of menu names that a prompt explicitly opts OUT of (the * `!`-prefixed tokens in the `menus` front-matter). For example, for - * `menus: "prompts, !promptsPeriodic"` it returns `new Set(["promptsPeriodic"])`. + * `menus: "prompts, !promptsLoop"` it returns `new Set(["promptsLoop"])`. * Robust to null/undefined/empty (returns an empty Set). * * @param {Object} prompt - Prompt object with optional `menus` string @@ -54,7 +54,7 @@ export function promptMenuExcludes(prompt) { * bare `promptMenus(p).includes(menu)`, so that exclusions are always respected. * * @param {Object} prompt - Prompt object with optional `menus` string - * @param {string} menu - Menu name to check (e.g. "prompts", "promptsPeriodic") + * @param {string} menu - Menu name to check (e.g. "prompts", "promptsLoop") * @returns {boolean} */ export function promptMenuIncludes(prompt, menu) { @@ -73,48 +73,48 @@ export function isSingletonPrompt(prompt) { } /** - * Returns the periodic mode of a prompt: "always" | "optional" | "none". - * - "none" when prompt.periodic is absent/null (never periodic). - * - "optional" when prompt.periodic.mode === "optional". + * Returns the loop mode of a prompt: "always" | "optional" | "none". + * - "none" when prompt.loop is absent/null (never a loop). + * - "optional" when prompt.loop.mode === "optional". * - "always" otherwise (block present with absent/unknown mode → backend default). */ -export function promptPeriodicMode(prompt) { - const periodic = prompt?.periodic; - if (!periodic) return "none"; - return periodic.mode === "optional" ? "optional" : "always"; +export function promptLoopMode(prompt) { + const loop = prompt?.loop; + if (!loop) return "none"; + return loop.mode === "optional" ? "optional" : "always"; } -/** True iff the prompt's periodic mode is "optional" (the only toggleable category). */ -export function promptPeriodicIsToggleable(prompt) { - return promptPeriodicMode(prompt) === "optional"; +/** True iff the prompt's loop mode is "optional" (the only toggleable category). */ +export function promptLoopIsToggleable(prompt) { + return promptLoopMode(prompt) === "optional"; } /** - * Initial send-as-periodic state: + * Initial send-as-loop state: * - "always" → true (locked ON) - * - "optional" → prompt.periodic.default !== false (nil/true → true, false → false) + * - "optional" → prompt.loop.default !== false (nil/true → true, false → false) * - "none" → false */ -export function promptPeriodicDefaultOn(prompt) { - const mode = promptPeriodicMode(prompt); +export function promptLoopDefaultOn(prompt) { + const mode = promptLoopMode(prompt); if (mode === "none") return false; - if (mode === "optional") return prompt.periodic.default !== false; + if (mode === "optional") return prompt.loop.default !== false; return true; } /** - * Resolve whether a given send should be dispatched as periodic. - * @param {object} prompt - the prompt object (may have prompt.periodic with mode/default). + * Resolve whether a given send should be dispatched as a loop. + * @param {object} prompt - the prompt object (may have prompt.loop with mode/default). * @param {boolean} [override] - explicit per-send choice from a UI toggle; only honored for mode "optional". * @returns {boolean} */ -export function promptResolveAsPeriodic(prompt, override) { - const mode = promptPeriodicMode(prompt); - if (mode === "none") return false; // never periodic (override ignored) +export function promptResolveAsLoop(prompt, override) { + const mode = promptLoopMode(prompt); + if (mode === "none") return false; // never a loop (override ignored) if (mode === "always") return true; // locked ON (override ignored) // mode === "optional": if (typeof override === "boolean") return override; - return promptPeriodicDefaultOn(prompt); + return promptLoopDefaultOn(prompt); } /** @@ -181,7 +181,7 @@ export function promptParameters(prompt) { */ export const MENU_PARAM_TYPES = { prompts: [], - promptsPeriodic: [], + promptsLoop: [], conversation: [], beadsIssues: ["beadsId", "beadsTitle"], beadsList: [], diff --git a/web/static/utils/prompts.test.js b/web/static/utils/prompts.test.js index 87c091b4..164aa8a9 100644 --- a/web/static/utils/prompts.test.js +++ b/web/static/utils/prompts.test.js @@ -8,10 +8,10 @@ import { promptMenuExcludes, promptMenuIncludes, isSingletonPrompt, - promptPeriodicMode, - promptPeriodicIsToggleable, - promptPeriodicDefaultOn, - promptResolveAsPeriodic, + promptLoopMode, + promptLoopIsToggleable, + promptLoopDefaultOn, + promptResolveAsLoop, promptParameters, KNOWN_PARAM_TYPES, MENU_PARAM_TYPES, @@ -173,8 +173,8 @@ describe("MENU_PARAM_TYPES", () => { expect(MENU_PARAM_TYPES.prompts).toEqual([]); }); - test("promptsPeriodic menu provides no types", () => { - expect(MENU_PARAM_TYPES.promptsPeriodic).toEqual([]); + test("promptsLoop menu provides no types", () => { + expect(MENU_PARAM_TYPES.promptsLoop).toEqual([]); }); test("conversation menu provides no types", () => { @@ -1045,29 +1045,29 @@ describe("currentModelName", () => { describe("promptMenus — exclusion token stripping", () => { test("strips !-prefixed tokens from the positive list", () => { expect( - promptMenus({ menus: "prompts, !promptsPeriodic" }), + promptMenus({ menus: "prompts, !promptsLoop" }), ).toEqual(["prompts"]); }); test("defaults to ['prompts'] when only exclusion tokens remain", () => { - expect(promptMenus({ menus: "!promptsPeriodic" })).toEqual(["prompts"]); + expect(promptMenus({ menus: "!promptsLoop" })).toEqual(["prompts"]); }); test("strips multiple exclusion tokens", () => { expect( - promptMenus({ menus: "prompts, !promptsPeriodic, !conversation" }), + promptMenus({ menus: "prompts, !promptsLoop, !conversation" }), ).toEqual(["prompts"]); }); test("preserves positive tokens alongside exclusions", () => { expect( - promptMenus({ menus: "prompts, conversation, !promptsPeriodic" }), + promptMenus({ menus: "prompts, conversation, !promptsLoop" }), ).toEqual(["prompts", "conversation"]); }); test("handles whitespace around ! tokens", () => { expect( - promptMenus({ menus: "prompts , ! promptsPeriodic" }), + promptMenus({ menus: "prompts , ! promptsLoop" }), ).toEqual(["prompts"]); }); }); @@ -1093,20 +1093,20 @@ describe("promptMenuExcludes", () => { test("returns Set of excluded menu names without leading !", () => { expect( - promptMenuExcludes({ menus: "prompts, !promptsPeriodic" }), - ).toEqual(new Set(["promptsPeriodic"])); + promptMenuExcludes({ menus: "prompts, !promptsLoop" }), + ).toEqual(new Set(["promptsLoop"])); }); test("returns multiple excluded names", () => { expect( - promptMenuExcludes({ menus: "prompts, !promptsPeriodic, !conversation" }), - ).toEqual(new Set(["promptsPeriodic", "conversation"])); + promptMenuExcludes({ menus: "prompts, !promptsLoop, !conversation" }), + ).toEqual(new Set(["promptsLoop", "conversation"])); }); test("handles whitespace around ! token (defensive)", () => { expect( - promptMenuExcludes({ menus: "prompts, ! promptsPeriodic" }), - ).toEqual(new Set(["promptsPeriodic"])); + promptMenuExcludes({ menus: "prompts, ! promptsLoop" }), + ).toEqual(new Set(["promptsLoop"])); }); test("handles null prompt gracefully", () => { @@ -1135,13 +1135,13 @@ describe("promptMenuIncludes", () => { test("returns false when menu is explicitly excluded", () => { expect( - promptMenuIncludes({ menus: "prompts, !promptsPeriodic" }, "promptsPeriodic"), + promptMenuIncludes({ menus: "prompts, !promptsLoop" }, "promptsLoop"), ).toBe(false); }); test("returns true for a menu that is included but a different menu is excluded", () => { expect( - promptMenuIncludes({ menus: "prompts, !promptsPeriodic" }, "prompts"), + promptMenuIncludes({ menus: "prompts, !promptsLoop" }, "prompts"), ).toBe(true); }); @@ -1149,160 +1149,160 @@ describe("promptMenuIncludes", () => { expect(promptMenuIncludes({}, "prompts")).toBe(true); }); - test("returns false for promptsPeriodic when only !promptsPeriodic specified", () => { + test("returns false for promptsLoop when only !promptsLoop specified", () => { expect( - promptMenuIncludes({ menus: "!promptsPeriodic" }, "promptsPeriodic"), + promptMenuIncludes({ menus: "!promptsLoop" }, "promptsLoop"), ).toBe(false); }); }); // ============================================================================= -// Periodic filter behaviour — union with !promptsPeriodic exclusion +// Loop filter behaviour — union with !promptsLoop exclusion // ============================================================================= -describe("periodic prompt filter logic (union + exclusion)", () => { - // Replicates the periodicPrompts predicate from useWorkspacePrompts.js - function isPeriodicPrompt(p) { - if (promptMenuExcludes(p).has("promptsPeriodic")) return false; +describe("loop prompt filter logic (union + exclusion)", () => { + // Replicates the loopPrompts predicate from useWorkspacePrompts.js + function isLoopPrompt(p) { + if (promptMenuExcludes(p).has("promptsLoop")) return false; const menus = promptMenus(p); - return menus.includes("prompts") || menus.includes("promptsPeriodic"); + return menus.includes("prompts") || menus.includes("promptsLoop"); } - test("prompts-only prompt IS in periodic selector (union rule)", () => { - expect(isPeriodicPrompt({ menus: "prompts" })).toBe(true); + test("prompts-only prompt IS in loop selector (union rule)", () => { + expect(isLoopPrompt({ menus: "prompts" })).toBe(true); }); - test("promptsPeriodic-only prompt IS in periodic selector", () => { - expect(isPeriodicPrompt({ menus: "promptsPeriodic" })).toBe(true); + test("promptsLoop-only prompt IS in loop selector", () => { + expect(isLoopPrompt({ menus: "promptsLoop" })).toBe(true); }); - test("prompt with menus: prompts, !promptsPeriodic is NOT in periodic selector", () => { - expect(isPeriodicPrompt({ menus: "prompts, !promptsPeriodic" })).toBe(false); + test("prompt with menus: prompts, !promptsLoop is NOT in loop selector", () => { + expect(isLoopPrompt({ menus: "prompts, !promptsLoop" })).toBe(false); }); - test("prompt with menus: conversation is NOT in periodic selector", () => { - expect(isPeriodicPrompt({ menus: "conversation" })).toBe(false); + test("prompt with menus: conversation is NOT in loop selector", () => { + expect(isLoopPrompt({ menus: "conversation" })).toBe(false); }); - test("prompt with no menus field IS in periodic selector (defaults to prompts)", () => { - expect(isPeriodicPrompt({})).toBe(true); + test("prompt with no menus field IS in loop selector (defaults to prompts)", () => { + expect(isLoopPrompt({})).toBe(true); }); }); // ============================================================================= -// promptPeriodicMode / promptPeriodicIsToggleable / promptPeriodicDefaultOn +// promptLoopMode / promptLoopIsToggleable / promptLoopDefaultOn // ============================================================================= -describe("promptPeriodicMode / IsToggleable / DefaultOn", () => { - test("no periodic ({}) -> mode none, toggleable false, defaultOn false", () => { - expect(promptPeriodicMode({})).toBe("none"); - expect(promptPeriodicIsToggleable({})).toBe(false); - expect(promptPeriodicDefaultOn({})).toBe(false); +describe("promptLoopMode / IsToggleable / DefaultOn", () => { + test("no loop ({}) -> mode none, toggleable false, defaultOn false", () => { + expect(promptLoopMode({})).toBe("none"); + expect(promptLoopIsToggleable({})).toBe(false); + expect(promptLoopDefaultOn({})).toBe(false); }); - test("periodic: null -> mode none, toggleable false, defaultOn false", () => { - const p = { periodic: null }; - expect(promptPeriodicMode(p)).toBe("none"); - expect(promptPeriodicIsToggleable(p)).toBe(false); - expect(promptPeriodicDefaultOn(p)).toBe(false); + test("loop: null -> mode none, toggleable false, defaultOn false", () => { + const p = { loop: null }; + expect(promptLoopMode(p)).toBe("none"); + expect(promptLoopIsToggleable(p)).toBe(false); + expect(promptLoopDefaultOn(p)).toBe(false); }); - test("periodic present, no mode -> mode always, toggleable false, defaultOn true", () => { - const p = { periodic: {} }; - expect(promptPeriodicMode(p)).toBe("always"); - expect(promptPeriodicIsToggleable(p)).toBe(false); - expect(promptPeriodicDefaultOn(p)).toBe(true); + test("loop present, no mode -> mode always, toggleable false, defaultOn true", () => { + const p = { loop: {} }; + expect(promptLoopMode(p)).toBe("always"); + expect(promptLoopIsToggleable(p)).toBe(false); + expect(promptLoopDefaultOn(p)).toBe(true); }); test("mode: always -> mode always, toggleable false, defaultOn true", () => { - const p = { periodic: { mode: "always" } }; - expect(promptPeriodicMode(p)).toBe("always"); - expect(promptPeriodicIsToggleable(p)).toBe(false); - expect(promptPeriodicDefaultOn(p)).toBe(true); + const p = { loop: { mode: "always" } }; + expect(promptLoopMode(p)).toBe("always"); + expect(promptLoopIsToggleable(p)).toBe(false); + expect(promptLoopDefaultOn(p)).toBe(true); }); test("mode: always with default:false -> default ignored, defaultOn true", () => { - const p = { periodic: { mode: "always", default: false } }; - expect(promptPeriodicMode(p)).toBe("always"); - expect(promptPeriodicIsToggleable(p)).toBe(false); - expect(promptPeriodicDefaultOn(p)).toBe(true); + const p = { loop: { mode: "always", default: false } }; + expect(promptLoopMode(p)).toBe("always"); + expect(promptLoopIsToggleable(p)).toBe(false); + expect(promptLoopDefaultOn(p)).toBe(true); }); test("mode: optional -> mode optional, toggleable true, defaultOn true", () => { - const p = { periodic: { mode: "optional" } }; - expect(promptPeriodicMode(p)).toBe("optional"); - expect(promptPeriodicIsToggleable(p)).toBe(true); - expect(promptPeriodicDefaultOn(p)).toBe(true); + const p = { loop: { mode: "optional" } }; + expect(promptLoopMode(p)).toBe("optional"); + expect(promptLoopIsToggleable(p)).toBe(true); + expect(promptLoopDefaultOn(p)).toBe(true); }); test("mode: optional, default:true -> mode optional, toggleable true, defaultOn true", () => { - const p = { periodic: { mode: "optional", default: true } }; - expect(promptPeriodicMode(p)).toBe("optional"); - expect(promptPeriodicIsToggleable(p)).toBe(true); - expect(promptPeriodicDefaultOn(p)).toBe(true); + const p = { loop: { mode: "optional", default: true } }; + expect(promptLoopMode(p)).toBe("optional"); + expect(promptLoopIsToggleable(p)).toBe(true); + expect(promptLoopDefaultOn(p)).toBe(true); }); test("mode: optional, default:false -> mode optional, toggleable true, defaultOn false", () => { - const p = { periodic: { mode: "optional", default: false } }; - expect(promptPeriodicMode(p)).toBe("optional"); - expect(promptPeriodicIsToggleable(p)).toBe(true); - expect(promptPeriodicDefaultOn(p)).toBe(false); + const p = { loop: { mode: "optional", default: false } }; + expect(promptLoopMode(p)).toBe("optional"); + expect(promptLoopIsToggleable(p)).toBe(true); + expect(promptLoopDefaultOn(p)).toBe(false); }); test("unknown mode is treated as always", () => { - const p = { periodic: { mode: "weird" } }; - expect(promptPeriodicMode(p)).toBe("always"); - expect(promptPeriodicIsToggleable(p)).toBe(false); - expect(promptPeriodicDefaultOn(p)).toBe(true); + const p = { loop: { mode: "weird" } }; + expect(promptLoopMode(p)).toBe("always"); + expect(promptLoopIsToggleable(p)).toBe(false); + expect(promptLoopDefaultOn(p)).toBe(true); }); test("null-safe: undefined prompt -> mode none, toggleable false, defaultOn false", () => { - expect(promptPeriodicMode(undefined)).toBe("none"); - expect(promptPeriodicIsToggleable(undefined)).toBe(false); - expect(promptPeriodicDefaultOn(undefined)).toBe(false); + expect(promptLoopMode(undefined)).toBe("none"); + expect(promptLoopIsToggleable(undefined)).toBe(false); + expect(promptLoopDefaultOn(undefined)).toBe(false); }); }); // ============================================================================= -// promptResolveAsPeriodic +// promptResolveAsLoop // ============================================================================= -describe("promptResolveAsPeriodic", () => { +describe("promptResolveAsLoop", () => { test("mode none -> false (override ignored)", () => { - expect(promptResolveAsPeriodic({})).toBe(false); - expect(promptResolveAsPeriodic({}, true)).toBe(false); - expect(promptResolveAsPeriodic({ periodic: null }, true)).toBe(false); + expect(promptResolveAsLoop({})).toBe(false); + expect(promptResolveAsLoop({}, true)).toBe(false); + expect(promptResolveAsLoop({ loop: null }, true)).toBe(false); }); test("mode always -> true (override ignored)", () => { - const p = { periodic: { mode: "always" } }; - expect(promptResolveAsPeriodic(p)).toBe(true); - expect(promptResolveAsPeriodic(p, false)).toBe(true); + const p = { loop: { mode: "always" } }; + expect(promptResolveAsLoop(p)).toBe(true); + expect(promptResolveAsLoop(p, false)).toBe(true); }); test("mode optional, no override, default:false -> false", () => { - const p = { periodic: { mode: "optional", default: false } }; - expect(promptResolveAsPeriodic(p)).toBe(false); + const p = { loop: { mode: "optional", default: false } }; + expect(promptResolveAsLoop(p)).toBe(false); }); test("mode optional, no override, default:true -> true", () => { - const p = { periodic: { mode: "optional", default: true } }; - expect(promptResolveAsPeriodic(p)).toBe(true); + const p = { loop: { mode: "optional", default: true } }; + expect(promptResolveAsLoop(p)).toBe(true); }); test("mode optional, no override, default absent -> true", () => { - const p = { periodic: { mode: "optional" } }; - expect(promptResolveAsPeriodic(p)).toBe(true); + const p = { loop: { mode: "optional" } }; + expect(promptResolveAsLoop(p)).toBe(true); }); test("mode optional, override:true -> true even if default:false", () => { - const p = { periodic: { mode: "optional", default: false } }; - expect(promptResolveAsPeriodic(p, true)).toBe(true); + const p = { loop: { mode: "optional", default: false } }; + expect(promptResolveAsLoop(p, true)).toBe(true); }); test("mode optional, override:false -> false even if default:true", () => { - const p = { periodic: { mode: "optional", default: true } }; - expect(promptResolveAsPeriodic(p, false)).toBe(false); + const p = { loop: { mode: "optional", default: true } }; + expect(promptResolveAsLoop(p, false)).toBe(false); }); }); @@ -1330,16 +1330,16 @@ describe("buildPromptGroupMenuItems", () => { }); const prompts = [ - { name: "Always On", group: "G" }, // no periodic block -> "none" + { name: "Always On", group: "G" }, // no loop block -> "none" { - name: "Always Periodic", + name: "Always Loop", group: "G", - periodic: { mode: "always" }, + loop: { mode: "always" }, }, { - name: "Maybe Periodic", + name: "Maybe Loop", group: "G", - periodic: { mode: "optional", default: false }, + loop: { mode: "optional", default: false }, }, ]; @@ -1351,48 +1351,48 @@ describe("buildPromptGroupMenuItems", () => { return undefined; } - test("a 'none'-mode prompt yields periodicMode 'none'", () => { + test("a 'none'-mode prompt yields loopMode 'none'", () => { const items = buildPromptGroupMenuItems(prompts, () => {}, null); const sub = findSub(items, "Always On"); expect(sub).toBeDefined(); - expect(sub.periodicMode).toBe("none"); + expect(sub.loopMode).toBe("none"); }); - test("an 'always'-mode prompt carries periodicMode 'always' and periodicDefaultOn true", () => { + test("an 'always'-mode prompt carries loopMode 'always' and loopDefaultOn true", () => { const items = buildPromptGroupMenuItems(prompts, () => {}, null); - const sub = findSub(items, "Always Periodic"); + const sub = findSub(items, "Always Loop"); expect(sub).toBeDefined(); - expect(sub.periodicMode).toBe("always"); - expect(sub.periodicDefaultOn).toBe(true); + expect(sub.loopMode).toBe("always"); + expect(sub.loopDefaultOn).toBe(true); }); - test("an 'optional'-mode prompt carries periodicMode 'optional' and periodicDefaultOn matching its default", () => { + test("an 'optional'-mode prompt carries loopMode 'optional' and loopDefaultOn matching its default", () => { const items = buildPromptGroupMenuItems(prompts, () => {}, null); - const sub = findSub(items, "Maybe Periodic"); + const sub = findSub(items, "Maybe Loop"); expect(sub).toBeDefined(); - expect(sub.periodicMode).toBe("optional"); - expect(sub.periodicDefaultOn).toBe(false); + expect(sub.loopMode).toBe("optional"); + expect(sub.loopDefaultOn).toBe(false); }); - test("calling item.onClick({ asPeriodic: true }) invokes onRun with (prompt, { asPeriodic: true })", () => { + test("calling item.onClick({ asLoop: true }) invokes onRun with (prompt, { asLoop: true })", () => { const onRun = jest.fn(); const items = buildPromptGroupMenuItems(prompts, onRun, null); - const sub = findSub(items, "Maybe Periodic"); - sub.onClick({ asPeriodic: true }); + const sub = findSub(items, "Maybe Loop"); + sub.onClick({ asLoop: true }); expect(onRun).toHaveBeenCalledTimes(1); const [calledPrompt, calledOpts] = onRun.mock.calls[0]; - expect(calledPrompt.name).toBe("Maybe Periodic"); - expect(calledOpts).toEqual({ asPeriodic: true }); + expect(calledPrompt.name).toBe("Maybe Loop"); + expect(calledOpts).toEqual({ asLoop: true }); }); - test("calling item.onClick({ asPeriodic: false }) forwards false", () => { + test("calling item.onClick({ asLoop: false }) forwards false", () => { const onRun = jest.fn(); const items = buildPromptGroupMenuItems(prompts, onRun, null); - const sub = findSub(items, "Maybe Periodic"); - sub.onClick({ asPeriodic: false }); + const sub = findSub(items, "Maybe Loop"); + sub.onClick({ asLoop: false }); expect(onRun).toHaveBeenCalledTimes(1); const [calledPrompt, calledOpts] = onRun.mock.calls[0]; - expect(calledPrompt.name).toBe("Maybe Periodic"); - expect(calledOpts).toEqual({ asPeriodic: false }); + expect(calledPrompt.name).toBe("Maybe Loop"); + expect(calledOpts).toEqual({ asLoop: false }); }); }); diff --git a/web/static/utils/sessionGrouping.js b/web/static/utils/sessionGrouping.js index bcccb807..198c1e93 100644 --- a/web/static/utils/sessionGrouping.js +++ b/web/static/utils/sessionGrouping.js @@ -28,7 +28,7 @@ export function computeSessionFingerprint(filteredSessions, groupingMode) { filteredSessions .map( (s) => - `${s.session_id}|${s.parent_session_id || ""}|${s.working_dir || ""}|${s.archived || false}|${s.periodic_enabled || false}|${s.periodic_configured || false}|${s.pinned || false}|${s.name || ""}`, + `${s.session_id}|${s.parent_session_id || ""}|${s.working_dir || ""}|${s.archived || false}|${s.loop_enabled || false}|${s.loop_configured || false}|${s.pinned || false}|${s.name || ""}`, ) .sort() .join("\n") @@ -240,7 +240,7 @@ function annotateWithCategory(nodes) { } /** - * Compute the unified sidebar tree over ALL sessions (regular + periodic + + * Compute the unified sidebar tree over ALL sessions (regular + loop + * archived) without any tab pre-filtering. Returns a stable data model with * static injected nodes (per-folder tasks) and conversation nodes annotated * with their category and partitioned into active vs. archived roots. @@ -367,7 +367,7 @@ export function computeFolderGroupSections(folders) { * * Pure predicate applied to the unified tree (after grouping/nesting), before * render. Category derives from each conversation node's `category` - * (getFilterTabForSession): conversations→regular, periodic→periodic, + * (getFilterTabForSession): conversations→regular, loop→loop, * archived→archived. Per-folder Tasks nodes are the 'tasks' category. * * Hiding a parent conversation hides its children too (the whole subtree is @@ -375,19 +375,19 @@ export function computeFolderGroupSections(folders) { * Tasks hidden is pruned entirely. * * @param {{folders: Array}} tree - from computeUnifiedTree - * @param {{regular: boolean, periodic: boolean, archived: boolean, tasks: boolean}} filter + * @param {{regular: boolean, loop: boolean, archived: boolean, tasks: boolean}} filter * @returns {{folders: Array}} new tree; each folder gains showTasks */ export function filterUnifiedTree(tree, filter) { if (!tree) return { folders: [] }; const f = filter || {}; const regular = f.regular !== false; - const periodic = f.periodic !== false; + const loop = f.loop !== false; const archived = f.archived !== false; const tasks = f.tasks !== false; const categoryEnabled = (category) => { - if (category === FILTER_TAB.PERIODIC) return periodic; + if (category === FILTER_TAB.LOOP) return loop; if (category === FILTER_TAB.ARCHIVED) return archived; return regular; }; diff --git a/web/static/utils/sessionGrouping.test.js b/web/static/utils/sessionGrouping.test.js index 1f353545..0f5f449a 100644 --- a/web/static/utils/sessionGrouping.test.js +++ b/web/static/utils/sessionGrouping.test.js @@ -26,7 +26,7 @@ function makeSession(overrides = {}) { acp_server: "auggie", parent_session_id: null, archived: false, - periodic_enabled: false, + loop_enabled: false, pinned: false, name: "", created_at: "2024-01-01T10:00:00Z", @@ -321,12 +321,12 @@ describe("computeUnifiedTree", () => { ); }); - test("category tagging: regular → conversations, periodic → periodic, archived → archived", () => { + test("category tagging: regular → conversations, loop → loop, archived → archived", () => { const regular = makeSession({ session_id: "r1", working_dir: "/proj" }); - const periodic = makeSession({ + const loop = makeSession({ session_id: "p1", working_dir: "/proj", - periodic_enabled: true, + loop_enabled: true, created_at: "2024-01-02T00:00:00Z", }); const archived = makeSession({ @@ -335,14 +335,14 @@ describe("computeUnifiedTree", () => { archived: true, created_at: "2024-01-03T00:00:00Z", }); - const result = computeUnifiedTree([regular, periodic, archived], []); + const result = computeUnifiedTree([regular, loop, archived], []); const folder = result.folders[0]; const allNodes = [...folder.conversations, ...folder.archived]; const r = allNodes.find((n) => n.session_id === "r1"); const p = allNodes.find((n) => n.session_id === "p1"); const a = allNodes.find((n) => n.session_id === "a1"); expect(r.category).toBe("conversations"); - expect(p.category).toBe("periodic"); + expect(p.category).toBe("loop"); expect(a.category).toBe("archived"); }); @@ -357,7 +357,7 @@ describe("computeUnifiedTree", () => { session_id: "child", working_dir: "/proj", parent_session_id: "parent", - periodic_enabled: true, + loop_enabled: true, created_at: "2024-01-02T00:00:00Z", }); const result = computeUnifiedTree([parent, child], []); @@ -366,7 +366,7 @@ describe("computeUnifiedTree", () => { (n) => n.session_id === "parent", ); const childNode = parentNode.children.find((c) => c.session_id === "child"); - expect(childNode.category).toBe("periodic"); + expect(childNode.category).toBe("loop"); }); test("archived partitioning: archived root → folder.archived, active root → folder.conversations", () => { @@ -467,10 +467,10 @@ describe("filterUnifiedTree", () => { function makeRegular(overrides = {}) { return makeSession({ session_id: `r-${Math.random()}`, ...overrides }); } - function makePeriodic(overrides = {}) { + function makeLoop(overrides = {}) { return makeSession({ session_id: `p-${Math.random()}`, - periodic_enabled: true, + loop_enabled: true, ...overrides, }); } @@ -485,11 +485,11 @@ describe("filterUnifiedTree", () => { const WS = [{ working_dir: "/home/user/project" }]; test("all-true filter → folders/conversations/archived unchanged; showTasks true", () => { - const sessions = [makeRegular(), makePeriodic(), makeArchived()]; + const sessions = [makeRegular(), makeLoop(), makeArchived()]; const tree = computeUnifiedTree(sessions, WS); const result = filterUnifiedTree(tree, { regular: true, - periodic: true, + loop: true, archived: true, tasks: true, }); @@ -497,7 +497,7 @@ describe("filterUnifiedTree", () => { result.folders.forEach((folder) => { expect(folder.showTasks).toBe(true); }); - // total conversations (non-archived) should include regular + periodic + // total conversations (non-archived) should include regular + loop const totalConvs = result.folders.reduce( (sum, f) => sum + f.conversations.length, 0, @@ -510,12 +510,12 @@ describe("filterUnifiedTree", () => { expect(totalArchived).toBeGreaterThanOrEqual(1); }); - test("regular:false → regular nodes removed; periodic kept", () => { - const sessions = [makeRegular(), makePeriodic()]; + test("regular:false → regular nodes removed; loop kept", () => { + const sessions = [makeRegular(), makeLoop()]; const tree = computeUnifiedTree(sessions, WS); const result = filterUnifiedTree(tree, { regular: false, - periodic: true, + loop: true, archived: true, tasks: true, }); @@ -524,25 +524,25 @@ describe("filterUnifiedTree", () => { expect(node.category).not.toBe("conversations"); }); }); - const totalPeriodic = result.folders.reduce( + const totalLoop = result.folders.reduce( (sum, f) => sum + f.conversations.length, 0, ); - expect(totalPeriodic).toBeGreaterThanOrEqual(1); + expect(totalLoop).toBeGreaterThanOrEqual(1); }); - test("periodic:false → periodic nodes removed", () => { - const sessions = [makeRegular(), makePeriodic()]; + test("loop:false → loop nodes removed", () => { + const sessions = [makeRegular(), makeLoop()]; const tree = computeUnifiedTree(sessions, WS); const result = filterUnifiedTree(tree, { regular: true, - periodic: false, + loop: false, archived: true, tasks: true, }); result.folders.forEach((folder) => { folder.conversations.forEach((node) => { - expect(node.category).not.toBe("periodic"); + expect(node.category).not.toBe("loop"); }); }); }); @@ -552,7 +552,7 @@ describe("filterUnifiedTree", () => { const tree = computeUnifiedTree(sessions, WS); const result = filterUnifiedTree(tree, { regular: true, - periodic: true, + loop: true, archived: false, tasks: true, }); @@ -566,7 +566,7 @@ describe("filterUnifiedTree", () => { const tree = computeUnifiedTree(sessions, WS); const result = filterUnifiedTree(tree, { regular: true, - periodic: true, + loop: true, archived: true, tasks: false, }); @@ -580,15 +580,15 @@ describe("filterUnifiedTree", () => { const tree = computeUnifiedTree(sessions, WS); const result = filterUnifiedTree(tree, { regular: false, - periodic: false, + loop: false, archived: false, tasks: false, }); expect(result.folders).toHaveLength(0); }); - test("hiding a periodic parent drops the whole subtree", () => { - const parent = makePeriodic({ session_id: "parent-1" }); + test("hiding a loop parent drops the whole subtree", () => { + const parent = makeLoop({ session_id: "parent-1" }); const child = makeRegular({ session_id: "child-1", parent_session_id: "parent-1", @@ -597,11 +597,11 @@ describe("filterUnifiedTree", () => { const tree = computeUnifiedTree(sessions, WS); const result = filterUnifiedTree(tree, { regular: true, - periodic: false, + loop: false, archived: true, tasks: true, }); - // parent (periodic) should not appear + // parent (loop) should not appear result.folders.forEach((folder) => { folder.conversations.forEach((node) => { expect(node.session_id).not.toBe("parent-1"); @@ -619,7 +619,7 @@ describe("filterUnifiedTree", () => { }); test("missing filter (undefined) → treated as all-true", () => { - const sessions = [makeRegular(), makePeriodic(), makeArchived()]; + const sessions = [makeRegular(), makeLoop(), makeArchived()]; const tree = computeUnifiedTree(sessions, WS); const result = filterUnifiedTree(tree, undefined); result.folders.forEach((folder) => { @@ -724,7 +724,7 @@ describe("flattenUnifiedTreeForNav", () => { ); const filtered = filterUnifiedTree(tree, { regular: true, - periodic: true, + loop: true, archived: false, tasks: true, }); diff --git a/web/static/utils/storage.js b/web/static/utils/storage.js index e507ec9e..6eff30ec 100644 --- a/web/static/utils/storage.js +++ b/web/static/utils/storage.js @@ -296,6 +296,9 @@ export function migrateLegacyTabStorage() { // Remove orphaned top-level keys (use string literals; constants deleted) localStorage.removeItem("mitto_conversation_filter_tab"); localStorage.removeItem("mitto_filter_tab_grouping"); + // "periodic" here is the historical 3-tab sidebar tab name (mitto-1er.8.4), + // not the loop feature — kept verbatim so this cleanup still matches the real + // legacy localStorage key "mitto_last_session_id_periodic" in old browsers. ["conversations", "periodic", "archived"].forEach((t) => localStorage.removeItem("mitto_last_session_id_" + t), ); @@ -797,25 +800,25 @@ export function isGroupExpanded(groupKey) { */ export const FILTER_TAB = { CONVERSATIONS: "conversations", - PERIODIC: "periodic", + LOOP: "loop", ARCHIVED: "archived", }; /** * Derive which filter tab a session belongs to from its state. Mirrors the * tab-filtering logic used throughout the app (archived → archived, - * periodic_enabled → periodic, otherwise → conversations). + * loop_enabled → loop, otherwise → conversations). * - * NOTE: uses periodic_enabled (runs active), NOT periodic_configured (config exists). - * A paused/draft periodic conversation (configured but not enabled) falls into - * the CONVERSATIONS group — its editor is still visible via periodic_configured. - * @param {Object} session - A session object (archived, periodic_enabled flags) + * NOTE: uses loop_enabled (runs active), NOT loop_configured (config exists). + * A paused/draft loop conversation (configured but not enabled) falls into + * the CONVERSATIONS group — its editor is still visible via loop_configured. + * @param {Object} session - A session object (archived, loop_enabled flags) * @returns {string} The filter tab for the session */ export function getFilterTabForSession(session) { if (!session) return FILTER_TAB.CONVERSATIONS; if (session.archived) return FILTER_TAB.ARCHIVED; - if (session.periodic_enabled) return FILTER_TAB.PERIODIC; + if (session.loop_enabled) return FILTER_TAB.LOOP; return FILTER_TAB.CONVERSATIONS; } @@ -827,11 +830,11 @@ const CATEGORY_FILTER_KEY = "mitto_category_filter"; /** * Default category filter: all categories visible. - * Shape: { regular, periodic, archived, tasks } (all booleans). + * Shape: { regular, loop, archived, tasks } (all booleans). */ export const DEFAULT_CATEGORY_FILTER = { regular: true, - periodic: true, + loop: true, archived: true, tasks: true, }; @@ -840,7 +843,7 @@ export const DEFAULT_CATEGORY_FILTER = { * Read the category filter from sessionStorage (browser-session scope; resets * in a fresh browser session). Returns the all-visible default when unset or * invalid. - * @returns {{regular: boolean, periodic: boolean, archived: boolean, tasks: boolean}} + * @returns {{regular: boolean, loop: boolean, archived: boolean, tasks: boolean}} */ export function getCategoryFilter() { try { @@ -849,7 +852,7 @@ export function getCategoryFilter() { const parsed = JSON.parse(value); return { regular: parsed.regular !== false, - periodic: parsed.periodic !== false, + loop: parsed.loop !== false, archived: parsed.archived !== false, tasks: parsed.tasks !== false, }; @@ -861,14 +864,14 @@ export function getCategoryFilter() { /** * Persist the category filter to sessionStorage (browser-session scope). - * @param {{regular: boolean, periodic: boolean, archived: boolean, tasks: boolean}} state + * @param {{regular: boolean, loop: boolean, archived: boolean, tasks: boolean}} state */ export function setCategoryFilter(state) { try { const s = state || {}; const normalized = { regular: s.regular !== false, - periodic: s.periodic !== false, + loop: s.loop !== false, archived: s.archived !== false, tasks: s.tasks !== false, }; diff --git a/web/static/utils/storage.test.js b/web/static/utils/storage.test.js index 54055dd6..16d4d0d3 100644 --- a/web/static/utils/storage.test.js +++ b/web/static/utils/storage.test.js @@ -458,7 +458,7 @@ describe("getCategoryFilter / setCategoryFilter", () => { const result = getCategoryFilter(); expect(result).toEqual(DEFAULT_CATEGORY_FILTER); expect(result.regular).toBe(true); - expect(result.periodic).toBe(true); + expect(result.loop).toBe(true); expect(result.archived).toBe(true); expect(result.tasks).toBe(true); }); @@ -466,13 +466,13 @@ describe("getCategoryFilter / setCategoryFilter", () => { test("round-trips: setCategoryFilter then getCategoryFilter", () => { setCategoryFilter({ regular: false, - periodic: true, + loop: true, archived: true, tasks: false, }); const result = getCategoryFilter(); expect(result.regular).toBe(false); - expect(result.periodic).toBe(true); + expect(result.loop).toBe(true); expect(result.archived).toBe(true); expect(result.tasks).toBe(false); }); @@ -489,7 +489,7 @@ describe("getCategoryFilter / setCategoryFilter", () => { }); const result = getCategoryFilter(); expect(result.regular).toBe(false); - expect(result.periodic).toBe(true); + expect(result.loop).toBe(true); expect(result.archived).toBe(true); expect(result.tasks).toBe(true); }); @@ -510,6 +510,7 @@ describe("migrateLegacyTabStorage", () => { conversations: "folder", }); mockStore["mitto_last_session_id_conversations"] = "s1"; + // Historical 3-tab sidebar key (tab was named "periodic" pre-rename) mockStore["mitto_last_session_id_periodic"] = "s2"; mockStore["mitto_last_session_id_archived"] = "s3"; @@ -548,11 +549,11 @@ describe("migrateLegacyTabStorage", () => { expect(mockStore[DONE_KEY]).toBe("1"); // Re-seed the orphaned key (simulating stale state) - mockStore["mitto_conversation_filter_tab"] = "periodic"; + mockStore["mitto_conversation_filter_tab"] = "loop"; // Second call should not touch anything migrateLegacyTabStorage(); - expect(mockStore["mitto_conversation_filter_tab"]).toBe("periodic"); + expect(mockStore["mitto_conversation_filter_tab"]).toBe("loop"); }); });