Files

1 line
42 KiB
JSON

{"repo": "boxlogodev/sapstack", "files": {"apps/desktop/packages/shared/CLAUDE.md": {"d9c85b91944870ce0c75fc97d91c4e7f1182f4e8": {"t": 1785747195.6255515, "n": 21237, "h": "ef18adbed35f196d", "c": "# CLAUDE.md — `@sapstack-desktop/shared`\n\n## Purpose\nCore business logic package for sapstack Desktop:\n- Agent backends and session-scoped tools\n- Sources, credentials, sessions, and config\n- Permission modes and validation\n\n## Key folders\n- `src/agent/` — `claude-agent.ts`, `pi-agent.ts`, `base-agent.ts`, tools, permissions\n- `src/sources/` — source storage/types/services\n- `src/sessions/` — session persistence/index\n- `src/projects/` — workspace-scoped projects (config + assets); sessions bind via `projectId`\n- `src/config/` — config/preferences/theme/watcher\n- `src/credentials/` — encrypted credential management\n\n## Commands\nFrom repo root:\n```bash\ncd packages/shared && bun run tsc --noEmit\n```\n\n## Hard rules\n- Permission modes are fixed: `safe`, `ask`, `allow-all`.\n- Source types are fixed: `mcp`, `api`, `local`.\n- Keep credential handling in `src/credentials/` pathways (no ad-hoc secret storage).\n- Keep user-facing tool contracts backward-compatible where possible.\n\n## Notes\n- `ClaudeAgent` is the primary class in `src/agent/claude-agent.ts`.\n- **Reserved \"Task\" labels.** Task flows tag each task's whole family (orchestrator + subtasks) with a per-task ITEM label — a child of the plain root `Task` label, named `TASK-<slug>-<N>` (no valueType; N = max counter across the root's TASK-named children + 1, never recycled — unrelated children of an adopted user root don't feed it). Mint/inherit only via `SessionManager.applyTaskLabel` (which uses `ensureTaskLabel`/`ensureTaskItemLabel` in `labels/crud.ts`); resolve only via `findTaskLabel` / `findTaskItemLabelId` / `resolveTaskScopeLabelId` (`labels/filter.ts`). Never assume literal ids — slugs collide-shift, so always use the resolved id (surfaced as `TaskCreateResult.taskLabelId`). Legacy `task::N` valued entries still filter under the root; `ensureTaskLabel` converges a legacy `valueType: 'number'` root to a plain label but adopts a user's own root \"Task\" label as-is (shape + children untouched).\n- **Single label-filter predicate.** `matchesLabelFilter` (`labels/filter.ts`, browser-safe) is the only implementation of \"session matches a label filter\" (descendants, `__all__`, optional `projectId` scope). The session list, AppShell filtered set, and NavigationContext auto-select all route through it — do not hand-roll label matching in feature code.\n- Claude SDK subprocess env is sanitized to strip Claude-specific Bedrock routing vars (`CLAUDE_CODE_USE_BEDROCK`, `AWS_BEARER_TOKEN_BEDROCK`, `ANTHROPIC_BEDROCK_BASE_URL`). Pi Bedrock uses its own AWS env path instead.\n- Backward alias export (`CraftAgent`) exists for compatibility.\n- Prefer routing new model vendors through the existing Pi path (`providerType: 'pi'` + `piAuthProvider`) unless they truly need a distinct runtime/backend. The Pi provider catalog and display metadata live in `src/config/models-pi.ts`.\n- Custom endpoint model capabilities must preserve explicit per-model overrides end-to-end. In particular, `supportsImages: true` enables image input for one model and `supportsImages: false` must remain available to override a global endpoint image default. Active Pi custom-endpoint sessions refresh runtime capabilities via `updateRuntimeConfig`; capability changes are pushed proactively from the `llmConnections.SAVE` handler through `SessionManager.refreshConnectionRuntime`, with the lazy `getOrCreateAgent` path acting as a backstop. The session layer still gates image attachments at send time so disabled images are not sent even if a subprocess refresh fails.\n- `update_runtime_config` IPC carries `model, providerType, authType, baseUrl, customEndpoint, customModels` only — `piAuthProvider`, `slug`, and the broader credential/provider routing state cannot be re-routed inside a live Pi subprocess. `runtime-config.ts:buildRestartRequiredSignature` hashes those fields separately from the in-place-safe ones; when the restart signature drifts, `tryRefreshAgentRuntime` skips the in-place attempt and goes straight to dispose + recreate so the new auth/provider state actually takes effect.\n- Session lifecycle distinguishes **hard aborts** from **UI handoff interrupts**:\n - use hard aborts for true cancellation/teardown (`UserStop`, redirect fallback)\n - use handoff interrupts for pause points where control moves to the UI (`AuthRequest`, `PlanSubmitted`)\n- Remote workspace handoff summaries are injected as one-shot hidden context on the destination session's first turn.\n- **Task creation is a shared core.** `createTaskFromSpec` / `finishTaskOrchestrator` (`packages/server-core/src/tasks/create-task.ts`) implement \"create the task on the board without running it\" (task.yaml + orchestrator session + reserved TASK label + spec sources, fail-soft on label/sources). Both the `tasks:create` RPC fresh path and the agent-facing `create_task` session tool call it — never re-implement the flow. The tool path derives the slug via `uniqueTaskSlug` (`shared/tasks/slug.ts`, never overwrites; the TaskEditor keeps its own `slugify` copy because this barrel pulls Node fs code), inherits the invoking session's project unless `projectId` explicitly overrides it, and synthesizes the required single `main` node from the description; DAG authoring stays with the editor/`tasks:generate`. Running remains exclusively `tasks:run`/TaskRunner.\n- WebUI source OAuth uses a stable relay redirect URI (`https://boxlogodev.github.io/sapstack/auth/callback`); the deployment-specific callback target is carried in a relay-owned outer `state` envelope and unwrapped by the router worker.\n- Automations matching is unified through canonical matcher adapters in `src/automations/utils.ts` (`matcherMatches*`). Avoid direct primitive-only matcher checks in feature code so condition gating stays consistent across app and agent events.\n- Automation matchers may declare an optional `telegramTopic?: string` to route spawned sessions into a Telegram forum topic in the workspace's paired supergroup. The field is plumbed through `PendingPrompt` and `ExecutePromptAutomationInput`; runtime resolution and topic creation live in `@sapstack-desktop/messaging-gateway`'s `TopicRegistry` and `MessagingGatewayRegistry.bindAutomationSession`. SessionManager picks up the resolution via the optional `setAutomationBinder` hook installed by the messaging-gateway bootstrap.\n- The OpenAI Chat Completions strip stream (`unified-network-interceptor.ts:createOpenAiSseStrippingStream`) emits **one consolidated SSE event per logical tool call** with `id + name + cleanArgs` together — never split across init + args-only deltas. Some downstream SDKs (Pi SDK) treat args-only deltas as new tool_calls instead of merging by index, which produces duplicate empty-id entries on parallel-tool turns from DeepSeek and other relays. `sanitizeOpenAiHistoryInPlace` recovers sessions whose history was persisted by the pre-fix split-emit version.\n- `LlmConnection.midStreamBehavior` controls whether mid-stream user sends try to steer the in-flight turn or hold for the next turn. Default is per-`providerType` via `defaultMidStreamBehavior()` (anthropic→`'queue'`, pi/pi_compat→`'steer'`). **Read everywhere via `resolveMidStreamBehavior(connection)`** — never branch on `providerType` directly for this decision; legacy connections without the field rely on the resolver's fallback. New connections persist the explicit default at `createBuiltInConnection` time so the Settings → AI submenu shows a checkmark on first load. The decision is made in `SessionManager.sendMessage`'s mid-stream branch only — backend code (`claude-agent.ts`, `pi-agent.ts`) is unchanged: `'queue'` mode skips `agent.redirect()` entirely and lets the current turn finish before replay. Two correctness invariants live in this branch: (1) `managed.wasInterrupted` is set **only** on the steer path (where an actual `forceAbort` happened) — pure `'queue'` mode must NOT set it, otherwise the replayed turn injects the \"previous response was interrupted and may be incomplete\" reminder for a turn that actually completed, confusing the model. (2) The mid-stream user message is created with a queue-time timestamp (mid-stream, i.e. *before* the in-flight assistant reply is finalized at `text_complete`); `processNextQueuedMessage` **re-stamps** it via `this.monotonic()` on replay so it sorts after the prior turn's finalized reply — `groupMessagesByTurn` (`@sapstack-desktop/ui`) orders by timestamp. The emitted `user_message` event with `status: 'processing'` is the live-renderer reconciliation point: `handleUserMessage` must copy that canonical timestamp onto the already-mounted optimistic message while preserving its optimistic ID. Persisting the re-stamp server-side alone fixes reload order but leaves the live transcript wrong.\n- The network interceptor (`unified-network-interceptor.ts`) is currently **Pi-only**: it preloads into the Pi subprocess via Bun `--preload`. The Claude SDK no longer runs under Bun (since 0.2.113 it spawns a per-platform native `claude` binary), so `--preload` is not available there. Features that used to live in the interceptor for Claude (rich tool intent, fast-mode override, MalformedBodyError validation, etc.) are Phase-2 work — they'll need to move to SDK hooks or a local proxy. In dev / monorepo runs, the Pi interceptor still preloads from the .ts source so changes propagate without a rebuild; packaged builds use `apps/electron/dist/interceptor.cjs`. See `agent/backend/internal/runtime-resolver.ts:resolveInterceptorBundlePath`.\n- Per-message context is split into **volatile** vs **stable** blocks (`PromptBuilder.buildVolatileContextParts()` / `buildStableContextParts()`, composed by `buildContextParts()`). Volatile = date/time, `session_state`, `sources` (change per turn); stable = workspace capabilities, working directory (invariant per session). **Claude** keeps all blocks on the user-message tail (system prompt stays cacheable). **Pi** folds only stable blocks into the system prefix and routes volatile blocks to the user tail — otherwise a per-minute re-stamp invalidates pi-ai's cached system prefix and all downstream history (#862). `buildVolatileContextParts` consumes the one-shot mode-change signal (`consumeModeChangeUserSignal`), so call it **exactly once per turn** — never re-invoke a builder to compute a cache-debug hash (hash the produced string instead).\n- Anthropic OAuth identity (account/org) is captured from the token-exchange response in `auth/claude-oauth.ts` (`parseClaudeOAuthIdentity`; fields are optional/fail-soft, never block login) and persisted on `LlmConnection` (`oauthAccountUuid/Email`, `oauthOrganizationUuid/Name`, `oauthProfileVerifiedAt`) by threading it through the `SETUP_LLM_CONNECTION` payload (`oauthIdentity`), **not** the EXCHANGE handler — the connection record is created by SETUP, which runs after the exchange. `updateLlmConnection` rebuilds connections from a hardcoded allowlist, so any new persisted field must be added there too or it is dropped on the next save (#838).\n- **Mythos-class thinking (Claude Fable 5 / Mythos 5).** These models have adaptive thinking **always on** and the Messages API **rejects `thinking: { type: 'disabled' }`** (unlike Opus/Sonnet/Haiku, whose API is unchanged). `resolveClaudeThinkingOptions` therefore detects them via `isAdaptiveThinkingAlwaysOnModel()` (`config/models.ts`) and maps the \"off\"/`minimizeThinking` case to `{ thinking: { type: 'adaptive' }, effort: 'low' }` instead of `disabled` — there is no way to turn thinking off on these models. `runMiniCompletion` is unaffected (it runs on the resolved mini model, which is always Haiku). Model id is the dateless pinned snapshot `claude-fable-5` (1M context, 128k max output); registered in `MODEL_REGISTRY`.\n\n## i18n (Internationalization)\n\nTranslations live in `src/i18n/locales/{lang}.json`. All user-facing strings must use `t()` (React) or `i18n.t()` (non-React).\n\n### Locale registry (single source of truth)\n\nAll locale metadata lives in **`src/i18n/registry.ts`**. To add a new locale:\n\n1. Create `src/i18n/locales/{code}.json` with all keys (copy from `en.json`)\n2. Import the messages and `date-fns` locale in `registry.ts`\n3. Add one entry to `LOCALE_REGISTRY`\n\n**That's it.** `SUPPORTED_LANGUAGE_CODES`, `LANGUAGES`, i18n resources, and `getDateLocale()` are all derived automatically. No other file needs to change.\n\n### Key naming convention\n\nKeys use **flat dot-notation** with a category prefix:\n\n| Prefix | Scope | Example |\n|--------|-------|---------|\n| `common.*` | Shared labels (Cancel, Save, Close, Edit, Loading...) | `common.cancel` |\n| `menu.*` | App menu items (File, Edit, View, Window) | `menu.toggleSidebar` |\n| `sidebar.*` | Left sidebar navigation items | `sidebar.allSessions` |\n| `sidebarMenu.*` | Sidebar context menu actions | `sidebarMenu.addSource` |\n| `sessionMenu.*` | Session context menu actions | `sessionMenu.archive` |\n| `settings.*` | Settings pages — nested by page ID | `settings.ai.connections` |\n| `chat.*` | Chat input, session viewer, inline UI | `chat.attachFiles` |\n| `toast.*` | Toast/notification messages | `toast.failedToShare` |\n| `errors.*` | Error screens | `errors.sessionNotFound` |\n| `onboarding.*` | Onboarding flow — nested by step | `onboarding.welcome.title` |\n| `dialog.*` | Modal dialogs | `dialog.reset.title` |\n| `apiSetup.*` | API connection setup | `apiSetup.modelTier.best` |\n| `workspace.*` | Workspace creation/management | `workspace.createNew` |\n| `sourceInfo.*` | Source detail page | `sourceInfo.connection` |\n| `skillInfo.*` | Skill detail page | `skillInfo.metadata` |\n| `automations.*` | Automation list/detail/menus | `automations.runTest` |\n| `sourcesList.*` | Sources list panel | `sourcesList.noSourcesConfigured` |\n| `skillsList.*` | Skills list panel | `skillsList.addSkill` |\n| `editPopover.*` | EditPopover labels/placeholders | `editPopover.label.addSource` |\n| `status.*` | Session status names (by status ID) | `status.needs-review` |\n| `mode.*` | Permission mode names (by mode ID) | `mode.safe` |\n| `hints.*` | Empty state workflow suggestions | `hints.summarizeGmail` |\n| `table.*` | Data table column headers | `table.access` |\n| `time.*` | Relative time strings | `time.minutesAgo_other` |\n| `session.*` | Session list UI | `session.noSessionsYet` |\n| `shortcuts.*` | Keyboard shortcuts descriptions | `shortcuts.sendMessage` |\n| `sendToWorkspace.*` | Send to workspace dialog | `sendToWorkspace.title` |\n| `webui.*` | WebUI-specific strings | `webui.connectionFailed` |\n| `auth.*` | Auth banner/prompts | `auth.connectionRequired` |\n| `browser.*` | Browser empty state | `browser.readyTitle` |\n\n### Rules\n\n1. **Never call `i18n.t()` at module level** — store `labelKey` strings and resolve in components/functions.\n2. **Use i18next pluralization** (`_one`/`_other`), never manual `count === 1 ?` logic.\n3. **Keep brand names in English**: Craft, sapstack Desktop, Agents, Workspace, Claude, Anthropic, OpenAI, MCP, API, SDK.\n4. **Include `...` in the translation value** if the UI needs an ellipsis — don't append it in JSX.\n5. **Use `<Trans>` component** for translations containing HTML tags (e.g. `<strong>`).\n6. **Use `i18n.resolvedLanguage`** (not `i18n.language`) when comparing against supported language codes.\n7. **Keys must exist in every locale file** in `src/i18n/locales/` (not just `en.json` — `lint:i18n:parity` enforces this across the full set). Keep alphabetically sorted.\n8. **Watch translation length for constrained UI elements.** Translations can be 20-100%+ longer than English. For buttons, badges, tab labels, and dropdown items, keep translations concise — use shorter synonyms if needed. High-risk areas:\n - Permission mode badges (3-5 characters max)\n - Settings tab labels (≤10 characters ideal)\n - Button labels (avoid exceeding 2x the English length)\n - Menu items (flexible, but avoid 3x+ growth)\n\n### Validation\n\nThree checks gate i18n correctness, all wired into pre-commit (`lint:i18n:staged`) and `validate:ci`:\n\n| Script | Catches |\n|--------|---------|\n| `lint:i18n:sorted` | locale keys not alphabetical |\n| `lint:i18n:parity` | non-EN locale missing keys present in `en.json`, or vice versa |\n| `lint:i18n:coverage` | `t('...')` callsite referencing a key that doesn't exist in `en.json` |\n\n`parity` alone is insufficient — it can't detect symmetric losses across all locales (a merge that drops the same 50 keys from every locale file passes parity but breaks the UI). `coverage` closes that gap by verifying every literal `t(...)` / `i18n.t(...)` / `<Trans i18nKey>` reference resolves against `en.json`. Dynamic keys (`t(\\`status.${id}\\`)`) are skipped — those surface via i18next's runtime missing-key warnings.\n\nWhen resolving locale merge conflicts, run `bun run validate:ci` and trust the result — no manual key auditing needed if all three pass.\n\n### Adding a new translated string\n\n1. Add the key + English value to `en.json` (alphabetical order)\n2. Add the key + translated value to **every other** `src/i18n/locales/*.json` file (run `bun run lint:i18n:parity` to confirm none were missed)\n3. Use `t(\"your.key\")` in the component (add `useTranslation()` hook if not present)\n4. For non-React code, use `i18n.t(\"your.key\")` — but only inside functions, never at module level\n\n### Adding a new locale\n\n1. Create `src/i18n/locales/{code}.json` with all keys from `en.json`\n2. Add the entry to `LOCALE_REGISTRY` in `src/i18n/registry.ts` (messages + date-fns locale + native name)\n3. Run tests — the registry tests will catch any missing wiring\n\n### Cross-process language persistence\n\nThe main-process i18n instance has **no detection plugin** (no `localStorage` in Node) and would otherwise reset to `fallbackLng: 'en'` on every restart. To keep main + renderer in sync across launches:\n\n- **Renderer** uses `i18next-browser-languagedetector` → `localStorage` (`i18nextLng`). Survives restart.\n- **Main** hydrates on startup from `preferences.uiLanguage` in `~/.sapstack-desktop/preferences.json`. Maintained only by the `i18n:changeLanguage` IPC handler in `apps/electron/src/main/index.ts`.\n- **Renderer → main sync** happens on every Appearance change AND once at renderer startup (so a freshly-installed app immediately learns the persisted language).\n- The IPC handler validates the incoming code against `SUPPORTED_LANGUAGE_CODES` and `setPersistedUiLanguage()` no-ops if the value is unchanged — startup pushes don't churn the file or the config watcher.\n\n`uiLanguage` is **not** user-editable through `update_user_preferences`. The Appearance dropdown is the only writer.\n\n**Session-title language** resolves from this same persisted `uiLanguage` via `resolveTitleLanguageName()` (`config/preferences.ts`), **not** `i18n.resolvedLanguage`. The main-process i18n value hydrates asynchronously at startup and can still read the `'en'` fallback when an early title generates, which forced English titles for non-English chats (#885). When no language is persisted the helper returns `undefined`, so the title prompt auto-detects the conversation language instead of defaulting to English. Used at both `SessionManager` title sites (`generateTitle`, `refreshTitle`).\n\n## Token refresh for API sources\n\nAPI sources can auto-refresh tokens via two paths:\n- **OAuth** — Google, Slack, Microsoft, or generic OAuth (`authType: 'oauth'`)\n- **Renew endpoint** — custom bearer-token APIs with `api.renewEndpoint` in config.json\n\nFor renew-endpoint sources, the current access token is sent to the configured endpoint and a new token is extracted from the response. No separate refresh token is needed (MVP scope).\n\nKey integration points:\n- `isRefreshableSource()` in `types.ts` — single guard for \"can this source auto-refresh?\"\n- `SourceCredentialManager.refreshApiRenew()` — calls the renew endpoint\n- `TokenRefreshManager` — treats renew-endpoint sources as refreshable even without `refreshToken`\n- `server-builder.ts` — passes a token getter (not static credential) for renew-endpoint sources\n\n## `queryLlm` backend contract\n\nEvery `AgentBackend.queryLlm(request: LLMQueryRequest)` implementation MUST:\n- honor `request.model` (with backend-specific fallback only when the model is\n unresolvable/unsupported; always report the *effective* model in\n `LLMQueryResult.model`)\n- honor `request.systemPrompt`\n\nSHOULD:\n- honor `request.outputSchema` (at minimum via prompt injection — see\n `buildCallLlmRequest` in `agent/llm-tool.ts`, which already handles this\n pre-backend)\n\nMAY:\n- honor `request.maxTokens` and `request.temperature` if the underlying SDK\n supports passing these to its generation call\n\nMUST NOT:\n- return a fabricated `LLMQueryResult.model` that doesn't match what was actually\n used — downstream UI treats this as authoritative\n\nIPC envelopes between the main process and any subprocess backend (Pi today,\npotentially others) MUST carry the full `LLMQueryRequest`, not a subset.\nA backend that invents a narrower envelope is guaranteed to drift over time\n(see #596). The round-trip invariant is guarded by\n`packages/shared/src/agent/__tests__/pi-query-llm.test.ts`.\n\n## Source of truth\n- Package exports: `packages/shared/src/index.ts` and subpath export entries.\n- Agent exports: `packages/shared/src/agent/index.ts`\n"}, "0b64eb15b5521b910529c5d4dfd8dfeaf1c13851": {"t": 1785771271.1864092, "n": 21237, "h": "ef18adbed35f196d", "c": "# CLAUDE.md — `@sapstack-desktop/shared`\n\n## Purpose\nCore business logic package for sapstack Desktop:\n- Agent backends and session-scoped tools\n- Sources, credentials, sessions, and config\n- Permission modes and validation\n\n## Key folders\n- `src/agent/` — `claude-agent.ts`, `pi-agent.ts`, `base-agent.ts`, tools, permissions\n- `src/sources/` — source storage/types/services\n- `src/sessions/` — session persistence/index\n- `src/projects/` — workspace-scoped projects (config + assets); sessions bind via `projectId`\n- `src/config/` — config/preferences/theme/watcher\n- `src/credentials/` — encrypted credential management\n\n## Commands\nFrom repo root:\n```bash\ncd packages/shared && bun run tsc --noEmit\n```\n\n## Hard rules\n- Permission modes are fixed: `safe`, `ask`, `allow-all`.\n- Source types are fixed: `mcp`, `api`, `local`.\n- Keep credential handling in `src/credentials/` pathways (no ad-hoc secret storage).\n- Keep user-facing tool contracts backward-compatible where possible.\n\n## Notes\n- `ClaudeAgent` is the primary class in `src/agent/claude-agent.ts`.\n- **Reserved \"Task\" labels.** Task flows tag each task's whole family (orchestrator + subtasks) with a per-task ITEM label — a child of the plain root `Task` label, named `TASK-<slug>-<N>` (no valueType; N = max counter across the root's TASK-named children + 1, never recycled — unrelated children of an adopted user root don't feed it). Mint/inherit only via `SessionManager.applyTaskLabel` (which uses `ensureTaskLabel`/`ensureTaskItemLabel` in `labels/crud.ts`); resolve only via `findTaskLabel` / `findTaskItemLabelId` / `resolveTaskScopeLabelId` (`labels/filter.ts`). Never assume literal ids — slugs collide-shift, so always use the resolved id (surfaced as `TaskCreateResult.taskLabelId`). Legacy `task::N` valued entries still filter under the root; `ensureTaskLabel` converges a legacy `valueType: 'number'` root to a plain label but adopts a user's own root \"Task\" label as-is (shape + children untouched).\n- **Single label-filter predicate.** `matchesLabelFilter` (`labels/filter.ts`, browser-safe) is the only implementation of \"session matches a label filter\" (descendants, `__all__`, optional `projectId` scope). The session list, AppShell filtered set, and NavigationContext auto-select all route through it — do not hand-roll label matching in feature code.\n- Claude SDK subprocess env is sanitized to strip Claude-specific Bedrock routing vars (`CLAUDE_CODE_USE_BEDROCK`, `AWS_BEARER_TOKEN_BEDROCK`, `ANTHROPIC_BEDROCK_BASE_URL`). Pi Bedrock uses its own AWS env path instead.\n- Backward alias export (`CraftAgent`) exists for compatibility.\n- Prefer routing new model vendors through the existing Pi path (`providerType: 'pi'` + `piAuthProvider`) unless they truly need a distinct runtime/backend. The Pi provider catalog and display metadata live in `src/config/models-pi.ts`.\n- Custom endpoint model capabilities must preserve explicit per-model overrides end-to-end. In particular, `supportsImages: true` enables image input for one model and `supportsImages: false` must remain available to override a global endpoint image default. Active Pi custom-endpoint sessions refresh runtime capabilities via `updateRuntimeConfig`; capability changes are pushed proactively from the `llmConnections.SAVE` handler through `SessionManager.refreshConnectionRuntime`, with the lazy `getOrCreateAgent` path acting as a backstop. The session layer still gates image attachments at send time so disabled images are not sent even if a subprocess refresh fails.\n- `update_runtime_config` IPC carries `model, providerType, authType, baseUrl, customEndpoint, customModels` only — `piAuthProvider`, `slug`, and the broader credential/provider routing state cannot be re-routed inside a live Pi subprocess. `runtime-config.ts:buildRestartRequiredSignature` hashes those fields separately from the in-place-safe ones; when the restart signature drifts, `tryRefreshAgentRuntime` skips the in-place attempt and goes straight to dispose + recreate so the new auth/provider state actually takes effect.\n- Session lifecycle distinguishes **hard aborts** from **UI handoff interrupts**:\n - use hard aborts for true cancellation/teardown (`UserStop`, redirect fallback)\n - use handoff interrupts for pause points where control moves to the UI (`AuthRequest`, `PlanSubmitted`)\n- Remote workspace handoff summaries are injected as one-shot hidden context on the destination session's first turn.\n- **Task creation is a shared core.** `createTaskFromSpec` / `finishTaskOrchestrator` (`packages/server-core/src/tasks/create-task.ts`) implement \"create the task on the board without running it\" (task.yaml + orchestrator session + reserved TASK label + spec sources, fail-soft on label/sources). Both the `tasks:create` RPC fresh path and the agent-facing `create_task` session tool call it — never re-implement the flow. The tool path derives the slug via `uniqueTaskSlug` (`shared/tasks/slug.ts`, never overwrites; the TaskEditor keeps its own `slugify` copy because this barrel pulls Node fs code), inherits the invoking session's project unless `projectId` explicitly overrides it, and synthesizes the required single `main` node from the description; DAG authoring stays with the editor/`tasks:generate`. Running remains exclusively `tasks:run`/TaskRunner.\n- WebUI source OAuth uses a stable relay redirect URI (`https://boxlogodev.github.io/sapstack/auth/callback`); the deployment-specific callback target is carried in a relay-owned outer `state` envelope and unwrapped by the router worker.\n- Automations matching is unified through canonical matcher adapters in `src/automations/utils.ts` (`matcherMatches*`). Avoid direct primitive-only matcher checks in feature code so condition gating stays consistent across app and agent events.\n- Automation matchers may declare an optional `telegramTopic?: string` to route spawned sessions into a Telegram forum topic in the workspace's paired supergroup. The field is plumbed through `PendingPrompt` and `ExecutePromptAutomationInput`; runtime resolution and topic creation live in `@sapstack-desktop/messaging-gateway`'s `TopicRegistry` and `MessagingGatewayRegistry.bindAutomationSession`. SessionManager picks up the resolution via the optional `setAutomationBinder` hook installed by the messaging-gateway bootstrap.\n- The OpenAI Chat Completions strip stream (`unified-network-interceptor.ts:createOpenAiSseStrippingStream`) emits **one consolidated SSE event per logical tool call** with `id + name + cleanArgs` together — never split across init + args-only deltas. Some downstream SDKs (Pi SDK) treat args-only deltas as new tool_calls instead of merging by index, which produces duplicate empty-id entries on parallel-tool turns from DeepSeek and other relays. `sanitizeOpenAiHistoryInPlace` recovers sessions whose history was persisted by the pre-fix split-emit version.\n- `LlmConnection.midStreamBehavior` controls whether mid-stream user sends try to steer the in-flight turn or hold for the next turn. Default is per-`providerType` via `defaultMidStreamBehavior()` (anthropic→`'queue'`, pi/pi_compat→`'steer'`). **Read everywhere via `resolveMidStreamBehavior(connection)`** — never branch on `providerType` directly for this decision; legacy connections without the field rely on the resolver's fallback. New connections persist the explicit default at `createBuiltInConnection` time so the Settings → AI submenu shows a checkmark on first load. The decision is made in `SessionManager.sendMessage`'s mid-stream branch only — backend code (`claude-agent.ts`, `pi-agent.ts`) is unchanged: `'queue'` mode skips `agent.redirect()` entirely and lets the current turn finish before replay. Two correctness invariants live in this branch: (1) `managed.wasInterrupted` is set **only** on the steer path (where an actual `forceAbort` happened) — pure `'queue'` mode must NOT set it, otherwise the replayed turn injects the \"previous response was interrupted and may be incomplete\" reminder for a turn that actually completed, confusing the model. (2) The mid-stream user message is created with a queue-time timestamp (mid-stream, i.e. *before* the in-flight assistant reply is finalized at `text_complete`); `processNextQueuedMessage` **re-stamps** it via `this.monotonic()` on replay so it sorts after the prior turn's finalized reply — `groupMessagesByTurn` (`@sapstack-desktop/ui`) orders by timestamp. The emitted `user_message` event with `status: 'processing'` is the live-renderer reconciliation point: `handleUserMessage` must copy that canonical timestamp onto the already-mounted optimistic message while preserving its optimistic ID. Persisting the re-stamp server-side alone fixes reload order but leaves the live transcript wrong.\n- The network interceptor (`unified-network-interceptor.ts`) is currently **Pi-only**: it preloads into the Pi subprocess via Bun `--preload`. The Claude SDK no longer runs under Bun (since 0.2.113 it spawns a per-platform native `claude` binary), so `--preload` is not available there. Features that used to live in the interceptor for Claude (rich tool intent, fast-mode override, MalformedBodyError validation, etc.) are Phase-2 work — they'll need to move to SDK hooks or a local proxy. In dev / monorepo runs, the Pi interceptor still preloads from the .ts source so changes propagate without a rebuild; packaged builds use `apps/electron/dist/interceptor.cjs`. See `agent/backend/internal/runtime-resolver.ts:resolveInterceptorBundlePath`.\n- Per-message context is split into **volatile** vs **stable** blocks (`PromptBuilder.buildVolatileContextParts()` / `buildStableContextParts()`, composed by `buildContextParts()`). Volatile = date/time, `session_state`, `sources` (change per turn); stable = workspace capabilities, working directory (invariant per session). **Claude** keeps all blocks on the user-message tail (system prompt stays cacheable). **Pi** folds only stable blocks into the system prefix and routes volatile blocks to the user tail — otherwise a per-minute re-stamp invalidates pi-ai's cached system prefix and all downstream history (#862). `buildVolatileContextParts` consumes the one-shot mode-change signal (`consumeModeChangeUserSignal`), so call it **exactly once per turn** — never re-invoke a builder to compute a cache-debug hash (hash the produced string instead).\n- Anthropic OAuth identity (account/org) is captured from the token-exchange response in `auth/claude-oauth.ts` (`parseClaudeOAuthIdentity`; fields are optional/fail-soft, never block login) and persisted on `LlmConnection` (`oauthAccountUuid/Email`, `oauthOrganizationUuid/Name`, `oauthProfileVerifiedAt`) by threading it through the `SETUP_LLM_CONNECTION` payload (`oauthIdentity`), **not** the EXCHANGE handler — the connection record is created by SETUP, which runs after the exchange. `updateLlmConnection` rebuilds connections from a hardcoded allowlist, so any new persisted field must be added there too or it is dropped on the next save (#838).\n- **Mythos-class thinking (Claude Fable 5 / Mythos 5).** These models have adaptive thinking **always on** and the Messages API **rejects `thinking: { type: 'disabled' }`** (unlike Opus/Sonnet/Haiku, whose API is unchanged). `resolveClaudeThinkingOptions` therefore detects them via `isAdaptiveThinkingAlwaysOnModel()` (`config/models.ts`) and maps the \"off\"/`minimizeThinking` case to `{ thinking: { type: 'adaptive' }, effort: 'low' }` instead of `disabled` — there is no way to turn thinking off on these models. `runMiniCompletion` is unaffected (it runs on the resolved mini model, which is always Haiku). Model id is the dateless pinned snapshot `claude-fable-5` (1M context, 128k max output); registered in `MODEL_REGISTRY`.\n\n## i18n (Internationalization)\n\nTranslations live in `src/i18n/locales/{lang}.json`. All user-facing strings must use `t()` (React) or `i18n.t()` (non-React).\n\n### Locale registry (single source of truth)\n\nAll locale metadata lives in **`src/i18n/registry.ts`**. To add a new locale:\n\n1. Create `src/i18n/locales/{code}.json` with all keys (copy from `en.json`)\n2. Import the messages and `date-fns` locale in `registry.ts`\n3. Add one entry to `LOCALE_REGISTRY`\n\n**That's it.** `SUPPORTED_LANGUAGE_CODES`, `LANGUAGES`, i18n resources, and `getDateLocale()` are all derived automatically. No other file needs to change.\n\n### Key naming convention\n\nKeys use **flat dot-notation** with a category prefix:\n\n| Prefix | Scope | Example |\n|--------|-------|---------|\n| `common.*` | Shared labels (Cancel, Save, Close, Edit, Loading...) | `common.cancel` |\n| `menu.*` | App menu items (File, Edit, View, Window) | `menu.toggleSidebar` |\n| `sidebar.*` | Left sidebar navigation items | `sidebar.allSessions` |\n| `sidebarMenu.*` | Sidebar context menu actions | `sidebarMenu.addSource` |\n| `sessionMenu.*` | Session context menu actions | `sessionMenu.archive` |\n| `settings.*` | Settings pages — nested by page ID | `settings.ai.connections` |\n| `chat.*` | Chat input, session viewer, inline UI | `chat.attachFiles` |\n| `toast.*` | Toast/notification messages | `toast.failedToShare` |\n| `errors.*` | Error screens | `errors.sessionNotFound` |\n| `onboarding.*` | Onboarding flow — nested by step | `onboarding.welcome.title` |\n| `dialog.*` | Modal dialogs | `dialog.reset.title` |\n| `apiSetup.*` | API connection setup | `apiSetup.modelTier.best` |\n| `workspace.*` | Workspace creation/management | `workspace.createNew` |\n| `sourceInfo.*` | Source detail page | `sourceInfo.connection` |\n| `skillInfo.*` | Skill detail page | `skillInfo.metadata` |\n| `automations.*` | Automation list/detail/menus | `automations.runTest` |\n| `sourcesList.*` | Sources list panel | `sourcesList.noSourcesConfigured` |\n| `skillsList.*` | Skills list panel | `skillsList.addSkill` |\n| `editPopover.*` | EditPopover labels/placeholders | `editPopover.label.addSource` |\n| `status.*` | Session status names (by status ID) | `status.needs-review` |\n| `mode.*` | Permission mode names (by mode ID) | `mode.safe` |\n| `hints.*` | Empty state workflow suggestions | `hints.summarizeGmail` |\n| `table.*` | Data table column headers | `table.access` |\n| `time.*` | Relative time strings | `time.minutesAgo_other` |\n| `session.*` | Session list UI | `session.noSessionsYet` |\n| `shortcuts.*` | Keyboard shortcuts descriptions | `shortcuts.sendMessage` |\n| `sendToWorkspace.*` | Send to workspace dialog | `sendToWorkspace.title` |\n| `webui.*` | WebUI-specific strings | `webui.connectionFailed` |\n| `auth.*` | Auth banner/prompts | `auth.connectionRequired` |\n| `browser.*` | Browser empty state | `browser.readyTitle` |\n\n### Rules\n\n1. **Never call `i18n.t()` at module level** — store `labelKey` strings and resolve in components/functions.\n2. **Use i18next pluralization** (`_one`/`_other`), never manual `count === 1 ?` logic.\n3. **Keep brand names in English**: Craft, sapstack Desktop, Agents, Workspace, Claude, Anthropic, OpenAI, MCP, API, SDK.\n4. **Include `...` in the translation value** if the UI needs an ellipsis — don't append it in JSX.\n5. **Use `<Trans>` component** for translations containing HTML tags (e.g. `<strong>`).\n6. **Use `i18n.resolvedLanguage`** (not `i18n.language`) when comparing against supported language codes.\n7. **Keys must exist in every locale file** in `src/i18n/locales/` (not just `en.json` — `lint:i18n:parity` enforces this across the full set). Keep alphabetically sorted.\n8. **Watch translation length for constrained UI elements.** Translations can be 20-100%+ longer than English. For buttons, badges, tab labels, and dropdown items, keep translations concise — use shorter synonyms if needed. High-risk areas:\n - Permission mode badges (3-5 characters max)\n - Settings tab labels (≤10 characters ideal)\n - Button labels (avoid exceeding 2x the English length)\n - Menu items (flexible, but avoid 3x+ growth)\n\n### Validation\n\nThree checks gate i18n correctness, all wired into pre-commit (`lint:i18n:staged`) and `validate:ci`:\n\n| Script | Catches |\n|--------|---------|\n| `lint:i18n:sorted` | locale keys not alphabetical |\n| `lint:i18n:parity` | non-EN locale missing keys present in `en.json`, or vice versa |\n| `lint:i18n:coverage` | `t('...')` callsite referencing a key that doesn't exist in `en.json` |\n\n`parity` alone is insufficient — it can't detect symmetric losses across all locales (a merge that drops the same 50 keys from every locale file passes parity but breaks the UI). `coverage` closes that gap by verifying every literal `t(...)` / `i18n.t(...)` / `<Trans i18nKey>` reference resolves against `en.json`. Dynamic keys (`t(\\`status.${id}\\`)`) are skipped — those surface via i18next's runtime missing-key warnings.\n\nWhen resolving locale merge conflicts, run `bun run validate:ci` and trust the result — no manual key auditing needed if all three pass.\n\n### Adding a new translated string\n\n1. Add the key + English value to `en.json` (alphabetical order)\n2. Add the key + translated value to **every other** `src/i18n/locales/*.json` file (run `bun run lint:i18n:parity` to confirm none were missed)\n3. Use `t(\"your.key\")` in the component (add `useTranslation()` hook if not present)\n4. For non-React code, use `i18n.t(\"your.key\")` — but only inside functions, never at module level\n\n### Adding a new locale\n\n1. Create `src/i18n/locales/{code}.json` with all keys from `en.json`\n2. Add the entry to `LOCALE_REGISTRY` in `src/i18n/registry.ts` (messages + date-fns locale + native name)\n3. Run tests — the registry tests will catch any missing wiring\n\n### Cross-process language persistence\n\nThe main-process i18n instance has **no detection plugin** (no `localStorage` in Node) and would otherwise reset to `fallbackLng: 'en'` on every restart. To keep main + renderer in sync across launches:\n\n- **Renderer** uses `i18next-browser-languagedetector` → `localStorage` (`i18nextLng`). Survives restart.\n- **Main** hydrates on startup from `preferences.uiLanguage` in `~/.sapstack-desktop/preferences.json`. Maintained only by the `i18n:changeLanguage` IPC handler in `apps/electron/src/main/index.ts`.\n- **Renderer → main sync** happens on every Appearance change AND once at renderer startup (so a freshly-installed app immediately learns the persisted language).\n- The IPC handler validates the incoming code against `SUPPORTED_LANGUAGE_CODES` and `setPersistedUiLanguage()` no-ops if the value is unchanged — startup pushes don't churn the file or the config watcher.\n\n`uiLanguage` is **not** user-editable through `update_user_preferences`. The Appearance dropdown is the only writer.\n\n**Session-title language** resolves from this same persisted `uiLanguage` via `resolveTitleLanguageName()` (`config/preferences.ts`), **not** `i18n.resolvedLanguage`. The main-process i18n value hydrates asynchronously at startup and can still read the `'en'` fallback when an early title generates, which forced English titles for non-English chats (#885). When no language is persisted the helper returns `undefined`, so the title prompt auto-detects the conversation language instead of defaulting to English. Used at both `SessionManager` title sites (`generateTitle`, `refreshTitle`).\n\n## Token refresh for API sources\n\nAPI sources can auto-refresh tokens via two paths:\n- **OAuth** — Google, Slack, Microsoft, or generic OAuth (`authType: 'oauth'`)\n- **Renew endpoint** — custom bearer-token APIs with `api.renewEndpoint` in config.json\n\nFor renew-endpoint sources, the current access token is sent to the configured endpoint and a new token is extracted from the response. No separate refresh token is needed (MVP scope).\n\nKey integration points:\n- `isRefreshableSource()` in `types.ts` — single guard for \"can this source auto-refresh?\"\n- `SourceCredentialManager.refreshApiRenew()` — calls the renew endpoint\n- `TokenRefreshManager` — treats renew-endpoint sources as refreshable even without `refreshToken`\n- `server-builder.ts` — passes a token getter (not static credential) for renew-endpoint sources\n\n## `queryLlm` backend contract\n\nEvery `AgentBackend.queryLlm(request: LLMQueryRequest)` implementation MUST:\n- honor `request.model` (with backend-specific fallback only when the model is\n unresolvable/unsupported; always report the *effective* model in\n `LLMQueryResult.model`)\n- honor `request.systemPrompt`\n\nSHOULD:\n- honor `request.outputSchema` (at minimum via prompt injection — see\n `buildCallLlmRequest` in `agent/llm-tool.ts`, which already handles this\n pre-backend)\n\nMAY:\n- honor `request.maxTokens` and `request.temperature` if the underlying SDK\n supports passing these to its generation call\n\nMUST NOT:\n- return a fabricated `LLMQueryResult.model` that doesn't match what was actually\n used — downstream UI treats this as authoritative\n\nIPC envelopes between the main process and any subprocess backend (Pi today,\npotentially others) MUST carry the full `LLMQueryRequest`, not a subset.\nA backend that invents a narrower envelope is guaranteed to drift over time\n(see #596). The round-trip invariant is guarded by\n`packages/shared/src/agent/__tests__/pi-query-llm.test.ts`.\n\n## Source of truth\n- Package exports: `packages/shared/src/index.ts` and subpath export entries.\n- Agent exports: `packages/shared/src/agent/index.ts`\n"}}}}